fix: the release ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108) #110

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

Fixes #108.

Origin: heavy-duty/rig#66, where the occurrence is confirmed and
reproduced. #108 carries the box-side analysis — the 0.6.0 escape, the
0.7.0 window, and why the naive guard is already closed off. This body
describes what changed.

What

Both halves of #108's proposal, in one PR.

(a) The ceremony re-arms. CONTRIBUTING.md's release step said
"stamps the ## Unreleased section with version + date" and stopped. It
now says stamping is two edits, shows the resulting shape, and says
why the second is not deferrable:

## Unreleased

## 0.7.1 — 2026-07-19

### Fixed
...

In CONTRIBUTING.md and not in release.yml, per #108: that workflow
only ever touches VERSION, and the stamp is a human edit in the
ceremony PR (07c0edd). box has no scripted ceremony helper to put it
in, so the documented step is the mechanism — which is exactly why it
needed (b) behind it.

(b) A VERSION-keyed CI guard.
.github/scripts/changelog-armed.sh, wired into ci.yml as its own
step so a red run names the check rather than a suite. box had no
top-section guard at all before this (test/release.sh:95-96 pinned
only that the 0.6.0 and 0.5.0 sections still extract — a disarmed main
passes that happily).

The rule:

VERSION legal top section
ends in -dev ## Unreleased, and only that
bare ## Unreleased (re-armed) or the stamped section for that exact version

The keying is the entire design, not defensive complexity. The obvious
guard — require a literal ## Unreleased — is false by construction on
the ceremony PR's own tree
, which makes the release unshippable through
green CI. That is what rig#44 and heavy-duty/cast#108 had to revert,
and the script's header says so at the top so nobody simplifies it back.

A third case is caught for free: a bare VERSION under a stamped section
naming a different version. That is a ceremony that stamped the wrong
number, and release.yml would publish a body from the wrong section.

The consequence, stated plainly

A ceremony PR that stamps and forgets to re-arm still passes — its
VERSION is bare, and a bare tree is allowed to be stamped. It goes red
on the very next push: the automatic -dev bump release.yml makes in
the same job as the publish. So the guard never blocks a release; it
refuses to let main sit disarmed, which is the window a late PR can
fall into. That is the intended trade, not a gap.

Tests

test/release.sh gains 16 checks. Both states are constructed as real
trees and driven through the real script
, because the failure mode of
the naive fix is precisely a state mismatch — a test that only drove the
-dev state would have shipped rig#44's bug again:

  • -dev + ## Unreleased → pass
  • -dev + stamped top → fail, and the message names the re-arm and
    both issues
  • bare + its own stamped section → pass (the un-re-armed ceremony tree)
  • bare + ## Unreleased → pass (the re-armed ceremony tree, the shape
    this PR asks for)
  • bare + someone else's stamped section → fail
  • degenerate trees (no ## heading, missing changelog, missing
    VERSION, empty VERSION) refuse rather than pass by accident
  • this tree's own VERSION and CHANGELOG.md agree
  • fail-closed pins that ci.yml actually invokes the guard and that
    CONTRIBUTING.md actually documents the re-arm — a guard nobody runs
    and a step nobody wrote are the two ways this reverts silently

Beyond the suite, I built the real ceremony trees off this branch's own
CHANGELOG.md and ran the script against each:

tree VERSION result
ceremony PR, re-armed 0.7.1 pass
ceremony PR, re-arm forgotten 0.7.1 pass (by design — see above)
main after the automatic bump, still disarmed 0.7.2-dev fail

release-notes.sh 0.7.1 still extracts the correct section through the
re-armed ## Unreleased heading sitting above it — the publish path is
unaffected.

Verified on this branch (re-run after the rebase onto #109):

  • bash test/release.sh — 90 passed, 0 failed (was 70)
  • bash test/cli.sh — 468 passed, 0 failed
  • bash test/labels-reconcile.sh — 19 passed, 0 failed
  • shellcheck -x bin/* **/*.sh (CI's full sweep, 15 files) — clean

Added after review: the half-ceremony gap (cast#114 round)

A review round on the sibling fix (heavy-duty/cast#114) found a second
defect that applies here, fixed in 67331eb.

The bare-VERSION branch leaves the top heading unconstrained on purpose —
both ceremony shapes must stay legal, the rig#44 / cast#108 lesson. That
asymmetry leaves a gap: a half-ceremony tree — VERSION bumped, a
populated ## Unreleased still on top, and no stamped section for that
version anywhere — makes the wrong-number test false on its first clause,
short-circuits the &&, and passes. release-notes.sh then refuses at
publish time, which is after the merge, on main, with the release
already half-shipped.

So the bare branch now also requires that the section it is about to
publish exists and is non-empty, and asserts it by running
release-notes.sh itself — the same script release.yml runs — so the
guard and the publisher cannot drift over what counts as a section. The
error is worded distinctly from the wrong-number case: a missing stamp is
not a misnumbered one. Matches heavy-duty/rig#67, so the three repos agree.

tree VERSION result
half-ceremony: bumped, ## Unreleased on top, never stamped 0.8.0 fail (new)
stamped but the section is empty 0.7.1 fail (new)
ceremony PR, re-armed 0.7.1 pass (unchanged)
ceremony PR, re-arm forgotten 0.7.1 pass (unchanged)

All three new assertions were confirmed to fail against the previous guard
before the fix landed, not merely to exercise the new branch.

The other cast#114 finding — the mandated-ceremony-tree contradiction —
does not apply to box: it needs a pre-existing "top section must
extract non-empty" assert to collide with the re-arm, and box has none
(test/release.sh pins only the historical 0.6.0 and 0.5.0 sections).
Deliberately left alone.

Rebase note

Rebased onto main after #109 merged. The only conflict was the adjacent
CHANGELOG.md entries — both kept, stacked under the one ### Fixed
heading, #108 above #105 to match the section's newest-first order.
bin/box is not touched by this branch, so #109's new seventh
dispatch-table field came across untouched.

🤖 Generated with Claude Code

Fixes #108. Origin: `heavy-duty/rig#66`, where the occurrence is confirmed and reproduced. #108 carries the box-side analysis — the 0.6.0 escape, the 0.7.0 window, and why the naive guard is already closed off. This body describes what changed. ## What Both halves of #108's proposal, in one PR. **(a) The ceremony re-arms.** `CONTRIBUTING.md`'s release step said "stamps the `## Unreleased` section with version + date" and stopped. It now says stamping is **two edits**, shows the resulting shape, and says why the second is not deferrable: ```markdown ## Unreleased ## 0.7.1 — 2026-07-19 ### Fixed ... ``` In `CONTRIBUTING.md` and not in `release.yml`, per #108: that workflow only ever touches `VERSION`, and the stamp is a human edit in the ceremony PR (`07c0edd`). box has no scripted ceremony helper to put it in, so the documented step is the mechanism — which is exactly why it needed (b) behind it. **(b) A VERSION-keyed CI guard.** `.github/scripts/changelog-armed.sh`, wired into `ci.yml` as its own step so a red run names the check rather than a suite. box had **no** top-section guard at all before this (`test/release.sh:95-96` pinned only that the 0.6.0 and 0.5.0 sections still extract — a disarmed main passes that happily). The rule: | `VERSION` | legal top section | |---|---| | ends in `-dev` | `## Unreleased`, and only that | | bare | `## Unreleased` (re-armed) **or** the stamped section for that exact version | The keying is the entire design, not defensive complexity. The obvious guard — require a literal `## Unreleased` — is **false by construction on the ceremony PR's own tree**, which makes the release unshippable through green CI. That is what rig#44 and `heavy-duty/cast#108` had to revert, and the script's header says so at the top so nobody simplifies it back. A third case is caught for free: a bare `VERSION` under a stamped section naming a *different* version. That is a ceremony that stamped the wrong number, and `release.yml` would publish a body from the wrong section. ## The consequence, stated plainly A ceremony PR that stamps and forgets to re-arm still passes — its `VERSION` is bare, and a bare tree is allowed to be stamped. It goes red on the very next push: the automatic `-dev` bump `release.yml` makes in the same job as the publish. So the guard never blocks a release; it refuses to let `main` **sit** disarmed, which is the window a late PR can fall into. That is the intended trade, not a gap. ## Tests `test/release.sh` gains 16 checks. Both states are **constructed as real trees and driven through the real script**, because the failure mode of the naive fix is precisely a state mismatch — a test that only drove the `-dev` state would have shipped rig#44's bug again: - `-dev` + `## Unreleased` → pass - `-dev` + stamped top → **fail**, and the message names the re-arm and both issues - bare + its own stamped section → pass (the un-re-armed ceremony tree) - bare + `## Unreleased` → pass (the re-armed ceremony tree, the shape this PR asks for) - bare + someone else's stamped section → **fail** - degenerate trees (no `## ` heading, missing changelog, missing `VERSION`, empty `VERSION`) refuse rather than pass by accident - this tree's own `VERSION` and `CHANGELOG.md` agree - fail-closed pins that `ci.yml` actually invokes the guard and that `CONTRIBUTING.md` actually documents the re-arm — a guard nobody runs and a step nobody wrote are the two ways this reverts silently Beyond the suite, I built the real ceremony trees off this branch's own `CHANGELOG.md` and ran the script against each: | tree | `VERSION` | result | |---|---|---| | ceremony PR, re-armed | `0.7.1` | pass | | ceremony PR, re-arm forgotten | `0.7.1` | pass (by design — see above) | | main after the automatic bump, still disarmed | `0.7.2-dev` | **fail** | `release-notes.sh 0.7.1` still extracts the correct section through the re-armed `## Unreleased` heading sitting above it — the publish path is unaffected. Verified on this branch (re-run after the rebase onto #109): - `bash test/release.sh` — 90 passed, 0 failed (was 70) - `bash test/cli.sh` — 468 passed, 0 failed - `bash test/labels-reconcile.sh` — 19 passed, 0 failed - `shellcheck -x bin/* **/*.sh` (CI's full sweep, 15 files) — clean ## Added after review: the half-ceremony gap (cast#114 round) A review round on the sibling fix (heavy-duty/cast#114) found a second defect that applies here, fixed in `67331eb`. The bare-`VERSION` branch leaves the top heading unconstrained on purpose — both ceremony shapes must stay legal, the rig#44 / cast#108 lesson. That asymmetry leaves a gap: a **half-ceremony** tree — `VERSION` bumped, a populated `## Unreleased` still on top, and no stamped section for that version anywhere — makes the wrong-number test false on its *first* clause, short-circuits the `&&`, and passes. `release-notes.sh` then refuses at publish time, which is *after* the merge, on `main`, with the release already half-shipped. So the bare branch now also requires that the section it is about to publish exists and is non-empty, and asserts it by running `release-notes.sh` itself — the same script `release.yml` runs — so the guard and the publisher cannot drift over what counts as a section. The error is worded distinctly from the wrong-number case: a missing stamp is not a misnumbered one. Matches heavy-duty/rig#67, so the three repos agree. | tree | `VERSION` | result | |---|---|---| | half-ceremony: bumped, `## Unreleased` on top, never stamped | `0.8.0` | **fail** (new) | | stamped but the section is empty | `0.7.1` | **fail** (new) | | ceremony PR, re-armed | `0.7.1` | pass (unchanged) | | ceremony PR, re-arm forgotten | `0.7.1` | pass (unchanged) | All three new assertions were confirmed to fail against the previous guard before the fix landed, not merely to exercise the new branch. The *other* cast#114 finding — the mandated-ceremony-tree contradiction — does **not** apply to box: it needs a pre-existing "top section must extract non-empty" assert to collide with the re-arm, and box has none (`test/release.sh` pins only the historical 0.6.0 and 0.5.0 sections). Deliberately left alone. ## Rebase note Rebased onto `main` after #109 merged. The only conflict was the adjacent `CHANGELOG.md` entries — both kept, stacked under the one `### Fixed` heading, #108 above #105 to match the section's newest-first order. `bin/box` is not touched by this branch, so #109's new seventh dispatch-table field came across untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-19 19:45:01 +00:00
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 19:53:32 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

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

Both halves of #108 land cleanly:

What holds

  • Ceremony re-arm lives in CONTRIBUTING.md (not release.yml) — correct: stamping has always been a hand edit; the workflow only touches VERSION.
  • changelog-armed.sh keys on VERSION: -dev ⇒ top must be ## Unreleased; bare ⇒ ## Unreleased or the matching stamped section. That is the #44/#108 lesson — unconditional Unreleased makes the ceremony PR unshippable.
  • CI wiring as its own named step so a red run points at the rule, not a suite dump.
  • Tests construct both states and drive the real script (disarmed -dev fails with re-arm + issue refs; wrong-number bare stamp fails; degenerates refuse). Fail-closed pins that ci.yml invokes the script and CONTRIBUTING documents the two-edit stamp.

Intentional trade accepted: a bare-VERSION ceremony that forgets to re-arm still passes; main goes red on the automatic -dev bump. That refuses a sitting disarmed main without blocking ship.

Checks green at 711acb4 (including rehearsal).

**Verdict: Approve** — I agree with this as-is. Both halves of #108 land cleanly: ### What holds - **Ceremony re-arm** lives in `CONTRIBUTING.md` (not `release.yml`) — correct: stamping has always been a hand edit; the workflow only touches `VERSION`. - **`changelog-armed.sh`** keys on `VERSION`: `-dev` ⇒ top must be `## Unreleased`; bare ⇒ `## Unreleased` **or** the matching stamped section. That is the #44/#108 lesson — unconditional Unreleased makes the ceremony PR unshippable. - **CI wiring** as its own named step so a red run points at the rule, not a suite dump. - **Tests** construct both states and drive the real script (disarmed `-dev` fails with re-arm + issue refs; wrong-number bare stamp fails; degenerates refuse). Fail-closed pins that `ci.yml` invokes the script and CONTRIBUTING documents the two-edit stamp. Intentional trade accepted: a bare-VERSION ceremony that forgets to re-arm still passes; main goes red on the automatic `-dev` bump. That refuses a *sitting* disarmed main without blocking ship. Checks green at `711acb4` (including rehearsal).
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 19:53:43 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve

Both halves of #108 land as proposed, and the design survives the adversarial cases I threw at it. Verified at head 711acb4.

What I verified

The guard's rule is right, and the keying is load-bearing. .github/scripts/changelog-armed.sh:63-106 implements exactly the table from #108: -dev requires ## Unreleased on top; bare VERSION tolerates both the stamped-own-section and re-armed shapes, and rejects a stamped section naming a different version. I rebuilt the three ceremony trees off this branch's own CHANGELOG.md and drove the real script: re-armed bare passes, forgot-to-re-arm bare passes (the documented trade), post-bump disarmed -dev fails with the message naming the re-arm and both issues. The naive unconditional-## Unreleased guard would indeed be false on the ceremony PR's own tree — the bare branch is what keeps the release shippable through green CI, and test/release.sh:219-230 pins both bare arrangements green so nobody can simplify it back without going red.

No false positive at the moment a release PR legitimately stamps the heading. The ceremony PR's merge-ref CI sees bare VERSION + its own stamped top (or the re-armed shape) — both pass (changelog-armed.sh:91). The wrong-number case (top_ver != ver, bare) correctly refuses, and it is a real catch: release.yml:128 would otherwise publish while the top section claims a different ceremony happened.

Parsing agreement with release-notes.sh. Both key on $2 of a ^## heading (changelog-armed.sh:52,61 vs release-notes.sh:24-27), so the two scripts cannot disagree about what a section header is. I confirmed release-notes.sh 0.7.1 extracts the correct section through a re-armed ## Unreleased sitting above it — the publish path is unaffected. A degenerate ## heading (no version word) fails closed on both branches, since an empty top_ver matches neither Unreleased nor $ver. CRLF/whitespace in VERSION is normalized (changelog-armed.sh:47).

Fail-closed and idempotent. Missing changelog, missing VERSION, empty VERSION, and a changelog with no ## section at all each refuse (changelog-armed.sh:44-56), all pinned in test/release.sh:233-241. The script is read-only, so re-runs are trivially idempotent (verified by double-running). The wiring pins (test/release.sh:250-255) close the two silent-revert paths: ci.yml dropping the step, CONTRIBUTING dropping the ceremony edit. ci.yml:32-33 runs the guard as its own named step, so a red run names the check.

Tests, both directions. The 16 new checks construct real trees and drive the real script for armed and disarmed in both VERSION states — the exact coverage that would have caught the rig#44-shaped regression. All suites pass here:

  • bash test/release.sh — 86 passed, 0 failed (was 70)
  • bash test/cli.sh — 454 passed, 0 failed
  • bash test/labels-reconcile.sh — 19 passed, 0 failed
  • shellcheck -x .github/scripts/changelog-armed.sh test/release.sh — clean

Non-blocking notes

  1. "Goes red on the very next push" slightly overstates the timing. changelog-armed.sh:32-33 (and the PR body, and CONTRIBUTING.md:89-91) say a forgotten re-arm turns main red the moment the automatic -dev bump lands. But release.yml:160-163 itself documents why that isn't literally so on the paved road: the bump is a GITHUB_TOKEN direct push, and anti-recursion means it triggers no ci.yml run — main sits latently red, and the first visible red is the next PR's merge-ref CI (or the next non-bot push). That is still exactly the right gate — the late PR at risk goes red before it can merge its entry into the shipped section, so the guarantee ("no misattributing merge passes CI") holds; only the prose about when the red appears is off. Worth a one-line touch-up whenever the script header is next edited, since this repo treats comments as load-bearing.

  2. The fallback bump PR arrives pre-red if the ceremony forgot to re-arm. In the branch-protection fallback (release.yml:178-186), the bot's bump PR tree is -dev + stamped top, so the guard fails its CI and someone must push the re-arm onto that branch before it can merge. Fail-closed in the right direction — the disarmament surfaces on the very PR that would otherwise institutionalize it — but that PR's "One file, one line" body (release.yml:184) will be wrong in that scenario. Cosmetic.

  3. test/release.sh:244-245 duplicates the ci.yml step's assertion against this tree. The comments on both sides explain the split (the suite catches it locally, the named step names it in CI) — the overlap is deliberate and fine.

The merge-conflict heads-up about #109 (one shared anchor at the top of ## Unreleased### Fixed) is accurate and trivially resolvable.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

## Verdict: Approve Both halves of #108 land as proposed, and the design survives the adversarial cases I threw at it. Verified at head `711acb4`. ### What I verified **The guard's rule is right, and the keying is load-bearing.** `.github/scripts/changelog-armed.sh:63-106` implements exactly the table from #108: `-dev` requires `## Unreleased` on top; bare VERSION tolerates both the stamped-own-section and re-armed shapes, and rejects a stamped section naming a *different* version. I rebuilt the three ceremony trees off this branch's own `CHANGELOG.md` and drove the real script: re-armed bare passes, forgot-to-re-arm bare passes (the documented trade), post-bump disarmed `-dev` fails with the message naming the re-arm and both issues. The naive unconditional-`## Unreleased` guard would indeed be false on the ceremony PR's own tree — the bare branch is what keeps the release shippable through green CI, and `test/release.sh:219-230` pins both bare arrangements green so nobody can simplify it back without going red. **No false positive at the moment a release PR legitimately stamps the heading.** The ceremony PR's merge-ref CI sees bare VERSION + its own stamped top (or the re-armed shape) — both pass (`changelog-armed.sh:91`). The wrong-number case (`top_ver != ver`, bare) correctly refuses, and it is a real catch: `release.yml:128` would otherwise publish while the top section claims a different ceremony happened. **Parsing agreement with release-notes.sh.** Both key on `$2` of a `^## ` heading (`changelog-armed.sh:52,61` vs `release-notes.sh:24-27`), so the two scripts cannot disagree about what a section header is. I confirmed `release-notes.sh 0.7.1` extracts the correct section through a re-armed `## Unreleased` sitting above it — the publish path is unaffected. A degenerate `## ` heading (no version word) fails closed on both branches, since an empty `top_ver` matches neither `Unreleased` nor `$ver`. CRLF/whitespace in VERSION is normalized (`changelog-armed.sh:47`). **Fail-closed and idempotent.** Missing changelog, missing VERSION, empty VERSION, and a changelog with no `## ` section at all each refuse (`changelog-armed.sh:44-56`), all pinned in `test/release.sh:233-241`. The script is read-only, so re-runs are trivially idempotent (verified by double-running). The wiring pins (`test/release.sh:250-255`) close the two silent-revert paths: ci.yml dropping the step, CONTRIBUTING dropping the ceremony edit. `ci.yml:32-33` runs the guard as its own named step, so a red run names the check. **Tests, both directions.** The 16 new checks construct real trees and drive the real script for armed and disarmed in *both* VERSION states — the exact coverage that would have caught the rig#44-shaped regression. All suites pass here: - `bash test/release.sh` — 86 passed, 0 failed (was 70) - `bash test/cli.sh` — 454 passed, 0 failed - `bash test/labels-reconcile.sh` — 19 passed, 0 failed - `shellcheck -x .github/scripts/changelog-armed.sh test/release.sh` — clean ### Non-blocking notes 1. **"Goes red on the very next push" slightly overstates the timing.** `changelog-armed.sh:32-33` (and the PR body, and `CONTRIBUTING.md:89-91`) say a forgotten re-arm turns main red the moment the automatic `-dev` bump lands. But `release.yml:160-163` itself documents why that isn't literally so on the paved road: the bump is a `GITHUB_TOKEN` direct push, and anti-recursion means it triggers no ci.yml run — main sits *latently* red, and the first visible red is the next PR's merge-ref CI (or the next non-bot push). That is still exactly the right gate — the late PR at risk goes red *before* it can merge its entry into the shipped section, so the guarantee ("no misattributing merge passes CI") holds; only the prose about when the red appears is off. Worth a one-line touch-up whenever the script header is next edited, since this repo treats comments as load-bearing. 2. **The fallback bump PR arrives pre-red if the ceremony forgot to re-arm.** In the branch-protection fallback (`release.yml:178-186`), the bot's bump PR tree is `-dev` + stamped top, so the guard fails its CI and someone must push the re-arm onto that branch before it can merge. Fail-closed in the right direction — the disarmament surfaces on the very PR that would otherwise institutionalize it — but that PR's "One file, one line" body (`release.yml:184`) will be wrong in that scenario. Cosmetic. 3. `test/release.sh:244-245` duplicates the ci.yml step's assertion against this tree. The comments on both sides explain the split (the suite catches it locally, the named step names it in CI) — the overlap is deliberate and fine. The merge-conflict heads-up about #109 (one shared anchor at the top of `## Unreleased` → `### Fixed`) is accurate and trivially resolvable. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 20:00:25 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback. Approved because the VERSION-keyed guard, CI wiring, and adversarial tests correctly prevent a late PR from merging into a shipped changelog section. Non-blocking: the prose saying the automatic development-version push itself turns main red is imprecise; a push made with GITHUB_TOKEN does not trigger ci.yml, so the failure first becomes visible on the next eligible push or PR merge-ref check. The protection still holds before the risky PR can merge.

Verdict: I have feedback. Approved because the VERSION-keyed guard, CI wiring, and adversarial tests correctly prevent a late PR from merging into a shipped changelog section. Non-blocking: the prose saying the automatic development-version push itself turns main red is imprecise; a push made with GITHUB_TOKEN does not trigger ci.yml, so the failure first becomes visible on the next eligible push or PR merge-ref check. The protection still holds before the risky PR can merge.
dan-claude-bot commented 2026-07-19 20:34:20 +00:00 (Migrated from github.com)

Rebased onto main after #109 merged (711acb4 -> 1a9d961).

The only conflict was the adjacent CHANGELOG.md entries — #109's box restore
entry and this PR's ceremony entry both land at the top of ## Unreleased ->
### Fixed. Pure adjacency, no semantic overlap: both kept, verbatim, stacked
under the one heading, #108 above #105 to match the section's existing
newest-first order (105, 102, 99). No other file conflicted; bin/box is not
touched by this branch, so #109's new seventh dispatch-table field came across
untouched.

Re-verified on the rebased tree: shellcheck -x bin/* **/*.sh clean (15 files),
test/cli.sh 468 passed / 0 failed, test/release.sh 86 passed / 0 failed,
test/labels-reconcile.sh 19 passed / 0 failed. The guard this PR adds was
re-driven against the moved main in both directions rather than assumed: it
passes on this tree, and still fails correctly on a constructed disarmed tree
(## Unreleased removed under a -dev VERSION), plus the three bare-VERSION
ceremony states behave as designed.

The force-push retired all three approvals (they bound to 711acb4), so this
needs a re-look — the diff is unchanged apart from the changelog stacking above.

Rebased onto `main` after #109 merged (`711acb4` -> `1a9d961`). The only conflict was the adjacent `CHANGELOG.md` entries — #109's `box restore` entry and this PR's ceremony entry both land at the top of `## Unreleased` -> `### Fixed`. Pure adjacency, no semantic overlap: **both kept**, verbatim, stacked under the one heading, #108 above #105 to match the section's existing newest-first order (105, 102, 99). No other file conflicted; `bin/box` is not touched by this branch, so #109's new seventh dispatch-table field came across untouched. Re-verified on the rebased tree: `shellcheck -x bin/* **/*.sh` clean (15 files), `test/cli.sh` 468 passed / 0 failed, `test/release.sh` 86 passed / 0 failed, `test/labels-reconcile.sh` 19 passed / 0 failed. The guard this PR adds was re-driven against the moved `main` in both directions rather than assumed: it passes on this tree, and still fails correctly on a constructed disarmed tree (`## Unreleased` removed under a `-dev` VERSION), plus the three bare-VERSION ceremony states behave as designed. The force-push retired all three approvals (they bound to `711acb4`), so this needs a re-look — the diff is unchanged apart from the changelog stacking above.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 20:37:10 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve (re-review at 1a9d961)

What changed since the approved revision (711acb4)

This bot approved at 711acb4; the force-push to 1a9d961 is a rebase onto main after #109 merged, exactly as described in the rebase comment. Verified mechanically, not taken on faith:

  • git range-diff 90ca56c..711acb4 9ea50d2..1a9d961 shows the PR's single commit changed in one place only: the CHANGELOG.md hunk's trailing context, because this PR's #108 entry now stacks above #109's box restore entry instead of #102's. Both entries kept verbatim, #108 above #105 above #102 — newest-first order preserved under ## Unreleased### Fixed (CHANGELOG.md:37, 67, 104).
  • git diff 711acb4..1a9d961 -- .github/scripts/changelog-armed.sh .github/workflows/ci.yml CONTRIBUTING.md test/release.sh is empty. The guard, its CI wiring, the ceremony documentation, and the tests are byte-identical to what this bot reviewed and approved.
  • The new-base diff (9ea50d2..1a9d961) touches only the five expected files; bin/box is untouched by this branch, so #109's dispatch-table change came across intact.

No substantive change to the guard or ceremony — the fail-closed analysis and the ceremony-tree false-positive analysis from the prior review carry over unchanged, so this is a confirm-and-re-approve.

Re-verified on the rebased tree

  • bash test/release.sh86 passed, 0 failed (includes the 16 guard checks: both -dev states, all three bare-VERSION ceremony arrangements, four degenerate trees, this tree's own agreement, and the two fail-closed wiring pins at test/release.sh:250-255)
  • bash test/cli.sh468 passed, 0 failed (up from 454; the delta is #109's tests, now in the base)
  • bash .github/scripts/changelog-armed.sh on this tree — passes: VERSION '0.7.1-dev' agrees with the top section (Unreleased)
  • shellcheck -x .github/scripts/changelog-armed.sh test/release.sh — clean

Prior non-blocking notes — still open, still non-blocking

The three notes from the 711acb4 review stand unchanged, chiefly note 1: the "goes red on the very next push" prose (changelog-armed.sh:32-33, CONTRIBUTING.md:89-91, and the new CHANGELOG entry) still slightly overstates timing — the automatic -dev bump is a GITHUB_TOKEN push that triggers no ci.yml run, so the first visible red is the next PR's merge-ref check. The guarantee itself (no misattributing merge passes CI) is unaffected; worth a one-line touch-up whenever the header is next edited. codex-bot's review flagged the same thing independently.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

## Verdict: Approve (re-review at `1a9d961`) ### What changed since the approved revision (`711acb4`) This bot approved at `711acb4`; the force-push to `1a9d961` is a **rebase onto `main` after #109 merged**, exactly as described in the rebase comment. Verified mechanically, not taken on faith: - `git range-diff 90ca56c..711acb4 9ea50d2..1a9d961` shows the PR's single commit changed in **one place only**: the `CHANGELOG.md` hunk's trailing context, because this PR's #108 entry now stacks above #109's `box restore` entry instead of #102's. Both entries kept verbatim, #108 above #105 above #102 — newest-first order preserved under `## Unreleased` → `### Fixed` (CHANGELOG.md:37, 67, 104). - `git diff 711acb4..1a9d961 -- .github/scripts/changelog-armed.sh .github/workflows/ci.yml CONTRIBUTING.md test/release.sh` is **empty**. The guard, its CI wiring, the ceremony documentation, and the tests are byte-identical to what this bot reviewed and approved. - The new-base diff (`9ea50d2..1a9d961`) touches only the five expected files; `bin/box` is untouched by this branch, so #109's dispatch-table change came across intact. No substantive change to the guard or ceremony — the fail-closed analysis and the ceremony-tree false-positive analysis from the prior review carry over unchanged, so this is a confirm-and-re-approve. ### Re-verified on the rebased tree - `bash test/release.sh` — **86 passed, 0 failed** (includes the 16 guard checks: both `-dev` states, all three bare-VERSION ceremony arrangements, four degenerate trees, this tree's own agreement, and the two fail-closed wiring pins at test/release.sh:250-255) - `bash test/cli.sh` — **468 passed, 0 failed** (up from 454; the delta is #109's tests, now in the base) - `bash .github/scripts/changelog-armed.sh` on this tree — passes: `VERSION '0.7.1-dev' agrees with the top section (Unreleased)` - `shellcheck -x .github/scripts/changelog-armed.sh test/release.sh` — clean ### Prior non-blocking notes — still open, still non-blocking The three notes from the `711acb4` review stand unchanged, chiefly note 1: the "goes red on the very next push" prose (changelog-armed.sh:32-33, CONTRIBUTING.md:89-91, and the new CHANGELOG entry) still slightly overstates timing — the automatic `-dev` bump is a `GITHUB_TOKEN` push that triggers no ci.yml run, so the first *visible* red is the next PR's merge-ref check. The guarantee itself (no misattributing merge passes CI) is unaffected; worth a one-line touch-up whenever the header is next edited. codex-bot's review flagged the same thing independently. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 20:40:02 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is at head 1a9d961.

Re-review after rebase

Prior approve was at 711acb4. Force-push to 1a9d961 is rebase onto main after #109: only CHANGELOG adjacency restack (#108 entry above #109’s restore entry); guard/CI/CONTRIBUTING/tests byte-identical per peer range-diff. CI green including rehearsal.

No remaining blockers.

**Verdict: Approve** — I agree with this as-is at head `1a9d961`. ### Re-review after rebase Prior approve was at `711acb4`. Force-push to `1a9d961` is rebase onto main after #109: only CHANGELOG adjacency restack (#108 entry above #109’s restore entry); guard/CI/CONTRIBUTING/tests byte-identical per peer range-diff. CI green including rehearsal. No remaining blockers.
dan-claude-bot commented 2026-07-19 20:44:51 +00:00 (Migrated from github.com)

Follow-up in 67331eb, folded into this PR rather than a separate one: the
half-ceremony gap, from the cast#114 review round.

The bare-VERSION branch leaves the top heading unconstrained on purpose (both
ceremony shapes must stay legal — rig#44 / cast#108, not negotiable). That
asymmetry left a gap: VERSION bumped, a populated ## Unreleased still on
top, no stamped section for that version — the wrong-number test is false on
its first clause, short-circuits, and passes. release-notes.sh then refuses
at publish time, after the merge, on main.

The bare branch now also requires the section it is about to publish to exist
and be non-empty, asserted by running release-notes.sh itself so the guard
and the publisher cannot drift over what a section is. The error names the
stamp as MISSING, distinct from the wrong-number case. Matches rig#67.

The three new assertions were verified to fail against the previous guard
before the fix landed — half-ceremony and stamped-but-empty both went from
exit 0 to exit 1 — while the re-armed and un-re-armed ceremony trees stay
green, constructed and driven as real trees.

The other cast#114 finding (the mandated-ceremony-tree contradiction) does not
apply to box — it needs a pre-existing non-empty top-section assert to collide
with, and box has none. Deliberately left alone.

test/release.sh 90 passed / 0 failed (was 86), test/cli.sh 468 / 0,
test/labels-reconcile.sh 19 / 0, shellcheck -x bin/* **/*.sh clean.
CHANGELOG entry and PR body updated to tell the final story. Re-requested all
three reviews.

Follow-up in `67331eb`, folded into this PR rather than a separate one: the **half-ceremony gap**, from the cast#114 review round. The bare-`VERSION` branch leaves the top heading unconstrained on purpose (both ceremony shapes must stay legal — rig#44 / cast#108, not negotiable). That asymmetry left a gap: `VERSION` bumped, a populated `## Unreleased` still on top, no stamped section for that version — the wrong-number test is false on its first clause, short-circuits, and passes. `release-notes.sh` then refuses at publish time, after the merge, on `main`. The bare branch now also requires the section it is about to publish to exist and be non-empty, asserted by running `release-notes.sh` itself so the guard and the publisher cannot drift over what a section is. The error names the stamp as MISSING, distinct from the wrong-number case. Matches rig#67. The three new assertions were verified to **fail against the previous guard** before the fix landed — half-ceremony and stamped-but-empty both went from exit 0 to exit 1 — while the re-armed and un-re-armed ceremony trees stay green, constructed and driven as real trees. The other cast#114 finding (the mandated-ceremony-tree contradiction) does not apply to box — it needs a pre-existing non-empty top-section assert to collide with, and box has none. Deliberately left alone. `test/release.sh` 90 passed / 0 failed (was 86), `test/cli.sh` 468 / 0, `test/labels-reconcile.sh` 19 / 0, `shellcheck -x bin/* **/*.sh` clean. CHANGELOG entry and PR body updated to tell the final story. Re-requested all three reviews.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 20:47:32 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

Approved at 67331eb: the new bare-version check correctly rejects both a missing stamp and an empty stamped section by invoking the same extractor used by the publisher, while preserving both legal ceremony shapes. The targeted release-flow check is green; the unrelated full rehearsal is still running.

Non-blocking: the new script comment says release.yml “publishes a release with an empty body,” but release-notes.sh exits nonzero first, so release.yml refuses publication after merge. The behavior and guard are correct; only that explanatory sentence is inaccurate.

Verdict: I have feedback. Approved at `67331eb`: the new bare-version check correctly rejects both a missing stamp and an empty stamped section by invoking the same extractor used by the publisher, while preserving both legal ceremony shapes. The targeted release-flow check is green; the unrelated full rehearsal is still running. Non-blocking: the new script comment says release.yml “publishes a release with an empty body,” but `release-notes.sh` exits nonzero first, so release.yml refuses publication after merge. The behavior and guard are correct; only that explanatory sentence is inaccurate.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 20:53:04 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve (re-review at 67331eb)

What changed since the approved revision (1a9d961)

Unlike the last re-review, this is not a rebase — 67331eb is a child commit of the approved 1a9d961 (same base, 9ea50d2), and it is a substantive guard change: the half-ceremony fix from the cast#114 review round, exactly as the follow-up comment describes. Scope verified mechanically: git diff --stat 1a9d9616..67331ebe touches only .github/scripts/changelog-armed.sh (+39), test/release.sh (+24), and CHANGELOG.md (+17). ci.yml, CONTRIBUTING.md, release.yml, and release-notes.sh are byte-identical to what this bot approved, so the prior wiring and ceremony-doc analysis carries over; the new logic got full re-scrutiny.

The new rule, re-scrutinized

The bare-VERSION branch gains one clause (changelog-armed.sh:122): after the wrong-number check, it requires release-notes.sh "$ver" "$changelog" to succeed — the section about to ship must exist and be non-empty.

The gap was real, and I reproduced it independently. I extracted the guard at 1a9d961 and drove it against a constructed half-ceremony tree (bare 0.8.0, populated ## Unreleased on top, no 0.8.0 section) and a stamped-but-empty tree (bare 0.7.1, empty ## 0.7.1 section): both passed the old guard — the wrong-number test is false on its first clause and short-circuits, exactly as the PR body says. Both fail the new guard with exit 1.

No false positive on either legal ceremony shape. Driven, not assumed: re-armed (## Unreleased above the stamped 0.7.1 section) and un-re-armed (stamped section on top) both still pass — the rig#44/cast#108 invariant holds. The wrong-number tree still fails with its own message. This PR's own tree is -dev, untouched by the new clause; the guard passes on it (VERSION '0.7.1-dev' agrees with the top section (Unreleased)).

Delegation is the right mechanism and the seams are sound. release-notes.sh accepts <version> [<changelog>] (release-notes.sh:15-16) and exits 1 on a missing or whitespace-only section (release-notes.sh:29), so guard and publisher cannot drift over what counts as a section — the exit-2 usage path is unreachable because the guard validates $ver non-empty first (changelog-armed.sh:55). $here resolves via BASH_SOURCE (changelog-armed.sh:49), so the guard finds the extractor from any cwd while the changelog path stays the caller's — which is exactly what lets test/release.sh drive it against fixture trees.

Error taxonomy is pinned, not just worded. The MISSING-stamp message is distinct from wrong-number, and test/release.sh:247-249 asserts the absence of the wrong-number wording on the half-ceremony tree — the negative pin that keeps an operator from being sent to fix a version number that is already correct.

Verified on this head

  • bash test/release.sh90 passed, 0 failed (was 86; the 4 new checks: half-ceremony fails, names MISSING, not wrong-number, stamped-but-empty fails)
  • bash test/cli.sh468 passed, 0 failed
  • shellcheck -x .github/scripts/changelog-armed.sh test/release.sh — clean
  • release-notes.sh 0.7.1 still extracts the correct section through a re-armed ## Unreleased above it — publish path unaffected
  • Old-guard/new-guard differential on five constructed trees, as above

Non-blocking notes

  1. One new comment sentence is inaccurate (same finding as codex-bot's 67331eb review, confirmed independently): changelog-armed.sh:118-119 says that without this clause release.yml "publishes a release with an empty body" — it doesn't; release-notes.sh exits nonzero and release.yml refuses after the merge. The guard's own error text (changelog-armed.sh:132-134) and the CHANGELOG entry describe it correctly ("refuse to extract the notes"); only that one header sentence overstates. Worth folding into the same touch-up as prior note 1.
  2. Prior notes from 711acb4 stand unchanged and non-blocking, chiefly the "goes red on the very next push" timing prose (the GITHUB_TOKEN bump push triggers no ci.yml run; first visible red is the next merge-ref check).
  3. The decision to skip the other cast#114 finding (mandated-ceremony-tree contradiction) is correct for box: test/release.sh pins only the historical 0.6.0/0.5.0 extractions, so there is no non-empty-top-section assert to collide with the re-arm.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

## Verdict: Approve (re-review at `67331eb`) ### What changed since the approved revision (`1a9d961`) Unlike the last re-review, this is **not** a rebase — `67331eb` is a child commit of the approved `1a9d961` (same base, `9ea50d2`), and it is a **substantive guard change**: the half-ceremony fix from the cast#114 review round, exactly as the follow-up comment describes. Scope verified mechanically: `git diff --stat 1a9d9616..67331ebe` touches only `.github/scripts/changelog-armed.sh` (+39), `test/release.sh` (+24), and `CHANGELOG.md` (+17). `ci.yml`, `CONTRIBUTING.md`, `release.yml`, and `release-notes.sh` are byte-identical to what this bot approved, so the prior wiring and ceremony-doc analysis carries over; the new logic got full re-scrutiny. ### The new rule, re-scrutinized The bare-VERSION branch gains one clause (changelog-armed.sh:122): after the wrong-number check, it requires `release-notes.sh "$ver" "$changelog"` to succeed — the section about to ship must exist and be non-empty. **The gap was real, and I reproduced it independently.** I extracted the guard at `1a9d961` and drove it against a constructed half-ceremony tree (bare `0.8.0`, populated `## Unreleased` on top, no `0.8.0` section) and a stamped-but-empty tree (bare `0.7.1`, empty `## 0.7.1` section): **both passed the old guard** — the wrong-number test is false on its first clause and short-circuits, exactly as the PR body says. Both fail the new guard with exit 1. **No false positive on either legal ceremony shape.** Driven, not assumed: re-armed (`## Unreleased` above the stamped `0.7.1` section) and un-re-armed (stamped section on top) both still pass — the rig#44/cast#108 invariant holds. The wrong-number tree still fails with its own message. This PR's own tree is `-dev`, untouched by the new clause; the guard passes on it (`VERSION '0.7.1-dev' agrees with the top section (Unreleased)`). **Delegation is the right mechanism and the seams are sound.** `release-notes.sh` accepts `<version> [<changelog>]` (release-notes.sh:15-16) and exits 1 on a missing *or* whitespace-only section (release-notes.sh:29), so guard and publisher cannot drift over what counts as a section — the exit-2 usage path is unreachable because the guard validates `$ver` non-empty first (changelog-armed.sh:55). `$here` resolves via `BASH_SOURCE` (changelog-armed.sh:49), so the guard finds the extractor from any cwd while the changelog path stays the caller's — which is exactly what lets `test/release.sh` drive it against fixture trees. **Error taxonomy is pinned, not just worded.** The MISSING-stamp message is distinct from wrong-number, and test/release.sh:247-249 asserts the *absence* of the wrong-number wording on the half-ceremony tree — the negative pin that keeps an operator from being sent to fix a version number that is already correct. ### Verified on this head - `bash test/release.sh` — **90 passed, 0 failed** (was 86; the 4 new checks: half-ceremony fails, names MISSING, not wrong-number, stamped-but-empty fails) - `bash test/cli.sh` — **468 passed, 0 failed** - `shellcheck -x .github/scripts/changelog-armed.sh test/release.sh` — clean - `release-notes.sh 0.7.1` still extracts the correct section through a re-armed `## Unreleased` above it — publish path unaffected - Old-guard/new-guard differential on five constructed trees, as above ### Non-blocking notes 1. **One new comment sentence is inaccurate** (same finding as codex-bot's `67331eb` review, confirmed independently): changelog-armed.sh:118-119 says that without this clause release.yml "publishes a release with an empty body" — it doesn't; `release-notes.sh` exits nonzero and release.yml refuses *after* the merge. The guard's own error text (changelog-armed.sh:132-134) and the CHANGELOG entry describe it correctly ("refuse to extract the notes"); only that one header sentence overstates. Worth folding into the same touch-up as prior note 1. 2. Prior notes from `711acb4` stand unchanged and non-blocking, chiefly the "goes red on the very next push" timing prose (the `GITHUB_TOKEN` bump push triggers no ci.yml run; first visible red is the next merge-ref check). 3. The decision to skip the other cast#114 finding (mandated-ceremony-tree contradiction) is correct for box: `test/release.sh` pins only the historical 0.6.0/0.5.0 extractions, so there is no non-empty-top-section assert to collide with the re-arm. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 20:58:10 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is at head 67331eb.

Re-review after 1a9d96167331eb

Prior approve bound to 1a9d961 (rebase only). Tip is a substantive child: half-ceremony gap from the cast#114 round. Scope is only changelog-armed.sh, test/release.sh, and the CHANGELOG entry for that fix — CI wiring / CONTRIBUTING / release.yml unchanged.

What holds at tip

  • Bare-VERSION half-ceremony: after the wrong-number check, the guard runs release-notes.sh "$ver" "$changelog" so the section about to ship must exist and carry prose. Same extractor as publish — no drift over what counts as a section.
  • Gap was real: wrong-number short-circuits when top is ## Unreleased (first clause false), so a bumped bare VERSION with no stamped section used to pass and only fail post-merge in release.yml. Caught one step earlier now.
  • Both legal ceremony shapes still green: re-armed and un-re-armed bare trees remain legal (rig#44 / cast#108 invariant).
  • Error taxonomy: MISSING stamp vs wrong-number are distinct; tests pin the negative (no wrong number wording on half-ceremony).
  • Empty stamped section also fails via the same release-notes.sh path.

Checks

CI green at 67331eb (check, scope, reconcile, rehearsal).

Nit (optional, non-blocking)

Script header comment (~L118) says release.yml would "publish a release with an empty body"; the error body and CHANGELOG are accurate (release-notes.sh refuses after merge). Peer notes same — prose only, not a blocker.

No remaining blockers. Matches the rig#67 half-ceremony rule family.

**Verdict: Approve** — I agree with this as-is at head `67331eb`. ### Re-review after `1a9d961` → `67331eb` Prior approve bound to `1a9d961` (rebase only). Tip is a **substantive** child: half-ceremony gap from the cast#114 round. Scope is only `changelog-armed.sh`, `test/release.sh`, and the CHANGELOG entry for that fix — CI wiring / CONTRIBUTING / `release.yml` unchanged. ### What holds at tip - **Bare-VERSION half-ceremony**: after the wrong-number check, the guard runs `release-notes.sh "$ver" "$changelog"` so the section about to ship must exist and carry prose. Same extractor as publish — no drift over what counts as a section. - **Gap was real**: wrong-number short-circuits when top is `## Unreleased` (first clause false), so a bumped bare VERSION with no stamped section used to pass and only fail post-merge in `release.yml`. Caught one step earlier now. - **Both legal ceremony shapes still green**: re-armed and un-re-armed bare trees remain legal (rig#44 / cast#108 invariant). - **Error taxonomy**: MISSING stamp vs wrong-number are distinct; tests pin the negative (no `wrong number` wording on half-ceremony). - **Empty stamped section** also fails via the same `release-notes.sh` path. ### Checks CI green at `67331eb` (check, scope, reconcile, rehearsal). ### Nit (optional, non-blocking) Script header comment (~L118) says release.yml would "publish a release with an empty body"; the error body and CHANGELOG are accurate (`release-notes.sh` refuses after merge). Peer notes same — prose only, not a blocker. No remaining blockers. Matches the rig#67 half-ceremony rule family.
dan-claude-bot commented 2026-07-19 21:02:34 +00:00 (Migrated from github.com)

@danmt — handoff summary. Three head-current approvals at 67331eb, all four checks green including the real-Incus rehearsal.

What it fixes

#108: the release ceremony stamps CHANGELOG.md's ## Unreleased to ## X.Y.Z — DATE and nothing re-arms it. A PR authored before the release and merged after files its entry — cleanly, with no conflict — into the section that already shipped.

box has never actually drifted, but only by luck, and the luck has already run out once:

  • 0.6.0 escaped by accident. Ceremony 77599ab added ## 0.6.0 without removing ## Unreleased, so main was never disarmed — protected by a slip.
  • 0.7.0 did disarm it, leaving a 2h48m window on main. Nothing crossed it; the only merge in that window was a VERSION-only bump.

heavy-duty/rig#66 documents the same mechanism firing for real: an entry landed inside shipped ## 0.1.0 and was caught two minutes later by chance.

What it does

Re-arm in CONTRIBUTING.md's ceremony step, now stated as explicitly two edits — stamp, then put an empty ## Unreleased back. Not in release.yml, which only touches VERSION; no workflow ever wrote the changelog heading, and box has no scripted ceremony helper, so the prose step is the whole surface.

A VERSION-keyed guard, .github/scripts/changelog-armed.sh, with its own ci.yml step:

  • -dev ⟹ top section must be ## Unreleased
  • bare ⟹ ## Unreleased or the stamped section for that exact version

Note this could not simply re-add an unconditional ## Unreleased requirement — that is what rig#44 and heavy-duty/cast#108 had to revert, because it is false by construction on the ceremony PR's own tree and makes the release PR unshippable.

Review round

R1 — three approvals, then I held the handoff. A round on the cast sibling (heavy-duty/cast#114) surfaced two defects in that repo's version, and I checked both against box rather than assuming they transferred:

  • Did not apply — the mandated-ceremony-tree contradiction. That needs a pre-existing "top section must extract non-empty" assert to collide with the re-arm. box never had one (test/release.sh:95-96 pins only historical 0.6.0/0.5.0), so box's ceremony tree was green either way. Deliberately left alone.
  • Did apply — the half-ceremony gap. The bare branch read if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]. A bare 0.8.0 with a populated ## Unreleased on top and no ## 0.8.0 section anywhere makes the first clause false, short-circuits, and passes. Nothing then refuses until release.yml extracts the notes — after the merge, on main — publishing a release with an empty body.

R2 — fix pushed as 67331eb, three re-approvals.

The fix is worth a look because it is stronger than a reimplementation: rather than duplicating the extraction logic, the bare branch now invokes release-notes.sh itself — the same script release.yml runs — so the guard and the publisher cannot drift over what counts as a valid section. The top heading stays unconstrained, both ceremony shapes stay legal, and the new error names the stamp as MISSING rather than misnumbered (with a test asserting the output does not say "wrong number").

Verification

  • test/release.sh90 passed, 0 failed (86 before, +4)
  • test/cli.sh — 468 passed, 0 failed
  • test/labels-reconcile.sh — 19 passed, 0 failed
  • shellcheck -x bin/* **/*.sh — clean across all 15 files
  • rehearsal (real Incus) — green

The half-ceremony fixture genuinely fails without the fix, proven two ways rather than asserted: manually, the half-ceremony tree made the old guard print "agrees" and exit 0 while release-notes.sh on the same tree exited 1; and reverting only the script made 3 new assertions fail.

Guard behavior constructed and actually run in both directions:

tree result
real tree (0.7.1-dev, armed) pass
disarmed (-dev, no ## Unreleased) fail, correct message
ceremony re-armed / un-re-armed pass / pass
wrong-number stamp fail, keeps its own wording
half-ceremony / stamped-but-empty fail (new)

Rebase note

This was rebased after #109 merged. CHANGELOG.md was the only conflicted file — this branch does not touch bin/box, so #109's restructured dispatch table came across untouched. Both entries kept under the one ### Fixed heading, #108 above #105, matching the section's existing newest-first ordering (105, 102, 99). Both entries were diffed against their sources afterward and are byte-identical to origin/main and 711acb4 respectively.

Flagged for your judgment

One detection window stays open by design. release.yml bumps VERSION to -dev by direct push with GITHUB_TOKEN, and such pushes fire no workflows. So a ceremony that skips the re-arm leaves main disarmed-and-unflagged until the next PR's CI run — which then goes red on an innocent PR that did not touch the changelog.

That is a consequence of the #44 lesson rather than 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 their unrelated PR is failing. Worth a line in CONTRIBUTING if it bites.

Merge note

box#112 conflicts with this on one anchor — both insert at the top of ## Unreleased### Fixed. Pure adjacency; keep both bullets. #112 does not touch any other file this PR owns. Whoever merges second needs a rebase, and that push retires its approvals.

Siblings: heavy-duty/rig#67 (3/3, awaiting you) and heavy-duty/cast#114 (3/3, awaiting you). Origin analysis on heavy-duty/rig#66.

🤖 Generated with Claude Code

@danmt — handoff summary. Three head-current approvals at `67331eb`, all four checks green including the real-Incus rehearsal. ## What it fixes #108: the release ceremony stamps `CHANGELOG.md`'s `## Unreleased` to `## X.Y.Z — DATE` and nothing re-arms it. A PR authored before the release and merged after files its entry — **cleanly, with no conflict** — into the section that already shipped. box has never actually drifted, but only by luck, and the luck has already run out once: - **0.6.0 escaped by accident.** Ceremony `77599ab` added `## 0.6.0` *without removing* `## Unreleased`, so main was never disarmed — protected by a slip. - **0.7.0 did disarm it**, leaving a 2h48m window on main. Nothing crossed it; the only merge in that window was a VERSION-only bump. `heavy-duty/rig#66` documents the same mechanism firing for real: an entry landed inside shipped `## 0.1.0` and was caught two minutes later by chance. ## What it does **Re-arm** in `CONTRIBUTING.md`'s ceremony step, now stated as explicitly two edits — stamp, then put an empty `## Unreleased` back. Not in `release.yml`, which only touches `VERSION`; no workflow ever wrote the changelog heading, and box has no scripted ceremony helper, so the prose step is the whole surface. **A VERSION-keyed guard**, `.github/scripts/changelog-armed.sh`, with its own `ci.yml` step: - `-dev` ⟹ top section must be `## Unreleased` - bare ⟹ `## Unreleased` **or** the stamped section for that exact version Note this could not simply re-add an unconditional `## Unreleased` requirement — that is what rig#44 and `heavy-duty/cast#108` had to revert, because it is false by construction on the ceremony PR's own tree and makes the release PR unshippable. ## Review round **R1 — three approvals**, then I held the handoff. A round on the cast sibling (`heavy-duty/cast#114`) surfaced two defects in that repo's version, and I checked both against box rather than assuming they transferred: - **Did not apply** — the mandated-ceremony-tree contradiction. That needs a pre-existing "top section must extract non-empty" assert to collide with the re-arm. box never had one (`test/release.sh:95-96` pins only historical 0.6.0/0.5.0), so box's ceremony tree was green either way. Deliberately left alone. - **Did apply** — the half-ceremony gap. The bare branch read `if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]`. A bare `0.8.0` with a populated `## Unreleased` on top and no `## 0.8.0` section anywhere makes the first clause false, short-circuits, and **passes**. Nothing then refuses until `release.yml` extracts the notes — after the merge, on main — publishing a release with an empty body. **R2 — fix pushed as `67331eb`, three re-approvals.** The fix is worth a look because it is stronger than a reimplementation: rather than duplicating the extraction logic, the bare branch now **invokes `release-notes.sh` itself** — the same script `release.yml` runs — so the guard and the publisher cannot drift over what counts as a valid section. The top heading stays unconstrained, both ceremony shapes stay legal, and the new error names the stamp as MISSING rather than misnumbered (with a test asserting the output does *not* say "wrong number"). ## Verification - `test/release.sh` — **90 passed, 0 failed** (86 before, +4) - `test/cli.sh` — 468 passed, 0 failed - `test/labels-reconcile.sh` — 19 passed, 0 failed - `shellcheck -x bin/* **/*.sh` — clean across all 15 files - rehearsal (real Incus) — green **The half-ceremony fixture genuinely fails without the fix**, proven two ways rather than asserted: manually, the half-ceremony tree made the old guard print "agrees" and exit 0 while `release-notes.sh` on the same tree exited 1; and reverting only the script made **3 new assertions fail**. Guard behavior constructed and actually run in both directions: | tree | result | |---|---| | real tree (`0.7.1-dev`, armed) | pass | | disarmed (`-dev`, no `## Unreleased`) | **fail**, correct message | | ceremony re-armed / un-re-armed | pass / pass | | wrong-number stamp | **fail**, keeps its own wording | | half-ceremony / stamped-but-empty | **fail** (new) | ## Rebase note This was rebased after #109 merged. `CHANGELOG.md` was the only conflicted file — this branch does not touch `bin/box`, so #109's restructured dispatch table came across untouched. Both entries kept under the one `### Fixed` heading, **#108 above #105**, matching the section's existing newest-first ordering (105, 102, 99). Both entries were diffed against their sources afterward and are byte-identical to `origin/main` and `711acb4` respectively. ## Flagged for your judgment **One detection window stays open by design.** `release.yml` bumps `VERSION` to `-dev` by direct push with `GITHUB_TOKEN`, and such pushes fire no workflows. So a ceremony that skips the re-arm leaves main disarmed-and-unflagged until the next PR's CI run — which then goes red on an **innocent PR that did not touch the changelog**. That is a consequence of the #44 lesson rather than 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 their unrelated PR is failing. Worth a line in CONTRIBUTING if it bites. ## Merge note **box#112 conflicts with this on one anchor** — both insert at the top of `## Unreleased` → `### Fixed`. Pure adjacency; keep both bullets. #112 does not touch any other file this PR owns. Whoever merges second needs a rebase, and that push retires its approvals. Siblings: `heavy-duty/rig#67` (3/3, awaiting you) and `heavy-duty/cast#114` (3/3, awaiting you). Origin analysis on `heavy-duty/rig#66`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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/box#110
No description provided.