fix(release): re-arm the changelog heading, and guard it against VERSION (#66) #67
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#67
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/changelog-rearm"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #66.
What
The ceremony stamps
CHANGELOG.md's## Unreleasedheading to## X.Y.Z — YYYY-MM-DDand stops. Nothing re-arms it, so between that mergeand the next PR that happens to re-add the heading by hand,
mainhas no## Unreleased— and every PR still in flight wrote its entry under one.Git files those entries under whatever now occupies that position: the release
that already shipped. Cleanly, with no conflict — the stamped heading and
the incoming entry never overlap textually — so the one signal an author
relies on ("git told me to look") is missing exactly when the outcome is
wrong.
It already happened here. #60's
#58entry landed inside## 0.1.0at67386b4, repaired two minutes later by0ff520c. #54 would have filed aBREAKING entry the same way.
Two corrections to the issue as filed, both from the findings comment on #66:
the release job;
release.ymlonly ever touchesVERSION(:216-238).Stamping has always been by hand in the ceremony PR (
a702f1f). The-devre-arm the workflow does perform was never extended to the changelog. So
the fix lands in the ceremony step, not the workflow.
test/release.sh:103-107asserted only that whatever the top
##section is extracts non-empty.That relaxation was deliberate (#44): the old guard demanded a literal
## Unreleased, which is false by construction on the tree theceremony's own PR produces, and it turned CI red the day the first release
PR was opened. Re-adding that requirement is a closed path.
How
Both halves of #66's Option 3, with the placement corrected.
(a) Re-arm —
CONTRIBUTING.md, the ceremony step. The release PR now addsa fresh empty
## Unreleasedimmediately above the section it just stamped.A late merge then has somewhere correct to land with no author action,
which is what makes this a fix for the cause rather than a detector. There is
no scripted ceremony helper in this repo to also carry it — stamping is a hand
edit, so the prose step is the whole surface.
(b) A VERSION-keyed guard —
test/release.sh. The rule that distinguishesthe two states the old check collapsed:
-devis precisely the state in whichmainis a place feature PRs mergeinto, so that is precisely where the heading has to be armed. Bare
VERSIONis the ceremony PR and main until the bump lands — the states #44 was about —
and those stay green.
One asymmetry is deliberate: on a bare
VERSIONthe top heading is notconstrained at all. Because (a) re-arms in the ceremony PR itself, that tree
legitimately carries an empty
## Unreleasedabove the stamped section —and an empty top section is exactly what the old non-empty assert would have
rejected. So that assert is retargeted rather than kept: what must extract
non-empty on a bare
VERSIONis the section that actually ships, which isthe same assertion
release.ymlmakes before publishing. Net, the guard isstrictly stronger than the one it replaces, in both states.
Tests
changelog_armedruns against the real tree, and the rule is proven againstseven
VERSION+CHANGELOG.mdpairs built and actually executed, notargued about — a guard only ever run against a passing tree has not been shown
to fail:
VERSION0.2.0## Unreleased0.2.0## 0.2.0 — date0.2.1-dev## 0.2.0 — date0.2.1-dev## Unreleased0.2.1-dev## Unreleased+ entries0.3.0## Unreleased##heading at all0.2.1-devRows 1-2 are the #44 non-regression: the ceremony stays shippable with or
without the re-arm. Row 3 is #66 reproduced. Row 4 is the state
mainsitsin immediately after a release, and it must not require entries to exist yet.
Mutation check against the real tree, since the fixtures could in
principle pass vacuously: replacing the live
CHANGELOG.md's## Unreleasedwith
## 0.1.0 — 2026-07-19— reconstructing67386b4's actual state, withVERSIONat0.1.1-devas it is onmaintoday — turns the guard red:shellcheck -x test/release.sh— clean.Siblings
heavy-duty/boxandheavy-duty/castcarry the identical release flow(
heavy-duty/box#96) and the same exposure; both are getting this fix.heavy-duty/castis disarmed onmainas of this writing — itsmainisthe 0.1.0 tag with zero commits after, top heading
## 0.1.0 — 2026-07-19—so the next merged PR that writes under
## Unreleasedlands inside a shippedrelease. There, the fix and the repair are the same diff.
heavy-duty/boxescaped twice: 0.6.0's ceremony (77599ab) added the stampedheading without removing
## Unreleased, so main was never disarmed, and0.7.0's 2h48m window happened to contain only a VERSION-only merge.
bash test/release.sh— 68 passed, 0 failed (61 onmain; one checkretargeted, eight added).
bash test/cli.sh— 393 passed, 0 failed, unchanged.🤖 Generated with Claude Code
Verdict: I agree with everything and have no additional feedback.
Verdict: Approve
Reviewed at
a43d91e7f9. I re-verified the diagnosis, the guard's semantics, and every claim in the PR body against the tree, and ran the suites myself.Verified
bash test/release.sh— 68 passed, 0 failed.bash test/cli.sh— 393 passed, 0 failed.shellcheck -x test/release.sh— clean. All match the PR body's numbers.CHANGELOG.md(replaced## Unreleasedwith## 0.1.0 — 2026-07-19,VERSIONat0.1.1-dev) and got exactly the advertised red:FAIL: CHANGELOG.md: armed for the VERSION it carries (#66) — exit 1, wanted 0/67 passed, 1 failed. The guard is not vacuous.release.yml:216-238(the bump step) touches onlyVERSION; no workflow ever wrote the changelog heading. Putting the re-arm in the ceremony step (CONTRIBUTING.md:52-66) rather than the workflow matches the findings comment on #66 and the actual history (a702f1fstamped by hand).changelog_armed(test/release.sh:134-142) keys onVERSION—-devdemands a literal top## Unreleased; bare demandschangelog_sectionextracts that version non-empty. That is exactly the state split the pre-#44 guard collapsed, and it keeps both ceremony shapes green (rows attest/release.sh:160-167) while turning #66's state red (:171-172).##heading at all → disarmed (:136-137, pinned at:190-191); a missing/unreadable file degrades the same way (empty grep → return 1); a bareVERSIONwhose section was never stamped fails one round beforerelease.yml's own empty-notes refusal (:186-187, mirroringrelease.yml:167-177).:105-107non-empty-top check would reject the re-armed ceremony's legitimately empty## Unreleased(row at:176-177). The new bare-VERSION branch asserts what actually ships extracts non-empty — the same assertrelease.ymlmakes — so coverage is strictly stronger in both states.VERSION, changed) passes with or without the re-arm; main in the post-release window (bare, stamped top) passes; steady-devstate passes. No legitimate state in the #48 flow goes red.ci.yml:5,24runstest/release.shonpull_request, andactions/checkoutthere checks out the merge-preview ref — so a late PR opened against a disarmed-devmain goes red before merging, i.e. at exactly the moment the misfile would happen. And when the-devbump falls back to a PR (release.yml:230-238), that bump PR's own CI catches a forgotten re-arm before main ever reaches the disarmed-devstate.Non-blocking observations
release.yml:230): aGITHUB_TOKENpush fires no workflows, so if a ceremony skips the re-arm, main sits disarmed-and-unflagged until the next PR's CI run — which then goes red on that innocent PR. That is a deliberate consequence of the #44 lesson (an un-re-armed ceremony must stay shippable,test/release.sh:163-167), the comments own it, and the red arrives before any misfiled merge, so I'm fine with the trade. Worth remembering when the first confused "why is my PR red on a changelog I didn't touch" appears.changelog_armed's bare branch doesn't constrain the top heading at all — a bare0.2.0whose stamped section sits below an older heading would pass. Only hand-mangling produces that tree, andrelease.ymlwould still extract the right notes, so this is fine as-is; noting it since the comment attest/release.sh:125-131says "not constrained at all" and means it.-devcomparison is exact ([ "$top" = "## Unreleased" ],test/release.sh:139), so trailing whitespace on the heading reads as disarmed. That fails closed, which is the right direction.CHANGELOG entry is under
## Unreleased(CHANGELOG.md:69), CONTRIBUTING's new prose matches the guard's rule word-for-word, and the fixture trees restore cleanly (rm -rf "$WORK"). Nice work — the seven executed fixtures plus the real-tree guard are exactly the "prove the guard can fail" discipline the PR body advertises.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Design
changelog_armedkeys on VERSION:-dev⇒ top must be exactly## Unreleasedchangelog_section)release.yml, while re-armed ceremony (empty Unreleased above a real stamped section) still passes — the #44 non-regression.Fixtures
Seven constructed trees cover re-armed/un-re-armed ceremony, disarmed
-dev(#66), empty re-armed main, steady state, unstamped bare, and headless. Real-tree guard retained.CHANGELOG entry under re-armed Unreleased is correct. Checks green at
a43d91e.@danmt — handoff summary. Three head-current approvals at
a43d91e, all checks green.What it fixes
#66: the release ceremony stamps
CHANGELOG.md's## Unreleasedto## X.Y.Z — DATEand stops. Nothing re-arms it, so a PR authored before the release and merged after files its entry — cleanly, with no conflict — into the section that already shipped. The one signal an author relies on ("git told me to look") is absent exactly when the outcome is wrong.This is not theoretical here. #60's
#58entry landed inside## 0.1.0at67386b4, an hour after 0.1.0 published, and was repaired two minutes later by0ff520conly because someone happened to be looking.Two things the issue got wrong, corrected in this PR
No workflow stamps the heading. #66 reads as though the release job does it, and proposes fixing it there. It doesn't —
release.yml:216-238only touchesVERSION; the stamp is a hand edit in the ceremony PR (a702f1f). So the re-arm went intoCONTRIBUTING.md's ceremony step, not the workflow. rig has no scripted ceremony helper, so the prose step is the entire surface.The existing guard couldn't be tightened the obvious way.
test/release.sh:103-107asserted only that whatever the top section is extracts non-empty. Re-adding an unconditional## Unreleasedrequirement would regress #44 — that requirement is false by construction on the ceremony PR's own tree, which is exactly why #44 removed it.Worse, implementing the re-arm breaks the old assert from the other direction: a re-armed ceremony tree's top section is a legitimately empty
## Unreleased, which the non-empty check rejects. So the assert was retargeted rather than kept — on a bare VERSION, what must extract non-empty is the section that ships, mirroringrelease.yml's own pre-publish check. Coverage is strictly stronger in both states.The guard
changelog_armed(test/release.sh:134-142) keys onVERSION:-dev⟹ top section must be exactly## UnreleasedThat is precisely the state split the pre-#44 guard collapsed, and it keeps both ceremony shapes green while turning #66's state red.
Verification
bash test/release.sh— 68 passed, 0 failed (61 on main; one check retargeted, eight added)bash test/cli.sh— 393 passed, 0 failed, unchangedshellcheck -x test/release.sh— clean67386b4with VERSION at0.1.1-devturns the real-tree guard red. claude-bot reproduced this independently and got the identical failure — the guard is not vacuous.Seven constructed fixture trees cover re-armed and un-re-armed ceremony, disarmed
-dev(the #66 state), empty re-armed main, steady state, unstamped bare, and headless.Flagged for your judgment
One detection window stays open, deliberately. Raised by claude-bot as non-blocking; I agree it shouldn't block, but you should know it exists before this becomes the norm in all three repos.
release.yml:230bumpsVERSIONto-devby direct push withGITHUB_TOKEN, and such pushes fire no workflows. So if a ceremony skips the re-arm, main sits disarmed-and-unflagged until the next PR's CI run — which then goes red on an innocent PR that didn't touch the changelog.That is a consequence of the #44 lesson, not an oversight: an un-re-armed ceremony must stay shippable, so the guard cannot demand the re-arm at ceremony time. The trade is sound — the red arrives before any misfiled merge, which is the outcome that matters. But the first person to hit it will reasonably ask "why is my PR red on a changelog I didn't touch," and it's worth knowing the answer in advance.
A genuinely good find from claude-bot, worth recording because it makes the fix stronger than the PR body claimed:
ci.yml:5,24runs onpull_requestandactions/checkoutuses the merge-preview ref, so a late PR opened against a disarmed-devmain goes red before merging — at exactly the moment the misfile would occur, not after. The PR body undersold this.Two smaller non-blocking notes, both fail-closed and both fine as-is: the bare branch doesn't constrain the top heading (only hand-mangling produces that tree), and the
-devcomparison is exact, so trailing whitespace reads as disarmed.Cross-repo state
Same fix in the siblings: heavy-duty/box#110 (rebased after box#109 merged) and heavy-duty/cast#114 (currently in a review round). cast was the worst case — it was sitting disarmed on
mainwith the next merged PR set to land inside shipped 0.1.0, so its PR repairs main as part of its own diff. Origin analysis is on #66.🤖 Generated with Claude Code
Verdict: I agree with everything and have no additional feedback.
Re-evaluated the complete unchanged head after the renewed review request and the maintainer handoff. The VERSION-keyed changelog guard covers the disarmed development state and unstamped bare-version state while preserving both valid ceremony shapes; every current check run is successful.