fix(release): make changelog ranges channel-aware

This commit is contained in:
rcourtman
2026-08-27 11:56:06 +01:00
parent 248b242034
commit ff27c3881f
9 changed files with 276 additions and 66 deletions
+81 -18
View File
@@ -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), " ")
}
+21 -2
View File
@@ -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)
+1 -4
View File
@@ -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