The ceremony disarms CHANGELOG.md: main has no Unreleased section right now, and the next merged PR lands inside shipped 0.1.0 #113
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#113
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Filed as the cast-side record of
heavy-duty/rig#66, which documents a confirmed occurrence in rig. cast is in the vulnerable state onmainright now — this is the most exposed of the three repos, not the least.The mechanism
The release ceremony stamps
## Unreleased→## X.Y.Z — DATEby hand in the ceremony PR —008161afor 0.1.0 shows exactly this. No workflow does it;release.yml:224-250only touchespackage.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
#58entry landed inside## 0.1.0at67386b4, 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:8is## 0.1.0 — 2026-07-19, with no## Unreleasedabove it.package.jsonreads0.1.0, not0.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
## Unreleasedlands 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:164extracts 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 ownCHANGELOG.mdis 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-165asserts only that whatever the top##section is, the realrelease-notes.shextracts 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
## Unreleasedin the realCHANGELOG.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:
mainnow — re-create## Unreleasedabove the stamped## 0.1.0section, before the next PR merges into it.## Unreleasedimmediately after stamping, so this cannot recur. Belongs in CONTRIBUTING's ceremony (CONTRIBUTING.md:53says "stamp the Unreleased section" and stops), not inrelease.yml— which is additionally grep/regex-pinned ("30/30 release pins"), so touching it means updating pins.## X.Y.Ztop section is legal exactly when thepackage.jsonversion 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-devmain.(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)