The ceremony disarms CHANGELOG.md: main has no Unreleased section right now, and the next merged PR lands inside shipped 0.1.0 #113

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

Filed as the cast-side record of heavy-duty/rig#66, which documents a confirmed occurrence in rig. cast is in the vulnerable state on main right now — this is the most exposed of the three repos, not the least.

The mechanism

The release ceremony stamps ## Unreleased## X.Y.Z — DATE by hand in the ceremony PR008161a for 0.1.0 shows exactly this. No workflow does it; release.yml:224-250 only touches package.json. Nothing re-adds the heading afterwards.

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.

This is not hypothetical. In rig it happened: #60's #58 entry landed inside ## 0.1.0 at 67386b4, an hour after 0.1.0 shipped, and was repaired two minutes later only because someone was looking.

cast is disarmed as of filing

  • origin/main == f098a27 == the 0.1.0 tag. Zero commits after the release.
  • CHANGELOG.md:8 is ## 0.1.0 — 2026-07-19, with no ## Unreleased above it.
  • package.json reads 0.1.0, not 0.1.1-dev — the post-release re-arm has not landed either.

No in-flight branch currently carries a changelog edit, so there is no drift today. But this is precisely the state rig was in at 18:03Z, and the next merged PR that writes an entry under ## Unreleased lands it inside shipped 0.1.0.

Blast radius is bounded, which is also why nobody notices

The published GitHub release body is safe — release.yml:164 extracts notes from the changelog at release time, from the tree at the tag, before any later PR can merge. 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.

The existing guard cannot catch this, and that is recent

test/release.test.ts:157-165 asserts only that whatever the top ## section is, the real release-notes.sh extracts non-empty from it. It does not require that section to be ## Unreleased.

That is not an oversight. It was relaxed by #108, because the old guard demanded a literal ## Unreleased in the real CHANGELOG.md — false by construction on the tree the ceremony's own PR produces, which made the release PR unshippable. The repo traded away the only signal that main was disarmed in exchange for a shippable ceremony.

So the naive fix is already closed off: re-introducing the unconditional requirement regresses #108.

Proposed fix

Three parts, the first specific to cast's current state:

  1. Repair main now — re-create ## Unreleased above the stamped ## 0.1.0 section, before the next PR merges into it.
  2. Re-arm in the ceremony step — re-add an empty ## Unreleased immediately after stamping, so this cannot recur. Belongs in CONTRIBUTING's ceremony (CONTRIBUTING.md:53 says "stamp the Unreleased section" and stops), not in release.yml — which is additionally grep/regex-pinned ("30/30 release pins"), so touching it means updating pins.
  3. A version-keyed CI guard — a stamped ## X.Y.Z top section is legal exactly when the package.json version is bare; whenever it ends in -dev, the top section must be ## Unreleased. That distinguishes the two states the current guard collapses, stays green through the ceremony PR, and goes red on a disarmed -dev main.

(2) makes the common case correct; (3) 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.

There is a neat recursion in the fix: any PR that closes this must itself write a changelog entry under ## Unreleased — which does not exist — so re-creating it is the repair.

Refs

heavy-duty/rig#66 (origin, with the confirmed occurrence and reproduction) · heavy-duty/box#108 (box-side sibling) · #108 (why the existing guard was relaxed)

Filed as the cast-side record of `heavy-duty/rig#66`, which documents a confirmed occurrence in rig. **cast is in the vulnerable state on `main` right now** — this is the most exposed of the three repos, not the least. ## The mechanism The release ceremony stamps `## Unreleased` → `## X.Y.Z — DATE` **by hand in the ceremony PR** — `008161a` for 0.1.0 shows exactly this. No workflow does it; `release.yml:224-250` only touches `package.json`. Nothing re-adds the heading afterwards. 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. This is not hypothetical. In rig it happened: #60's `#58` entry landed inside `## 0.1.0` at `67386b4`, an hour after 0.1.0 shipped, and was repaired two minutes later only because someone was looking. ## cast is disarmed as of filing - `origin/main` == `f098a27` == the 0.1.0 tag. **Zero commits after the release.** - `CHANGELOG.md:8` is `## 0.1.0 — 2026-07-19`, with **no `## Unreleased` above it**. - `package.json` reads `0.1.0`, not `0.1.1-dev` — the post-release re-arm has not landed either. No in-flight branch currently carries a changelog edit, so there is no drift *today*. But this is precisely the state rig was in at 18:03Z, and **the next merged PR that writes an entry under `## Unreleased` lands it inside shipped 0.1.0**. ## Blast radius is bounded, which is also why nobody notices The **published** GitHub release body is safe — `release.yml:164` extracts notes from the changelog at release time, from the tree at the tag, before any later PR can merge. 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. ## The existing guard cannot catch this, and that is recent `test/release.test.ts:157-165` asserts only that **whatever the top `## ` section is**, the real `release-notes.sh` extracts non-empty from it. It does not require that section to be `## Unreleased`. That is not an oversight. It was **relaxed by #108**, because the old guard demanded a literal `## Unreleased` in the real `CHANGELOG.md` — false by construction on the tree the ceremony's own PR produces, which made the release PR unshippable. The repo traded away the only signal that main was disarmed in exchange for a shippable ceremony. So the naive fix is already closed off: re-introducing the unconditional requirement regresses #108. ## Proposed fix Three parts, the first specific to cast's current state: 1. **Repair `main` now** — re-create `## Unreleased` above the stamped `## 0.1.0` section, before the next PR merges into it. 2. **Re-arm in the ceremony step** — re-add an empty `## Unreleased` immediately after stamping, so this cannot recur. Belongs in CONTRIBUTING's ceremony (`CONTRIBUTING.md:53` says "stamp the Unreleased section" and stops), **not** in `release.yml` — which is additionally grep/regex-pinned ("30/30 release pins"), so touching it means updating pins. 3. **A version-keyed CI guard** — a stamped `## X.Y.Z` top section is legal exactly when the `package.json` version is bare; whenever it ends in `-dev`, the top section must be `## Unreleased`. That distinguishes the two states the current guard collapses, stays green through the ceremony PR, and goes red on a disarmed `-dev` main. (2) makes the common case correct; (3) 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. There is a neat recursion in the fix: any PR that closes this must itself write a changelog entry under `## Unreleased` — which does not exist — so re-creating it *is* the repair. ## Refs `heavy-duty/rig#66` (origin, with the confirmed occurrence and reproduction) · `heavy-duty/box#108` (box-side sibling) · #108 (why the existing guard was 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/cast#113
No description provided.