fix(release): stamp the version into the changelog - #69
Conversation
The release PR refreshed the Unreleased section and never wrote a section for the version being released, so nothing in the pipeline ever converted Unreleased into `## [X.Y.Z]`. release-tag.yml touches the changelog not at all. That is silently destructive. Once a tag exists, git cliff stops reporting the commits it covers as unreleased, and update-unreleased.sh drops the stale block by design -- so entries that never got a versioned section have nowhere to go. v0.1.0 shipped on 2026-08-05 with no `## [0.1.0]` section, and the v0.1.1 release PR consequently deleted 134 lines of history: 383 lines in, 264 out. stamp-changelog.sh regenerates with `--tag`, which rebuilds the file with a section per tag and the pending release at the top. Verified against the real repository: 398 lines, with `## [0.1.0]` restored and carrying the entries the release PR had removed. Regenerating wholesale is safe because released sections derive from tags and commits; the only differences against the committed file are whitespace that the workflow's prettier step normalizes right after. It refuses rather than writes when the result looks wrong -- empty output, no section for the pending version, or fewer sections than it replaces -- because a silent shrink is exactly the failure that cost this repository its v0.1.0 entries. Seven tests cover those guards using a git-cliff stub, so the suite needs no fixture repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR Summary by QodoFix release workflow to stamp versioned changelog sections
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
1.
|
The pending version reaches git-cliff as a tag and the section guard as a grep pattern, but stamp-changelog.sh trusted whatever it was handed. The release workflow validates first, so this only bites a standalone run -- which the usage line invites. Normalize through parse-version.sh, the same strict parser the three release workflows already share, and escape the dots that survive it so `## [1.2.3]` no longer also matches `## [1x2x3]`. Reported by Qodo.
|
/agentic_review |
|
[Review-Convergence] Round 1: active
Round 1 dispositions — 3 Qodo findings, all thread-backed
All three threads carry a reply and are resolved. Qodo has since struck all three in its persistent summary ( Why Qodo still counts as pending. Its summary |
|
Code review by qodo was updated up to the latest commit abeb632 |
Normalizing the tag through parse-version.sh made locating that helper load-bearing, and nothing covered where the lookup happens. Invoke through PATH from an unrelated directory with a decoy parse-version.sh planted in it, and assert the real sibling still wins. Prompted by a Qodo finding that read `dirname "$0"` as CWD-relative under PATH invocation. It is not -- the shell resolves through PATH and execs the absolute path, so "$0" already carries the real directory -- but the property is worth pinning now that it matters.
|
/agentic_review |
|
[Review-Convergence] Round 2: active
Round 2 disposition — 1 new Qodo finding on the round-1 fixQodo's pass on abeb632 flagged Declined on measured evidence. The repo-convention half was accurate — I implemented the suggested fix first and tested it against the decoy case before and after — identical results, which is what prompted checking the premise. Reverted rather than carry an unreachable branch, which also keeps this consistent with Kept: a test in ba08193 that invokes through PATH from an unrelated directory with a decoy |
|
Code review by qodo was updated up to the latest commit ba08193 |
The decoy test put the real scripts/release on PATH so the script could be named bare, which meant a regression to a bare `parse-version.sh` would still find the real helper there and the test would pass anyway. It only ever caught the `./` shape. Invoke through $STAMP by absolute path and put the decoy directory on PATH instead. Now a bare lookup and a `./` lookup both land on the decoy, and only a script-relative one wins. Verified by mutating the script both ways: each fails, baseline passes. Dropping the release_dir line also drops its unchecked command substitution, which the script's `set -uo pipefail` would not have caught. Reported by Qodo.
|
/agentic_review |
|
[Review-Convergence] Round 3: active
Round 3 dispositions — 2 new Qodo findings, both fixedQodo's pass on ba08193 returned a marker and two new findings, both against the test added in round 2. Helper lookup test masked — fixed. The better catch of the two. The test put the real
The bare-PATH row passed before this change. Unchecked Both findings were on runner-authored test code, which is worth naming: the round-2 test inherited its PATH framing from a round-2 finding whose premise turned out to be wrong. Qodo's read of what it actually proved was correct. |
|
Code review by qodo was updated up to the latest commit 86b964b |
|
[Review-Convergence] Round 4: converged
Qodo's pass on 86b964b posted a real-review marker, cleared its summary to Bugs (0) / Rule violations (0) / Skill insights (0), and left no unresolved threads. Nothing further is pending. Four rounds, six findings — full disposition
Three commits landed: abeb632 (validate the tag), ba08193 (pin helper lookup), 86b964b (make the decoy actually bite). Rounds 2 and 3 were spent on code this runner introduced rather than on the original change. Round 3's findings were both correct — the round-2 test claimed more than it proved, and Qodo caught it. This runner does not merge, approve, or resolve human threads. Merge policy is yours — note the repo is squash-merge only. |
Summary
Releases never wrote a changelog section for the version they released, and the next release then deleted the entries that had nowhere else to live.
release-pr.ymlranscripts/update-unreleased.sh, which refreshes the Unreleased block;changelog-autoupdate.ymlruns the same script weekly, which is correct for it; andrelease-tag.ymldoes not touch the changelog at all. Nothing in the pipeline ever converted[Unreleased]into## [X.Y.Z].The failure is silent rather than loud.
update-unreleased.shdrops the stale Unreleased block by design and regenerates it fromgit cliff --unreleased, which reports commits since the newest tag. The moment a release is tagged, its commits stop being unreleased — so if no versioned section was written for them, the next refresh removes them and puts nothing back.v0.1.0shipped on 2026-08-05 with no## [0.1.0]section inCHANGELOG.md, which jumps straight from[Unreleased]to[0.0.4], and #67 consequently proposed deleting 134 lines of shipped history: 383 lines in, 264 out, with no[0.1.0]section in its output either.scripts/release/stamp-changelog.shregenerates with--tag, which rebuilds the file with a section per tag and the pending release on top. Run against this repository it grows the file rather than shrinking it — 413 lines against the committed 383 — with## [0.1.0]restored, carrying the entries #67 had removed. Regenerating wholesale rather than splicing is safe because released sections derive from tags and commits, both immutable — the only differences against the committed file are whitespace that the workflow's existing prettier step normalizes on the very next line.The script refuses rather than writes when the result looks wrong: empty output, no section for the version being released, or fewer sections than the file it replaces. A silent shrink is precisely the failure that cost this repository its v0.1.0 entries, so it is worth failing the release PR over. It also normalizes the tag through
scripts/release/parse-version.sh— the strictvX.Y.Zparser the three release workflows already share — before that value reachesgit cliff --tagor the section guard's grep pattern, so a run by hand cannot put a metacharacter into either.Test plan
tests/test-unit.shpasses — 506 assertions across 7 suites, including 9 new onespre-commithook passes, includingshellcheck,shfmt,yamllint,actionlint, andgitlintgit cliff --tag v0.1.1yields 413 lines against the committed 383, with## [0.1.0]restored above the[0.0.4]section, and that section contains commits chore(release): prepare v0.1.1 #67 deletes — "add signed release workflows", "bump the README rev pin on release", "add benchmark runner scripts" all present[0.0.4]-and-older sections against the committed file shows only a double space and two blank lines, all of which prettier normalizes, and a whitespace-insensitive diff of that range is emptyparse-version.shor at./parse-version.sheach makes the suite fail, and only the script-relative form passesReviewer guide
git-cliffstub, which keeps the suite hermetic and free of a fixture repository.release-pleasewould remove the whole class by making its release PR the versioned section. It would also cost the two things this repository built deliberately:git tag -a -swith your own GPG key, guarded by a check that refuses to publish a tag that is not a validly signed annotated tag, and thereleaseenvironment's one-approval-per-release gate. A hybrid — release-please for version and changelog,release-tag.ymlretained for signed tagging — keeps both. This PR is the narrow fix and does not foreclose any of that.$0and does not dereference symlinks, so the script would not findparse-version.shif it were symlinked into abindirectory; nothing does that today.Notes
v0.1.0remains absent fromCHANGELOG.mdonmain. This PR fixes the process; the missing section arrives with the next release PR, whose regeneration will include it. #67 should be closed rather than merged — it was generated by the old path and still carries the deletion.Files touched
Status legend:
+added,~modified,-removed,→renamed,~/→renamed and modified.++git-cliffstub~~~~Closes #70 (review-convergence bulletin)