mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
fix(release): make changelog ranges channel-aware
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
# The agent runs read-only git/gh commands itself instead of being fed
|
||||
# pre-chewed diff fragments, so nothing user-visible is missed by grep luck.
|
||||
#
|
||||
# Usage: ./scripts/generate-release-notes.sh <version> [previous-tag]
|
||||
# Usage: ./scripts/generate-release-notes.sh <version> [comparison-tag]
|
||||
# ./scripts/generate-release-notes.sh --resolve-base <version>
|
||||
#
|
||||
# Contract: the release notes markdown is written to STDOUT (trigger-release.sh
|
||||
# captures it); all progress/diagnostics go to STDERR. SAVE_TO_FILE=1 also
|
||||
@@ -19,30 +20,91 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE=generate
|
||||
if [ "${1:-}" = "--resolve-base" ]; then
|
||||
MODE=resolve-base
|
||||
shift
|
||||
fi
|
||||
|
||||
VERSION=${1:-}
|
||||
PREVIOUS_TAG=${2:-}
|
||||
REQUESTED_COMPARISON_TAG=${2:-}
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <version> [previous-tag]" >&2
|
||||
echo "Example: $0 6.1.0 v6.0.6" >&2
|
||||
echo "Usage: $0 <version> [comparison-tag]" >&2
|
||||
echo " $0 --resolve-base <version>" >&2
|
||||
echo "Example: $0 6.4.0-rc.6" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous tag found, cannot generate diff-based release notes" >&2
|
||||
exit 1
|
||||
VERSION=${VERSION#v}
|
||||
|
||||
latest_stable_before() {
|
||||
local target_tag="v$1"
|
||||
local candidate
|
||||
|
||||
while IFS= read -r candidate; do
|
||||
[ "$candidate" = "$target_tag" ] && continue
|
||||
if [ "$(printf '%s\n%s\n' "$candidate" "$target_tag" | sort -V | head -n 1)" = "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
fi
|
||||
done < <(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)
|
||||
}
|
||||
|
||||
resolve_comparison_tag() {
|
||||
local version=$1
|
||||
local base rc expected
|
||||
|
||||
if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc\.([0-9]+)$ ]]; then
|
||||
base=${BASH_REMATCH[1]}
|
||||
rc=${BASH_REMATCH[2]}
|
||||
if (( rc > 1 )); then
|
||||
expected="v${base}-rc.$((rc - 1))"
|
||||
if ! git merge-base --is-ancestor "$expected" HEAD 2>/dev/null; then
|
||||
echo "Expected immediately preceding RC tag '$expected' is not an ancestor of HEAD" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$expected"
|
||||
return
|
||||
fi
|
||||
elif [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Unsupported release version '$version'; expected X.Y.Z or X.Y.Z-rc.N" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
latest_stable_before "${base:-$version}" | tail -n 1
|
||||
}
|
||||
|
||||
EXPECTED_COMPARISON_TAG=$(resolve_comparison_tag "$VERSION")
|
||||
if [ -z "$EXPECTED_COMPARISON_TAG" ]; then
|
||||
echo "No valid comparison tag found for v${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git rev-parse -q --verify "${PREVIOUS_TAG}^{commit}" >/dev/null; then
|
||||
echo "Previous tag '${PREVIOUS_TAG}' does not exist" >&2
|
||||
if [ -n "$REQUESTED_COMPARISON_TAG" ] && [ "$REQUESTED_COMPARISON_TAG" != "$EXPECTED_COMPARISON_TAG" ]; then
|
||||
echo "Comparison tag '$REQUESTED_COMPARISON_TAG' violates the release-note range for v${VERSION}; expected '$EXPECTED_COMPARISON_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS_TAG=$EXPECTED_COMPARISON_TAG
|
||||
|
||||
if ! git rev-parse -q --verify "${PREVIOUS_TAG}^{commit}" >/dev/null; then
|
||||
echo "Comparison tag '${PREVIOUS_TAG}' does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "resolve-base" ]; then
|
||||
printf '%s\n' "$PREVIOUS_TAG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == *-rc.* ]]; then
|
||||
RELEASE_RANGE_GUIDANCE="This is an RC release. Cover only the user-visible changes since the immediately preceding RC, ${PREVIOUS_TAG}. Do not repeat changes already announced in an earlier RC."
|
||||
else
|
||||
RELEASE_RANGE_GUIDANCE="This is a stable GA release. Cover the complete release train since the previous stable release, ${PREVIOUS_TAG}. Synthesize that potentially large commit range into a few user-relevant themes; do not concatenate RC notes or enumerate every commit."
|
||||
fi
|
||||
|
||||
echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
|
||||
|
||||
read -r -d '' PROMPT <<EOF || true
|
||||
@@ -60,8 +122,12 @@ commits that reference GitHub issues (#1234), you may use \`gh issue view\` to
|
||||
understand the user-facing symptom. Only describe changes that exist in the
|
||||
final code state; verify anything you are unsure about before writing it.
|
||||
|
||||
${RELEASE_RANGE_GUIDANCE}
|
||||
|
||||
Focus on USER-VISIBLE changes only: features, fixes, and behavior users will
|
||||
notice. Ignore internal refactors, test changes, CI/tooling, and docs.
|
||||
notice. Ignore internal refactors, test changes, CI/tooling, and docs. Group
|
||||
related commits into the outcome a user would recognize, such as a rewritten
|
||||
alert system, rather than listing the implementation steps separately.
|
||||
|
||||
Write the release notes in exactly this format:
|
||||
|
||||
@@ -77,12 +143,6 @@ with what feels better or works now, not how it was implemented.]
|
||||
[Use 4-6 meaningful bullets for a normal RC or minor release. A narrow patch
|
||||
may use fewer. Keep every bullet concrete and independently useful.]
|
||||
|
||||
## Fixes
|
||||
|
||||
- [A visible problem that no longer happens.]
|
||||
|
||||
[Omit this section only when there are genuinely no user-facing fixes.]
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
[Only user-relevant compatibility, migration, signing, companion-app, or known
|
||||
@@ -95,6 +155,9 @@ Guidelines:
|
||||
- Every bullet must stand on its own. A reader should understand where they
|
||||
would notice the change and what is different without knowing Pulse's
|
||||
implementation.
|
||||
- Put features and fixes together under What's improved. Describe each
|
||||
user-visible outcome exactly once; do not repeat it under another heading or
|
||||
split one outcome into separate feature and fix bullets.
|
||||
- Avoid internal release and architecture vocabulary such as canonical,
|
||||
governed, schema, provider transport, preflight, convergence, or runtime
|
||||
boundary unless that exact term is visible to the user in the product.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -884,18 +885,26 @@ func TestCurrentPrereleasePacketTracksInstallMetadata(t *testing.T) {
|
||||
assertFileContainsAllNormalized(t, releaseNotesPath,
|
||||
"# Pulse v"+version+" Release Notes",
|
||||
"## What's improved",
|
||||
"## Fixes",
|
||||
"## Before you upgrade",
|
||||
"## Known issues",
|
||||
"Complete standalone PBS details",
|
||||
"Complete LXC filesystem coverage",
|
||||
"Earlier disk-cabling warnings",
|
||||
"Alert policy is resolved consistently",
|
||||
"Alert-event queries now allocate",
|
||||
"More predictable alerts",
|
||||
"Safer alert-history queries",
|
||||
"Pulse Mobile does not consume the changed PBS browser detail or alert evaluation internals",
|
||||
"not Authenticode-signed",
|
||||
"Unknown Publisher warning",
|
||||
)
|
||||
assertFileDoesNotContain(t, releaseNotesPath, "## Fixes")
|
||||
for _, issueURL := range []string{
|
||||
"https://github.com/rcourtman/Pulse/issues/1723",
|
||||
"https://github.com/rcourtman/Pulse/issues/1477",
|
||||
"https://github.com/rcourtman/Pulse/issues/1776",
|
||||
} {
|
||||
assertFileContainsExactlyOnce(t, releaseNotesPath, issueURL)
|
||||
assertFileContainsExactlyOnce(t, changelogPath, issueURL)
|
||||
}
|
||||
assertFileContainsAllNormalized(t, changelogPath,
|
||||
"Version: `v"+version+"`",
|
||||
"Previous stable: `v"+previous+"`",
|
||||
@@ -3065,6 +3074,74 @@ func assertFileContainsAll(t *testing.T, path string, required ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNotesGeneratorResolvesChannelSpecificComparisonRanges(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
commit := func(message string) {
|
||||
t.Helper()
|
||||
runGit("commit", "--allow-empty", "--no-gpg-sign", "-m", message)
|
||||
}
|
||||
|
||||
runGit("init", "-b", "main")
|
||||
runGit("config", "user.name", "Pulse Release Test")
|
||||
runGit("config", "user.email", "release-test@example.invalid")
|
||||
commit("stable 6.3.1")
|
||||
runGit("tag", "v6.3.1")
|
||||
runGit("checkout", "-b", "release-v6.3.2")
|
||||
commit("stable 6.3.2 hotfix")
|
||||
runGit("tag", "v6.3.2")
|
||||
runGit("checkout", "main")
|
||||
for rc := 1; rc <= 5; rc++ {
|
||||
commit("release candidate " + strconv.Itoa(rc))
|
||||
runGit("tag", "v6.4.0-rc."+strconv.Itoa(rc))
|
||||
}
|
||||
commit("release candidate 6 changes")
|
||||
|
||||
generator, err := filepath.Abs(repoFile("scripts", "generate-release-notes.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve release-note generator path: %v", err)
|
||||
}
|
||||
resolve := func(version string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("bash", generator, "--resolve-base", version)
|
||||
cmd.Dir = repo
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve comparison base for %s: %v\n%s", version, err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
if got := resolve("6.4.0-rc.6"); got != "v6.4.0-rc.5" {
|
||||
t.Fatalf("RC comparison base = %q, want v6.4.0-rc.5", got)
|
||||
}
|
||||
if got := resolve("6.4.0-rc.1"); got != "v6.3.2" {
|
||||
t.Fatalf("RC1 comparison base = %q, want v6.3.2", got)
|
||||
}
|
||||
if got := resolve("6.4.0"); got != "v6.3.2" {
|
||||
t.Fatalf("GA comparison base = %q, want v6.3.2", got)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bash", generator, "6.4.0-rc.6", "v6.4.0-rc.4")
|
||||
cmd.Dir = repo
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatal("generator accepted a comparison tag older than the immediately preceding RC")
|
||||
}
|
||||
if !strings.Contains(string(output), "expected 'v6.4.0-rc.5'") {
|
||||
t.Fatalf("unexpected comparison-range rejection:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContainsAllNormalized(t *testing.T, path string, required ...string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
@@ -3079,6 +3156,34 @@ func assertFileContainsAllNormalized(t *testing.T, path string, required ...stri
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileDoesNotContain(t *testing.T, path string, forbidden ...string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
s := string(content)
|
||||
for _, needle := range forbidden {
|
||||
if strings.Contains(s, needle) {
|
||||
t.Fatalf("%s contains forbidden substring: %s", path, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContainsExactlyOnce(t *testing.T, path string, required ...string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
s := string(content)
|
||||
for _, needle := range required {
|
||||
if count := strings.Count(s, needle); count != 1 {
|
||||
t.Fatalf("%s contains %q %d times, want exactly once", path, needle, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedInstallTestWhitespace(text string) string {
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
@@ -170,6 +170,20 @@ def _requires_customer_facing_standard(version: str) -> bool:
|
||||
return core is not None and core >= _CUSTOMER_FORMAT_MINIMUM
|
||||
|
||||
|
||||
def _requires_single_change_list(version: str) -> bool:
|
||||
"""Return whether fixes must be folded into the one customer outcome list."""
|
||||
|
||||
normalized = version.lower().removeprefix("v")
|
||||
core = _release_core(normalized)
|
||||
if core is None or core < (6, 4, 0):
|
||||
return False
|
||||
if core > (6, 4, 0):
|
||||
return True
|
||||
|
||||
rc_match = re.fullmatch(r"6\.4\.0-rc\.(\d+)", normalized)
|
||||
return rc_match is None or int(rc_match.group(1)) >= 6
|
||||
|
||||
|
||||
def _section_lines(text: str, heading_index: int) -> list[str]:
|
||||
lines = _normalize_newlines(text).splitlines()
|
||||
section: list[str] = []
|
||||
@@ -198,7 +212,7 @@ def _flat_bullet_items(lines: list[str], section_name: str) -> list[str]:
|
||||
return items
|
||||
|
||||
|
||||
def _validate_customer_facing_release_notes(text: str) -> None:
|
||||
def _validate_customer_facing_release_notes(text: str, version: str) -> None:
|
||||
"""Enforce concise public notes without release-control implementation prose."""
|
||||
|
||||
lines = _normalize_newlines(text).strip().splitlines()
|
||||
@@ -265,6 +279,11 @@ def _validate_customer_facing_release_notes(text: str) -> None:
|
||||
)
|
||||
|
||||
if "fixes" in headings:
|
||||
if _requires_single_change_list(version):
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"customer-facing release notes must describe features and fixes once "
|
||||
"in What's improved instead of adding a separate Fixes section"
|
||||
)
|
||||
fixes = _flat_bullet_items(_section_lines(text, headings["fixes"]), "Fixes")
|
||||
if not fixes or len(fixes) > _MAX_CUSTOMER_FIX_ITEMS:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
@@ -314,7 +333,7 @@ def validate_release_notes_shape(raw_text: str, version: str) -> None:
|
||||
|
||||
_highlight_items(text)
|
||||
if _requires_customer_facing_standard(version):
|
||||
_validate_customer_facing_release_notes(text)
|
||||
_validate_customer_facing_release_notes(text, version)
|
||||
|
||||
|
||||
def strip_validation_status_block(text: str) -> str:
|
||||
|
||||
@@ -190,7 +190,7 @@ class RenderReleaseBodyTest(unittest.TestCase):
|
||||
render_release_body.validate_release_notes_shape(notes, "6.2.1")
|
||||
|
||||
def test_future_release_notes_require_customer_facing_structure(self) -> None:
|
||||
notes = """# Pulse v6.4.0-rc.2 Release Notes
|
||||
notes = """# Pulse v6.4.0-rc.6 Release Notes
|
||||
|
||||
Pulse is faster and more predictable in larger environments.
|
||||
|
||||
@@ -198,17 +198,34 @@ Pulse is faster and more predictable in larger environments.
|
||||
|
||||
- **Faster infrastructure views** — Tables stay responsive as estates grow.
|
||||
- **Lighter realtime updates** — Pages do less work when resources change.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Saved API keys are no longer returned to the browser.
|
||||
- **Safer API key handling** — Saved API keys are no longer returned to the browser.
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
No manual migration is required.
|
||||
"""
|
||||
|
||||
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.2")
|
||||
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.6")
|
||||
|
||||
def test_current_release_notes_reject_a_second_fixes_list(self) -> None:
|
||||
notes = """# Pulse v6.4.0-rc.6 Release Notes
|
||||
|
||||
Pulse is faster and more predictable in larger environments.
|
||||
|
||||
## What's improved
|
||||
|
||||
- **Faster infrastructure views** — Tables stay responsive as estates grow.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Infrastructure tables no longer stall in larger estates.
|
||||
"""
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
render_release_body.ReleaseBodyIntegrityError,
|
||||
"features and fixes once",
|
||||
):
|
||||
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.6")
|
||||
|
||||
def test_customer_facing_standard_exempts_only_the_already_cut_rc1(self) -> None:
|
||||
self.assertFalse(
|
||||
@@ -300,9 +317,11 @@ Pulse is faster and more predictable in larger environments.
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("## What's improved", template)
|
||||
self.assertIn("## Fixes", template)
|
||||
self.assertIn("## Before you upgrade", template)
|
||||
self.assertIn("four to six meaningful improvements", template)
|
||||
self.assertIn("For an RC, cover only changes since the immediately preceding RC", template)
|
||||
self.assertIn("For a stable GA release", template)
|
||||
self.assertIn("Do not add a separate `Fixes` section", template)
|
||||
self.assertNotIn("\n## Fixes\n", template)
|
||||
self.assertIn("pipeline appends the `Install` and `Roll back` sections", template)
|
||||
self.assertNotIn("## Release Qualification", template)
|
||||
self.assertNotIn("## Promotion Metadata", template)
|
||||
|
||||
@@ -156,10 +156,7 @@ else
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo "Generating release notes..."
|
||||
# Try to find previous tag for better context
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
if ./scripts/generate-release-notes.sh "$VERSION" "$PREV_TAG" > "$NOTES_FILE"; then
|
||||
if ./scripts/generate-release-notes.sh "$VERSION" > "$NOTES_FILE"; then
|
||||
echo "Release notes generated at ${NOTES_FILE}"
|
||||
echo ""
|
||||
# Show first few lines
|
||||
|
||||
Reference in New Issue
Block a user