Skip to content

fix(release): promote the curated [Unreleased] instead of regenerating - #154

Merged
adnaan merged 2 commits into
mainfrom
fix/release-promote-unreleased
Jul 20, 2026
Merged

fix(release): promote the curated [Unreleased] instead of regenerating#154
adnaan merged 2 commits into
mainfrom
fix/release-promote-unreleased

Conversation

@adnaan

@adnaan adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Ports the core library's fix to the client, which has the same defect. Refs livetemplate/livetemplate#511.

The problem

release.sh rebuilt CHANGELOG.md from a raw git log dump on every release, so a hand-written ## [Unreleased] section never became the release notes. It survived only as a stranded section below the new version, still titled [Unreleased], while the notes that shipped were bare commit subjects.

Hit four times in two days — core v0.19.1 and v0.20.0, client v0.19.1 and v0.20.0 — each needing a manual curation commit plus a gh release edit, since publish_github extracts its notes from the clobbered file.

The fix

Promote instead of regenerate. Retitle [Unreleased] as the release heading, leave its content alone. This is what the file's own header already claims: Keep a Changelog's workflow is maintain [Unreleased], promote on release.

The commit-subject dump stays as the fallback for releases nobody curated, so nothing regresses when the section is absent or empty.

Refuse to start on a duplicate [Unreleased]. Release-note extraction stops at the first heading, so a duplicate means one section can never ship. That is exactly how the stale v0.8.5 notice sat unnoticed in this file until #509 — this makes the same situation loud:

✗ CHANGELOG.md has 2 '## [Unreleased]' headings; there must be at most one

1207:## [Unreleased]
1211:## [Unreleased]

Merge them into the topmost one, or retitle the stale section with the
version it actually shipped in, then re-run.

It runs in check_prerequisites, before anything mutates.

Verification

scripts/test_release_changelog.sh — new, sourcing release.sh with RELEASE_SH_LIB=1 so the functions are testable without running a release:

1️⃣  Curated [Unreleased] is promoted, content preserved verbatim
2️⃣  Empty [Unreleased] falls through to the commit-subject fallback
3️⃣  Whitespace-only [Unreleased] counts as empty
3️⃣b A curated section does NOT take the fallback
4️⃣  unreleased_body reads only its own section

These were run against the previous release.sh to confirm they discriminate. Two fail there, reporting the exact defect:

✗ [Unreleased] survived the promotion
      8:## [Unreleased]
✗ history damaged

Tests 2/3 assert on which branch was taken rather than the resulting file — the client's fallback writes an "Initial release" block when no tag exists, so the end state differs between repos while the promote-vs-regenerate decision does not. That keeps one test file valid for both.

Duplicate guard verified by injecting two headings and confirming it aborts before the dirty-tree check.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG

Ports the core library's fix (livetemplate/livetemplate#511) to the client,
which has the same defect.

release.sh rebuilt CHANGELOG.md from a raw git log dump on every release,
so a hand-written [Unreleased] section never became the release notes. It
survived only as a stranded section below the new version, still titled
[Unreleased], while the notes that shipped were bare commit subjects — the
release-script chore billed equally with the change users see.

Hit on both v0.19.1 and v0.20.0, each needing a manual curation commit
plus a gh release edit, since publish_github extracts its notes from the
clobbered file.

Promote instead: retitle [Unreleased] as the release heading and leave its
content alone. The commit-subject dump stays as the fallback for releases
nobody curated, so nothing regresses when the section is absent or empty.

Also refuse to start when the file has more than one [Unreleased] heading,
since release-note extraction stops at the first and a duplicate means one
section can never ship.

scripts/test_release_changelog.sh is shared with core, asserting on which
branch was taken rather than on the resulting file — this repo's fallback
writes an "Initial release" block when no tag exists, so the end state
differs while the promote-vs-regenerate decision does not.

Refs livetemplate/livetemplate#511

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Solid fix — the core defect (regenerating over a curated [Unreleased] section) is real and the promote-instead-of-regenerate approach correctly follows Keep a Changelog's own workflow. The extraction into small, testable functions (unreleased_body, promote_unreleased) and the RELEASE_SH_LIB=1 sourcing trick for testing an otherwise main()-only script are nice touches.

Gap: the duplicate-heading guard has no automated test coverage.
The PR description says "Duplicate guard verified by injecting two headings and confirming it aborts before the dirty-tree check," but scripts/test_release_changelog.sh only tests generate_changelog/unreleased_body/promote_unreleased (tests 1–4). None of them call check_prerequisites or assert on the "has 2 ## [Unreleased] headings" error path — so that verification apparently happened manually and isn't checked into the regression suite. Since this guard is the second half of the fix (and the one preventing the next incident, per issue #511), it'd be worth either:

  • extracting the duplicate-count check into its own small function (mirroring unreleased_body/promote_unreleased) so it can be unit tested without needing gh auth/npm to be present, or
  • adding a test that sources just that logic against a synthetic two-heading CHANGELOG.md.

Minor / non-blocking:

  • In the fallback (commit-subject dump) branch with a prev_tag present, tail -n +7 CHANGELOG.md will still carry forward a stale empty [Unreleased] heading from the previous file into the new one (pre-existing behavior, not introduced by this PR, but worth a follow-up since it's adjacent to what this PR is cleaning up).
  • local unreleased_headings; unreleased_headings=$(grep -c ... 2>/dev/null || true)grep -c already prints 0 on no match, so the ${unreleased_headings:-0} fallback is only reachable when CHANGELOG.md is missing entirely; harmless but worth a one-line comment if intentional.

No security or correctness issues found in the awk-based promotion logic itself (safe use of -v for interpolation, no injection risk from version/date strings).

Ports the same two follow-ups made in the core library's PR.

The took_fallback helper piped generate_changelog into `grep -q`. Sourcing
release.sh brings `set -o pipefail` with it, and grep -q exits on its first
match, so the closed pipe could make generate_changelog exit non-zero and
fail the whole pipeline — intermittently, depending on which finished
first. Measured at 9 failures in 15 runs in the core repo; capturing to a
variable instead is 0 in 30 here and there.

The duplicate-[Unreleased] guard now also names the likeliest cause: a
release that takes the commit-subject fallback leaves an empty
[Unreleased] behind, so a later curated one added above it trips the
guard, and the empty section is the one to drop.

Refs livetemplate/livetemplate#511

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

What it does: Ports the core-library fix so release.sh promotes a hand-curated ## [Unreleased] section into the release heading instead of clobbering it with a raw commit-subject dump, and adds a check_prerequisites guard that aborts if CHANGELOG.md has more than one ## [Unreleased] heading.

Correctness

  • unreleased_body/promote_unreleased logic is sound: the awk state machine correctly scopes to content between ## [Unreleased] and the next ## [ heading, and promote_unreleased only rewrites the first match (guarded by the duplicate check upstream) leaving surrounding content untouched.
  • The empty-vs-whitespace-only detection (unreleased_body | tr -d '[:space:]') correctly falls back to the commit-subject dump so nothing regresses for uncurated releases.
  • The grep -c ... || true / ${unreleased_headings:-0} handling correctly tolerates a missing CHANGELOG.md under set -euo pipefail without spuriously tripping the duplicate check.
  • The RELEASE_SH_LIB=1 sourcing guard is a clean, minimal way to make the script's functions testable without restructuring it.

Test coverage gap

scripts/test_release_changelog.sh covers unreleased_body, promote_unreleased, and the promote-vs-fallback decision in generate_changelog well (including the whitespace-only edge case). However, the new duplicate-[Unreleased]-heading guard added to check_prerequisites — arguably the more novel/riskier part of this change — has no automated test; the PR description says it was verified manually. Worth adding a case to the test script (or a small standalone one) that sources release.sh and asserts check_prerequisites exits non-zero on a two-heading CHANGELOG.md, so this doesn't silently regress later.

Minor

  • The duplicate check matches only the exact literal ^## [Unreleased]; a heading with trailing whitespace (e.g. ## [Unreleased] ) would silently bypass both the duplicate guard and the promotion logic. Unlikely in practice given the file is hand-maintained, but worth a passing thought if this ever gets less careful editors.

Overall the change is well-scoped, the reasoning in the comments is clear, and the fallback path is preserved so this shouldn't regress uncurated releases. Main actionable item is the missing automated coverage for the duplicate-heading guard.

@adnaan
adnaan merged commit a1d43e5 into main Jul 20, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant