A PR that predates a release merges its Unreleased entry into the released section — silently #66
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
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/rig#66
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?
A PR that predates a release merges its
## Unreleasedentry into the released section — silentlyWhat happened
release: 0.1.0) merged. The ceremony stampsCHANGELOG.md's## Unreleasedheading to## 0.1.0 — 2026-07-19.## Unreleased, which no longer existed.Git merged that entry cleanly, with no conflict, into
## 0.1.0's### Fixed— the notes of an already-published release.main's changelogthen claimed 0.1.0 contained a fix that shipped an hour after it.
Why it is easy to miss
There is no conflict to notice. The stamped heading and the incoming entry
do not overlap textually; the entry simply lands under whatever heading now
occupies that position. #54's rebase in this batch produced zero conflicts
and still placed a BREAKING entry inside 0.1.0's notes.
The signal an author would normally rely on — "git told me to look" — is
absent precisely when the outcome is wrong.
Blast radius
Every PR open across a release boundary. In this batch that was all four
(#53, #54, #55, #60), because they were authored before 0.1.0 and merged
after. This will recur at every release with any PR in flight, which is the
normal state of the repo.
What was NOT damaged, checked
The published GitHub release body for 0.1.0 is clean —
release.ymlgenerates it from the changelog section at release time, before the late
merges land. So the artifact users read is correct; the repository's own
CHANGELOG.mdis what drifts.That is a meaningful limit on severity, and also why nobody would notice:
the wrong version of the file is the one only maintainers read.
Current state
#60's entry is inside
## 0.1.0onmainright now. #53/#54/#55 each carry acorrected structure (a rebuilt
## Unreleasedholding their own entry plus#60's, moved back out), so merging any one of them repairs
main.Options
test/release.sh(or the labels workflow) fails a PR whosediff adds an entry under a
## X.Y.Zheading rather than## Unreleased.Cheap, mechanical, catches it at the only moment someone is looking. This
repo already greps its own changelog structure in
test/release.sh, so themachinery exists.
## Unreleasedsection immediately after stamping, so a late-merging PR has a correct
heading to land in. Fixes the cause rather than detecting the symptom —
the entry goes to the right place with no author action. Downside: an empty
section sitting in the file between releases.
someone hand-edits or the re-arm is skipped.
Option 2 alone would have prevented this entirely, and it composes with the
existing "release re-arms main" step (box#96's equivalent already bumps
VERSIONto-devin the same job — adding the heading there is the sameshape of change).
Note
box has the identical release flow (box#96) and the same exposure. Worth
fixing in both, or deciding deliberately that one repo's cadence makes it a
non-issue.
Checked this against box and cast, and dug into the history. Three findings — one corrects the premise, one is worse than stated, one is new.
The premise needs a correction: no workflow stamps the heading
The issue reads as though the release job does the stamping, and Option 2 proposes fixing it there. It doesn't. Stamping is done by hand in the ceremony PR — verifiable in all three ceremony commits:
07c0edd:-## Unreleased/+## 0.7.0 — 2026-07-19a702f1f:-## Unreleased/+## 0.1.0 — 2026-07-19008161a:-## Unreleased/+## 0.1.0 — 2026-07-19The workflows only ever touch VERSION/package.json (box
release.yml:164-186, rig:216-238, cast:224-250). The-devre-arm exists for the version and was never extended to the changelog heading. CONTRIBUTING says "stamp the Unreleased section" (box:51, rig:53, cast:53) and stops.So Option 2 is still right in spirit, but it lands in the ceremony step / PR template, not the release job. Worth knowing before someone opens
release.ymllooking for the bug.Confirmed on rig, and reproduced exactly
The calibration case is real:
b8e8e79(authored 17:29:20Z, #60) wrote its#58entry under## Unreleasedon its branch6ff90f0at 18:03:35Z stamped that heading to## 0.1.0 — 2026-07-1967386b4) — clean, no conflict. At that commit the#58entry sits under## 0.1.0 — 2026-07-19Repaired ~2min later by
0ff520c. Currentorigin/mainis clean; the 0.1.0 section is byte-identical to the tag.box and cast: both exposed. cast is armed right now.
box — exposed at 0.7.0, no drift on either release. 0.6.0 escaped by accident: its ceremony (
77599ab) added## 0.6.0without removing## Unreleased, so main was never disarmed. 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 (cdf4c04, #98) was a VERSION-only bump. The first changelog-touching branch after,b58ce55, has merge-base exactly the release merge and re-added## Unreleaseditself.cast — disarmed on main as of now.
origin/main == f098a27 ==the 0.1.0 tag, zero commits after. Top heading is## 0.1.0 — 2026-07-19at line 8, no## Unreleasedabove it. No in-flight branch currently carries a changelog edit, so there's no drift today — but this is precisely the state rig was in at 18:03Z, and the next merged PR that writes under## Unreleasedlands inside shipped 0.1.0.Confirming the issue's blast-radius claim: published release bodies are clean in all three. Notes are extracted from the tree at the tag, before late merges land — box
release.yml:128/:208, rig:71/:172, cast:164. Drift can only ever corruptCHANGELOG.mdon main.The part that isn't in the issue: the CI guards cannot catch this, and that's recent
Option 1 proposes a CI guard on the grounds that "this repo already greps its own changelog structure." It does — but the existing guard is structurally incapable of catching this, and was made so deliberately.
rig
test/release.sh:103-107and casttest/release.test.ts:157-165both assert only that whatever the top##section is extracts non-empty. Neither requires it to be## Unreleased. box has no top-section guard at all —test/release.sh:95-96pins only that the 0.6.0 and 0.5.0 sections still extract.That's not an oversight. Both were relaxed by rig#44 and cast#108, because the old guard demanded a literal
## Unreleasedin the real CHANGELOG.md — which is false by construction on the tree the ceremony's own PR produces, making the release PR unshippable.So the repo traded away the only signal that main was disarmed, in exchange for a shippable ceremony. A guard that just re-adds the
## Unreleasedrequirement would re-break the ceremony PR; that path is already closed.What would work: make the guard state-aware rather than unconditional. A stamped top section is legal exactly when
VERSIONis bare — true on the ceremony PR and on main only until the-devbump lands. Everywhere else, the top section must be## Unreleased. That distinguishes the two states the current guard collapses, and it stays green through the ceremony.Suggestion
Option 3, with the placement corrected: re-arm in the ceremony step (not the release job), plus the VERSION-keyed guard above. The re-arm makes the common case correct with no author action; the guard catches a hand-edit or a skipped re-arm. The empty-section downside of Option 2 seems cheap next to a changelog that misattributes shipped releases.
Happy to open this across all three repos if the approach looks right. cast is the one worth doing first — it's disarmed on main today, and its own changelog entry would have to re-create
## Unreleased, so the fix and the repair are the same diff.