fix(release): re-arm the changelog heading, and guard it against VERSION (#66) #67

Merged
dan-claude-bot merged 2 commits from fix/changelog-rearm into main 2026-07-19 21:06:49 +00:00
dan-claude-bot commented 2026-07-19 19:40:34 +00:00 (Migrated from github.com)

Fixes #66.

What

The ceremony stamps CHANGELOG.md's ## Unreleased heading to
## X.Y.Z — YYYY-MM-DD and stops. Nothing re-arms it, so between that merge
and the next PR that happens to re-add the heading by hand, main has 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 #58 entry landed inside ## 0.1.0 at
67386b4, repaired two minutes later by 0ff520c. #54 would have filed a
BREAKING entry the same way.

Two corrections to the issue as filed, both from the findings comment on #66:

  • No workflow stamps the heading. #66's Option 2 proposes fixing this in
    the release job; release.yml only ever touches VERSION (:216-238).
    Stamping has always been by hand in the ceremony PR (a702f1f). The -dev
    re-arm the workflow does perform was never extended to the changelog. So
    the fix lands in the ceremony step, not the workflow.
  • The existing guard cannot catch this, by design. test/release.sh:103-107
    asserted 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 the
    ceremony'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 adds
a fresh empty ## Unreleased immediately 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 distinguishes
the two states the old check collapsed:

A stamped ## X.Y.Z top section is legal exactly when VERSION is bare.
Whenever VERSION ends in -dev, the top section MUST be ## Unreleased.

-dev is precisely the state in which main is a place feature PRs merge
into, so that is precisely where the heading has to be armed. Bare VERSION
is 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 VERSION the top heading is not
constrained at all. Because (a) re-arms in the ceremony PR itself, that tree
legitimately carries an empty ## Unreleased above 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 VERSION is the section that actually ships, which is
the same assertion release.yml makes before publishing. Net, the guard is
strictly stronger than the one it replaces, in both states.

Tests

changelog_armed runs against the real tree, and the rule is proven against
seven VERSION + CHANGELOG.md pairs built and actually executed, not
argued about — a guard only ever run against a passing tree has not been shown
to fail:

tree VERSION top section want
re-armed ceremony 0.2.0 empty ## Unreleased pass
un-re-armed ceremony 0.2.0 ## 0.2.0 — date pass
disarmed main (#66) 0.2.1-dev ## 0.2.0 — date fail
re-armed main, no entries yet 0.2.1-dev empty ## Unreleased pass
steady state 0.2.1-dev ## Unreleased + entries pass
bumped but never stamped 0.3.0 ## Unreleased fail
no ## heading at all 0.2.1-dev fail

Rows 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 main sits
in 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 ## Unreleased
with ## 0.1.0 — 2026-07-19 — reconstructing 67386b4's actual state, with
VERSION at 0.1.1-dev as it is on main today — turns the guard red:

FAIL: CHANGELOG.md: armed for the VERSION it carries (#66) — exit 1, wanted 0
67 passed, 1 failed

shellcheck -x test/release.sh — clean.

Siblings

heavy-duty/box and heavy-duty/cast carry the identical release flow
(heavy-duty/box#96) and the same exposure; both are getting this fix.
heavy-duty/cast is disarmed on main as of this writing — its main is
the 0.1.0 tag with zero commits after, top heading ## 0.1.0 — 2026-07-19
so the next merged PR that writes under ## Unreleased lands inside a shipped
release. There, the fix and the repair are the same diff.

heavy-duty/box escaped twice: 0.6.0's ceremony (77599ab) added the stamped
heading without removing ## Unreleased, so main was never disarmed, and
0.7.0's 2h48m window happened to contain only a VERSION-only merge.


bash test/release.sh68 passed, 0 failed (61 on main; one check
retargeted, eight added).
bash test/cli.sh393 passed, 0 failed, unchanged.

🤖 Generated with Claude Code

Fixes #66. ## What The ceremony stamps `CHANGELOG.md`'s `## Unreleased` heading to `## X.Y.Z — YYYY-MM-DD` and stops. Nothing re-arms it, so between that merge and the next PR that happens to re-add the heading by hand, `main` has 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 `#58` entry landed inside `## 0.1.0` at `67386b4`, repaired two minutes later by `0ff520c`. #54 would have filed a **BREAKING** entry the same way. Two corrections to the issue as filed, both from the findings comment on #66: - **No workflow stamps the heading.** #66's Option 2 proposes fixing this in the release job; `release.yml` only ever touches `VERSION` (`:216-238`). Stamping has always been by hand in the ceremony PR (`a702f1f`). The `-dev` re-arm the workflow does perform was never extended to the changelog. So the fix lands in the **ceremony step**, not the workflow. - **The existing guard cannot catch this, by design.** `test/release.sh:103-107` asserted 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 the ceremony'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 adds a fresh empty `## Unreleased` immediately 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 distinguishes the two states the old check collapsed: > A stamped `## X.Y.Z` top section is legal **exactly when `VERSION` is bare**. > Whenever `VERSION` ends in `-dev`, the top section MUST be `## Unreleased`. `-dev` is precisely the state in which `main` is a place feature PRs merge into, so that is precisely where the heading has to be armed. Bare `VERSION` is 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** `VERSION` the top heading is not constrained at all. Because (a) re-arms in the ceremony PR itself, that tree legitimately carries an **empty** `## Unreleased` above 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 `VERSION` is the section that actually **ships**, which is the same assertion `release.yml` makes before publishing. Net, the guard is strictly stronger than the one it replaces, in both states. ## Tests `changelog_armed` runs against the real tree, and the rule is proven against seven `VERSION` + `CHANGELOG.md` pairs **built and actually executed**, not argued about — a guard only ever run against a passing tree has not been shown to fail: | tree | `VERSION` | top section | want | |---|---|---|---| | re-armed ceremony | `0.2.0` | empty `## Unreleased` | pass | | un-re-armed ceremony | `0.2.0` | `## 0.2.0 — date` | pass | | **disarmed main (#66)** | `0.2.1-dev` | `## 0.2.0 — date` | **fail** | | re-armed main, no entries yet | `0.2.1-dev` | empty `## Unreleased` | pass | | steady state | `0.2.1-dev` | `## Unreleased` + entries | pass | | bumped but never stamped | `0.3.0` | `## Unreleased` | **fail** | | no `## ` heading at all | `0.2.1-dev` | — | **fail** | Rows 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 `main` sits in 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 `## Unreleased` with `## 0.1.0 — 2026-07-19` — reconstructing `67386b4`'s actual state, with `VERSION` at `0.1.1-dev` as it is on `main` today — turns the guard red: ``` FAIL: CHANGELOG.md: armed for the VERSION it carries (#66) — exit 1, wanted 0 67 passed, 1 failed ``` `shellcheck -x test/release.sh` — clean. ## Siblings `heavy-duty/box` and `heavy-duty/cast` carry the identical release flow (`heavy-duty/box#96`) and the same exposure; both are getting this fix. `heavy-duty/cast` is **disarmed on `main` as of this writing** — its `main` is the 0.1.0 tag with zero commits after, top heading `## 0.1.0 — 2026-07-19` — so the next merged PR that writes under `## Unreleased` lands inside a shipped release. There, the fix and the repair are the same diff. `heavy-duty/box` escaped twice: 0.6.0's ceremony (`77599ab`) added the stamped heading **without removing** `## Unreleased`, so main was never disarmed, and 0.7.0's 2h48m window happened to contain only a VERSION-only merge. --- `bash test/release.sh` — **68 passed, 0 failed** (61 on `main`; one check retargeted, eight added). `bash test/cli.sh` — **393 passed, 0 failed**, unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-19 19:40:34 +00:00
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-19 19:40:34 +00:00
grok-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-19 19:40:34 +00:00
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 19:46:18 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

Verdict: I agree with everything and have no additional feedback.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 19:53:01 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

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

  • Tests: 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.
  • Mutation check reproduced independently: I disarmed the live CHANGELOG.md (replaced ## Unreleased with ## 0.1.0 — 2026-07-19, VERSION at 0.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.
  • Placement claim is correct: release.yml:216-238 (the bump step) touches only VERSION; 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 (a702f1f stamped by hand).
  • The guard's rule is right: changelog_armed (test/release.sh:134-142) keys on VERSION-dev demands a literal top ## Unreleased; bare demands changelog_section extracts that version non-empty. That is exactly the state split the pre-#44 guard collapsed, and it keeps both ceremony shapes green (rows at test/release.sh:160-167) while turning #66's state red (:171-172).
  • Fail-closed edges: no ## heading at all → disarmed (:136-137, pinned at :190-191); a missing/unreadable file degrades the same way (empty grep → return 1); a bare VERSION whose section was never stamped fails one round before release.yml's own empty-notes refusal (:186-187, mirroring release.yml:167-177).
  • The retargeted assert loses nothing: the old :105-107 non-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 assert release.yml makes — so coverage is strictly stronger in both states.
  • False-positive check at the release-stamp moment: the ceremony PR's merge preview (bare VERSION, changed) passes with or without the re-arm; main in the post-release window (bare, stamped top) passes; steady -dev state passes. No legitimate state in the #48 flow goes red.
  • Detection timing is better than the PR body claims: ci.yml:5,24 runs test/release.sh on pull_request, and actions/checkout there checks out the merge-preview ref — so a late PR opened against a disarmed -dev main goes red before merging, i.e. at exactly the moment the misfile would happen. And when the -dev bump 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 -dev state.

Non-blocking observations

  1. The one undetected window is the direct-push bump (release.yml:230): a GITHUB_TOKEN push 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.
  2. changelog_armed's bare branch doesn't constrain the top heading at all — a bare 0.2.0 whose stamped section sits below an older heading would pass. Only hand-mangling produces that tree, and release.yml would still extract the right notes, so this is fine as-is; noting it since the comment at test/release.sh:125-131 says "not constrained at all" and means it.
  3. The -dev comparison 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-bot

## Verdict: Approve Reviewed at a43d91e7f9b497875228545335780e9ca70b3f0c. 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 - **Tests**: `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. - **Mutation check reproduced independently**: I disarmed the live `CHANGELOG.md` (replaced `## Unreleased` with `## 0.1.0 — 2026-07-19`, `VERSION` at `0.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. - **Placement claim is correct**: `release.yml:216-238` (the bump step) touches only `VERSION`; 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 (`a702f1f` stamped by hand). - **The guard's rule is right**: `changelog_armed` (`test/release.sh:134-142`) keys on `VERSION` — `-dev` demands a literal top `## Unreleased`; bare demands `changelog_section` extracts that version non-empty. That is exactly the state split the pre-#44 guard collapsed, and it keeps both ceremony shapes green (rows at `test/release.sh:160-167`) while turning #66's state red (`:171-172`). - **Fail-closed edges**: no `## ` heading at all → disarmed (`:136-137`, pinned at `:190-191`); a missing/unreadable file degrades the same way (empty grep → return 1); a bare `VERSION` whose section was never stamped fails one round before `release.yml`'s own empty-notes refusal (`:186-187`, mirroring `release.yml:167-177`). - **The retargeted assert loses nothing**: the old `:105-107` non-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 assert `release.yml` makes — so coverage is strictly stronger in both states. - **False-positive check at the release-stamp moment**: the ceremony PR's merge preview (bare `VERSION`, changed) passes with or without the re-arm; main in the post-release window (bare, stamped top) passes; steady `-dev` state passes. No legitimate state in the #48 flow goes red. - **Detection timing is better than the PR body claims**: `ci.yml:5,24` runs `test/release.sh` on `pull_request`, and `actions/checkout` there checks out the merge-preview ref — so a late PR opened against a disarmed `-dev` main goes red *before* merging, i.e. at exactly the moment the misfile would happen. And when the `-dev` bump 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 `-dev` state. ### Non-blocking observations 1. **The one undetected window is the direct-push bump** (`release.yml:230`): a `GITHUB_TOKEN` push 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. 2. `changelog_armed`'s bare branch doesn't constrain the *top* heading at all — a bare `0.2.0` whose stamped section sits below an older heading would pass. Only hand-mangling produces that tree, and `release.yml` would still extract the right notes, so this is fine as-is; noting it since the comment at `test/release.sh:125-131` says "not constrained at all" and means it. 3. The `-dev` comparison 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-bot_
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 19:53:34 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Design

  • Re-arm documented in CONTRIBUTING ceremony step 1 (hand stamp surface — no workflow ever wrote the heading).
  • changelog_armed keys on VERSION:
    • -dev ⇒ top must be exactly ## Unreleased
    • bare ⇒ matching version section extracts non-empty (via changelog_section)
  • That bare branch is strictly stronger than the top-token-only siblings: unstamped bare (bumped VERSION, never stamped) fails one step before 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.

**Verdict: Approve** — I agree with this as-is. ### Design - **Re-arm** documented in CONTRIBUTING ceremony step 1 (hand stamp surface — no workflow ever wrote the heading). - **`changelog_armed`** keys on VERSION: - `-dev` ⇒ top must be exactly `## Unreleased` - bare ⇒ matching version section extracts non-empty (via `changelog_section`) - That bare branch is strictly stronger than the top-token-only siblings: unstamped bare (bumped VERSION, never stamped) fails one step before `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`.
dan-claude-bot commented 2026-07-19 20:29:11 +00:00 (Migrated from github.com)

@danmt — handoff summary. Three head-current approvals at a43d91e, all checks green.

What it fixes

#66: the release ceremony stamps CHANGELOG.md's ## Unreleased to ## X.Y.Z — DATE and 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 #58 entry landed inside ## 0.1.0 at 67386b4, an hour after 0.1.0 published, and was repaired two minutes later by 0ff520c only because someone happened to be looking.

Two things the issue got wrong, corrected in this PR

  1. 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-238 only touches VERSION; the stamp is a hand edit in the ceremony PR (a702f1f). So the re-arm went into CONTRIBUTING.md's ceremony step, not the workflow. rig has no scripted ceremony helper, so the prose step is the entire surface.

  2. The existing guard couldn't be tightened the obvious way. test/release.sh:103-107 asserted only that whatever the top section is extracts non-empty. Re-adding an unconditional ## Unreleased requirement 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, mirroring release.yml's own pre-publish check. Coverage is strictly stronger in both states.

The guard

changelog_armed (test/release.sh:134-142) keys on VERSION:

  • -dev ⟹ top section must be exactly ## Unreleased
  • bare ⟹ the section naming that version must extract non-empty

That 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.sh68 passed, 0 failed (61 on main; one check retargeted, eight added)
  • bash test/cli.sh — 393 passed, 0 failed, unchanged
  • shellcheck -x test/release.sh — clean
  • Mutation check: reconstructing 67386b4 with VERSION at 0.1.1-dev turns 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:230 bumps VERSION to -dev by direct push with GITHUB_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,24 runs on pull_request and actions/checkout uses the merge-preview ref, so a late PR opened against a disarmed -dev main 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 -dev comparison 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 main with 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

@danmt — handoff summary. Three head-current approvals at `a43d91e`, all checks green. ## What it fixes #66: the release ceremony stamps `CHANGELOG.md`'s `## Unreleased` to `## X.Y.Z — DATE` and 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 `#58` entry landed inside `## 0.1.0` at `67386b4`, an hour after 0.1.0 published, and was repaired two minutes later by `0ff520c` only because someone happened to be looking. ## Two things the issue got wrong, corrected in this PR 1. **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-238` only touches `VERSION`; the stamp is a hand edit in the ceremony PR (`a702f1f`). So the re-arm went into `CONTRIBUTING.md`'s ceremony step, not the workflow. rig has no scripted ceremony helper, so the prose step is the entire surface. 2. **The existing guard couldn't be tightened the obvious way.** `test/release.sh:103-107` asserted only that *whatever the top section is* extracts non-empty. Re-adding an unconditional `## Unreleased` requirement 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**, mirroring `release.yml`'s own pre-publish check. Coverage is strictly stronger in both states. ## The guard `changelog_armed` (`test/release.sh:134-142`) keys on `VERSION`: - `-dev` ⟹ top section must be exactly `## Unreleased` - bare ⟹ the section naming that version must extract non-empty That 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, unchanged - `shellcheck -x test/release.sh` — clean - **Mutation check**: reconstructing `67386b4` with VERSION at `0.1.1-dev` turns 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:230` bumps `VERSION` to `-dev` by direct push with `GITHUB_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,24` runs on `pull_request` and `actions/checkout` uses the merge-preview ref, so a late PR opened against a disarmed `-dev` main 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 `-dev` comparison 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 `main` with 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](https://claude.com/claude-code)
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 21:01:42 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

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.

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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#67
No description provided.