A PR that predates a release merges its Unreleased entry into the released section — silently #66

Closed
opened 2026-07-19 19:08:54 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-19 19:08:54 +00:00 (Migrated from github.com)

A PR that predates a release merges its ## Unreleased entry into the released section — silently

What happened

  • 18:03:35Z#46 (release: 0.1.0) merged. The ceremony stamps
    CHANGELOG.md's ## Unreleased heading to ## 0.1.0 — 2026-07-19.
  • 18:03:45Z — 0.1.0 published.
  • 19:02:12Z#60 merged. Its changelog entry had been written under
    ## 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 changelog
then 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.yml
generates 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.md is 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.0 on main right now. #53/#54/#55 each carry a
corrected structure (a rebuilt ## Unreleased holding their own entry plus
#60's, moved back out), so merging any one of them repairs main.

Options

  1. A CI guard. test/release.sh (or the labels workflow) fails a PR whose
    diff adds an entry under a ## X.Y.Z heading 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 the
    machinery exists.
  2. Re-arm on release. Have the release job append an empty ## Unreleased
    section 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.
  3. Both. (2) makes the common case correct; (1) catches the case where
    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
VERSION to -dev in the same job — adding the heading there is the same
shape 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.

# A PR that predates a release merges its `## Unreleased` entry into the released section — silently ## What happened - **18:03:35Z** — #46 (`release: 0.1.0`) merged. The ceremony stamps `CHANGELOG.md`'s `## Unreleased` heading to `## 0.1.0 — 2026-07-19`. - **18:03:45Z** — 0.1.0 published. - **19:02:12Z** — #60 merged. Its changelog entry had been written under `## 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 changelog then 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.yml` generates 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.md` is 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.0` on `main` right now. #53/#54/#55 each carry a corrected structure (a rebuilt `## Unreleased` holding their own entry plus #60's, moved back out), so merging any one of them repairs `main`. ## Options 1. **A CI guard.** `test/release.sh` (or the labels workflow) fails a PR whose diff adds an entry under a `## X.Y.Z` heading 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 the machinery exists. 2. **Re-arm on release.** Have the release job append an empty `## Unreleased` section 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. 3. **Both.** (2) makes the common case correct; (1) catches the case where 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 `VERSION` to `-dev` in the same job — adding the heading there is the same shape 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.
dan-claude-bot commented 2026-07-19 19:34:38 +00:00 (Migrated from github.com)

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:

  • box 0.7.0 07c0edd: -## Unreleased / +## 0.7.0 — 2026-07-19
  • rig 0.1.0 a702f1f: -## Unreleased / +## 0.1.0 — 2026-07-19
  • cast 0.1.0 008161a: -## Unreleased / +## 0.1.0 — 2026-07-19

The workflows only ever touch VERSION/package.json (box release.yml:164-186, rig :216-238, cast :224-250). The -dev re-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.yml looking for the bug.

Confirmed on rig, and reproduced exactly

The calibration case is real:

  • b8e8e79 (authored 17:29:20Z, #60) wrote its #58 entry under ## Unreleased on its branch
  • release merge 6ff90f0 at 18:03:35Z stamped that heading to ## 0.1.0 — 2026-07-19
  • #60 merged 19:02:12Z (67386b4) — clean, no conflict. At that commit the #58 entry sits under ## 0.1.0 — 2026-07-19

Repaired ~2min later by 0ff520c. Current origin/main is 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.0 without 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 ## Unreleased itself.

castdisarmed on main as of now. origin/main == f098a27 == the 0.1.0 tag, zero commits after. Top heading is ## 0.1.0 — 2026-07-19 at line 8, no ## Unreleased above 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 ## Unreleased lands 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 corrupt CHANGELOG.md on 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-107 and cast test/release.test.ts:157-165 both 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-96 pins 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 ## Unreleased in 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 ## Unreleased requirement 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 VERSION is bare — true on the ceremony PR and on main only until the -dev bump 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.

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: - box 0.7.0 `07c0edd`: `-## Unreleased` / `+## 0.7.0 — 2026-07-19` - rig 0.1.0 `a702f1f`: `-## Unreleased` / `+## 0.1.0 — 2026-07-19` - cast 0.1.0 `008161a`: `-## Unreleased` / `+## 0.1.0 — 2026-07-19` The workflows only ever touch VERSION/package.json (box `release.yml:164-186`, rig `:216-238`, cast `:224-250`). The `-dev` re-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.yml` looking for the bug. ## Confirmed on rig, and reproduced exactly The calibration case is real: - `b8e8e79` (authored 17:29:20Z, #60) wrote its `#58` entry under `## Unreleased` on its branch - release merge `6ff90f0` at 18:03:35Z stamped that heading to `## 0.1.0 — 2026-07-19` - #60 merged 19:02:12Z (`67386b4`) — clean, no conflict. At that commit the `#58` entry sits under `## 0.1.0 — 2026-07-19` Repaired ~2min later by `0ff520c`. Current `origin/main` is 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.0` **without 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 `## Unreleased` itself. **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-19` at line 8, no `## Unreleased` above 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 `## Unreleased` lands 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 corrupt `CHANGELOG.md` on 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-107` and cast `test/release.test.ts:157-165` both 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-96` pins 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 `## Unreleased` in 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 `## Unreleased` requirement 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 `VERSION` is bare — true on the ceremony PR and on main only until the `-dev` bump 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.
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/rig#66
No description provided.