From 3e1b579e616bd95437827840fc29664c8eae7b6f Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 19:39:45 +0000 Subject: [PATCH] fix: the ceremony re-arms the changelog, and CI notices when it doesn't (#113) Repair main's missing `## Unreleased`, re-arm in the CONTRIBUTING ceremony step, and add a version-keyed guard in test/release.test.ts. Cross-refs heavy-duty/rig#66 (origin, confirmed occurrence) and heavy-duty/box#108 (box sibling). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 32 +++++++++++++++ CONTRIBUTING.md | 16 +++++++- test/release.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d504f9b..4128a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ since its first commit, but grew its release surface (this file, `cast --version`, tagged releases with a prebuilt asset) on the way to actually cutting it, and this file starts there. +## Unreleased + +### Fixed + +- **The release ceremony re-arms the changelog, and CI notices when it + doesn't** (#113) — stamping `## Unreleased` into `## X.Y.Z — DATE` is + done by hand in the ceremony PR; no workflow writes this file, and + nothing put the heading back. So `main` sat with the shipped section on + top and no `## Unreleased` above it — this repo's state from 0.1.0 + until this entry. A PR authored before a release and merged after has + its entry land under whatever heading now occupies that position: the + release that already shipped. Git does that *cleanly*. The stamped + heading and the incoming entry never overlap textually, so the one + signal an author trusts — "git told me to look" — is missing exactly + when the result is wrong. rig watched it happen (heavy-duty/rig#66, the + origin of this fix): an entry landed inside published `## 0.1.0` an + hour after 0.1.0 shipped, and was caught only because someone was + reading. The published release body is never at risk — `release.yml` + extracts notes from the tree at the tag, before anything late can merge + — which is also why nobody notices: the file that drifts is the one + only maintainers read. Three moves. `## Unreleased` is back above + `## 0.1.0` (this entry re-creating it *is* the repair). CONTRIBUTING's + ceremony step now re-arms in the same diff that stamps. And + `test/release.test.ts` keys the rule to `package.json`: a stamped top + section is legal while the version is bare — the ceremony's own tree, + and main until the `-dev` bump — but once the version says `-dev`, the + top section must be `## Unreleased`. That is the distinction #108 had + to collapse to make the ceremony shippable at all, recovered rather + than reverted: the ceremony stays green at every step, and a disarmed + dev `main` goes red. box and rig carry the same fix + (heavy-duty/box#108, heavy-duty/rig#66). + ## 0.1.0 — 2026-07-19 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7adf03..9e9170c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,17 @@ on box#83's shape): 1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s `version` (and `package-lock.json`; `npm install --package-lock-only` keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as - `## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR. + `## X.Y.Z — YYYY-MM-DD`. **Then re-arm: add a fresh, empty + `## Unreleased` immediately above the section you just stamped.** The + same PR, the same diff — stamping without re-arming leaves main with no + `## Unreleased`, and the next PR that was authored before the release + and merged after has its entry land *inside the shipped section*, which + git does cleanly, with no conflict to warn anyone + (heavy-duty/rig#66 — it happened there). `test/release.test.ts` keys + this to the version: a stamped top section is legal while + `package.json` is bare, but the moment step 3's `-dev` bump lands, the + top section must be `## Unreleased` or CI is red. CI green on it, same + loop as any PR. 2. **Merge. That's the ship decision — nothing else to do.** [release.yml](.github/workflows/release.yml) fires on the merged, `release`-labeled PR and asserts, in order, each fail-loud and creating @@ -78,6 +88,10 @@ on box#83's shape): `versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must not impersonate the release it merely descends from. On the *manual* tag path the bump stays yours: open the one-line PR after publishing. + This step re-arms the **version** only — the `## Unreleased` heading is + step 1's, in the ceremony PR's own diff, because no workflow ever writes + `CHANGELOG.md`. The two halves meet in `test/release.test.ts`: once this + bump makes the version `-dev`, a missing `## Unreleased` is CI-red. ## Labels — who sets what diff --git a/test/release.test.ts b/test/release.test.ts index 7adfb09..5b1b3df 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -164,6 +164,103 @@ describe("release-notes.sh", () => { }); }); +// --- the changelog is ARMED — the version says which state is legal -------- +// heavy-duty/rig#66. The section above proves the top section EXTRACTS; it +// deliberately does not care what the top section is CALLED, and cannot: +// #108 relaxed exactly that, because the ceremony PR's own tree has a +// stamped `## X.Y.Z` on top and a literal-Unreleased demand made the +// release unshippable by construction. So nothing on main notices when +// `## Unreleased` is simply gone. +// +// That gap is not theoretical. A PR that writes its entry under +// `## Unreleased`, is authored before a release and merged after, has that +// entry land under whatever heading now occupies the position — the +// just-shipped `## X.Y.Z`. Git merges it CLEANLY: the stamped heading and +// the incoming entry never overlap textually, so the one signal an author +// relies on ("git told me to look") is absent precisely when the outcome is +// wrong. It happened in rig: #60's entry landed inside published `## 0.1.0`. +// +// The rule that separates the two states #108 collapsed, without demanding +// Unreleased unconditionally: **the package.json version keys it.** A bare +// `X.Y.Z` means the tree IS (or immediately follows) a release — the +// ceremony's stamped top section is legal there, and so is a re-armed +// Unreleased. A `-dev` version means main between releases, where a stamped +// top section can only mean the re-arm was skipped: `## Unreleased` is +// mandatory. Green through the whole ceremony; red on a disarmed `-dev` +// main, which is the state the guard exists to name. + +/** The top `## ` section's token — `Unreleased`, or a stamped version. */ +function topSection(changelog: string): string { + const top = changelog.match(/^## (\S+)/m); + if (!top) throw new Error("changelog has no ## section at all"); + return top[1]; +} + +/** null = armed. A string = why this (version, changelog) pair is illegal. */ +function disarmedBecause(version: string, changelog: string): string | null { + const top = topSection(changelog); + if (version.endsWith("-dev")) { + return top === "Unreleased" + ? null + : `version ${version} is a dev tree, so the top section must be '## Unreleased' — found '## ${top}'. The release ceremony stamps Unreleased into the shipped version and must re-add an empty one (heavy-duty/rig#66); without it the next PR's entry lands inside ${top}'s published notes, with no merge conflict to warn anyone.`; + } + if (top === "Unreleased" || top === version) return null; + return `version ${version} is bare, so the top section must be '## Unreleased' (re-armed) or the matching '## ${version}' (the ceremony tree) — found '## ${top}'.`; +} + +describe("the changelog is armed for the next entry (rig#66)", () => { + const dated = (v: string) => `## ${v} — 2026-07-19`; + const body = "\n\n- **An entry** — prose.\n"; + const armed = `# Changelog\n\n## Unreleased${body}\n${dated("0.2.0")}${body}`; + const stamped = `# Changelog\n\n${dated("0.2.0")}${body}`; + + it("the REAL tree is armed — package.json and CHANGELOG.md agree", () => { + const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) + .version as string; + const changelog = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8"); + expect(disarmedBecause(version, changelog)).toBeNull(); + }); + + // The ceremony, walked end to end. Every state green — this is the #108 + // regression the guard must not re-introduce. + it("stays green through the ceremony: the release PR's own stamped tree", () => { + expect(disarmedBecause("0.2.0", stamped)).toBeNull(); + }); + + it("stays green through the ceremony: the ceremony PR that re-arms too", () => { + expect(disarmedBecause("0.2.0", armed)).toBeNull(); + }); + + it("stays green through the ceremony: main in the post-release window", () => { + // Merged, tagged, published — the -dev bump has not landed yet. + expect(disarmedBecause("0.2.0", stamped)).toBeNull(); + }); + + it("stays green through the ceremony: main after the -dev bump, re-armed", () => { + expect(disarmedBecause("0.2.1-dev", armed)).toBeNull(); + }); + + // And red on the one state the extraction guard cannot see. + it("goes RED on a disarmed -dev main — the rig#66 failure, exactly", () => { + const why = disarmedBecause("0.2.1-dev", stamped); + expect(why).toContain("must be '## Unreleased'"); + expect(why).toContain("rig#66"); + }); + + it("goes RED when a bare version's stamp names a different release", () => { + // A hand-stamp that drifted from the bump it shipped with. + expect( + disarmedBecause("0.2.0", `# Changelog\n\n${dated("0.1.9")}${body}`), + ).toContain("the ceremony tree"); + }); + + it("refuses a changelog with no sections at all rather than passing it", () => { + expect(() => disarmedBecause("0.2.1-dev", "# Changelog\n")).toThrow( + "no ## section", + ); + }); +}); + // --- release.yml — the wiring, pinned -------------------------------------- // The workflow itself only runs on a tag push upstream, so its load-bearing // pieces are pinned here, fail-closed (the house discipline: the labels