The ceremony disarms CHANGELOG.md: a PR predating a release lands its Unreleased entry inside the shipped section, silently #108

Closed
opened 2026-07-19 19:37:35 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-19 19:37:35 +00:00 (Migrated from github.com)

Filed as the box-side record of heavy-duty/rig#66, which documents a confirmed occurrence in rig. box has the same mechanism and has not drifted yet — this is the "decide deliberately" half of that issue's closing note, answered as no, it is not a non-issue here.

The mechanism

The release ceremony stamps ## Unreleased## X.Y.Z — DATE by hand in the ceremony PR07c0edd for 0.7.0 shows exactly this. No workflow does it; release.yml:164-186 only touches VERSION. Nothing re-adds the heading afterwards, so main sits with no ## Unreleased between the ceremony and the next PR that happens to re-create one.

A PR authored before the release and merged after it wrote its entry under ## Unreleased. That heading no longer exists, so git lands the entry — cleanly, with no conflict — under whatever heading now occupies that position: the just-shipped section. The signal an author would rely on ("git told me to look") is absent precisely when the outcome is wrong.

box's actual exposure

No drift on either release. But the reason is luck, not design:

  • 0.6.0 escaped by accident. Ceremony 77599ab added ## 0.6.0 without removing ## Unreleased, so main was never disarmed. Structurally protected by a slip in the ceremony.
  • 0.7.0 did disarm it, leaving a 2h48m window on main (14:24:18Z → 17:12:28Z). Nothing crossed it — the only merge in that window was cdf4c04 (#98), a VERSION-only bump touching one file. The first changelog-touching branch after, b58ce55, has merge-base exactly the release merge fb7fc84, so it was cut from the stamped tree and re-added ## Unreleased itself.

Both released sections on main are currently byte-identical to their tags. The exposure is real; the sample size is just small. box merges PRs in flight across releases as a matter of course, so this recurs at every release with the normal state of the repo.

Blast radius is bounded, which is also why nobody notices

The published GitHub release body is safe — release.yml:128 and :208 extract notes from the changelog at release time, from the tree at the tag, before any later PR can merge. So the artifact users read stays correct; the repository's own CHANGELOG.md is what drifts. The wrong version of the file is the one only maintainers read.

box has no guard at all

Worth stating plainly, because rig and cast at least have something: box's test/release.sh:95-96 pins only that the 0.6.0 and 0.5.0 sections still extract. There is no top-section guard. rig (test/release.sh:103-107) and cast (test/release.test.ts:157-165) have one, but it asserts only that whatever the top ## section is extracts non-empty — which cannot catch this either, and that relaxation was deliberate (rig#44, cast#108) because the old guard demanded a literal ## Unreleased, false by construction on the ceremony PR's own tree.

So the naive fix is already closed off: re-introducing an unconditional ## Unreleased requirement re-breaks the ceremony PR.

Proposed fix

Both halves, matching what rig and cast are getting:

  1. Re-arm in the ceremony step — re-add an empty ## Unreleased immediately after stamping, so a late-merging PR has a correct heading to land in. This belongs in CONTRIBUTING's ceremony (CONTRIBUTING.md:51 says "stamp the Unreleased section" and stops), not in release.yml, which only touches VERSION. Fixes the cause: the entry goes to the right place with no author action.
  2. A VERSION-keyed CI guard — 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. That distinguishes the two states the current guards collapse, stays green through the ceremony PR, and goes red on a disarmed -dev main.

(1) makes the common case correct; (2) catches a hand-edit or a skipped re-arm. The cost is an empty section sitting in the file between releases, which seems cheap next to a changelog that misattributes shipped releases.

Refs

heavy-duty/rig#66 (origin, with the confirmed occurrence and the reproduction) · rig#44 / heavy-duty/cast#108 (why the existing guards were relaxed)

Filed as the box-side record of `heavy-duty/rig#66`, which documents a confirmed occurrence in rig. box has the same mechanism and has not drifted yet — this is the "decide deliberately" half of that issue's closing note, answered as *no, it is not a non-issue here*. ## The mechanism The release ceremony stamps `## Unreleased` → `## X.Y.Z — DATE` **by hand in the ceremony PR** — `07c0edd` for 0.7.0 shows exactly this. No workflow does it; `release.yml:164-186` only touches `VERSION`. Nothing re-adds the heading afterwards, so `main` sits with no `## Unreleased` between the ceremony and the next PR that happens to re-create one. A PR authored before the release and merged after it wrote its entry under `## Unreleased`. That heading no longer exists, so git lands the entry — **cleanly, with no conflict** — under whatever heading now occupies that position: the just-shipped section. The signal an author would rely on ("git told me to look") is absent precisely when the outcome is wrong. ## box's actual exposure **No drift on either release.** But the reason is luck, not design: - **0.6.0 escaped by accident.** Ceremony `77599ab` *added* `## 0.6.0` without removing `## Unreleased`, so main was never disarmed. Structurally protected by a slip in the ceremony. - **0.7.0 did disarm it**, leaving a 2h48m window on main (14:24:18Z → 17:12:28Z). Nothing crossed it — the only merge in that window was `cdf4c04` (#98), a VERSION-only bump touching one file. The first changelog-touching branch after, `b58ce55`, has merge-base exactly the release merge `fb7fc84`, so it was cut from the stamped tree and re-added `## Unreleased` itself. Both released sections on main are currently byte-identical to their tags. The exposure is real; the sample size is just small. box merges PRs in flight across releases as a matter of course, so this recurs at every release with the normal state of the repo. ## Blast radius is bounded, which is also why nobody notices The **published** GitHub release body is safe — `release.yml:128` and `:208` extract notes from the changelog at release time, from the tree at the tag, before any later PR can merge. So the artifact users read stays correct; the repository's own `CHANGELOG.md` is what drifts. The wrong version of the file is the one only maintainers read. ## box has no guard at all Worth stating plainly, because rig and cast at least have something: box's `test/release.sh:95-96` pins only that the 0.6.0 and 0.5.0 sections still extract. There is **no top-section guard**. rig (`test/release.sh:103-107`) and cast (`test/release.test.ts:157-165`) have one, but it asserts only that *whatever the top `## ` section is* extracts non-empty — which cannot catch this either, and that relaxation was deliberate (rig#44, cast#108) because the old guard demanded a literal `## Unreleased`, false by construction on the ceremony PR's own tree. So the naive fix is already closed off: re-introducing an unconditional `## Unreleased` requirement re-breaks the ceremony PR. ## Proposed fix Both halves, matching what rig and cast are getting: 1. **Re-arm in the ceremony step** — re-add an empty `## Unreleased` immediately after stamping, so a late-merging PR has a correct heading to land in. This belongs in CONTRIBUTING's ceremony (`CONTRIBUTING.md:51` says "stamp the Unreleased section" and stops), **not** in `release.yml`, which only touches VERSION. Fixes the cause: the entry goes to the right place with no author action. 2. **A VERSION-keyed CI guard** — 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`. That distinguishes the two states the current guards collapse, stays green through the ceremony PR, and goes red on a disarmed `-dev` main. (1) makes the common case correct; (2) catches a hand-edit or a skipped re-arm. The cost is an empty section sitting in the file between releases, which seems cheap next to a changelog that misattributes shipped releases. ## Refs `heavy-duty/rig#66` (origin, with the confirmed occurrence and the reproduction) · rig#44 / `heavy-duty/cast#108` (why the existing guards were relaxed)
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#108
No description provided.