fix: the ceremony re-arms the changelog, and CI notices when it doesn't (#113) #114
3 changed files with 144 additions and 1 deletions
32
CHANGELOG.md
32
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
|
`cast --version`, tagged releases with a prebuilt asset) on the way to
|
||||||
actually cutting it, and this file starts there.
|
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
|
## 0.1.0 — 2026-07-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,17 @@ on box#83's shape):
|
||||||
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s
|
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s
|
||||||
`version` (and `package-lock.json`; `npm install --package-lock-only`
|
`version` (and `package-lock.json`; `npm install --package-lock-only`
|
||||||
keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as
|
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.**
|
2. **Merge. That's the ship decision — nothing else to do.**
|
||||||
[release.yml](.github/workflows/release.yml) fires on the merged,
|
[release.yml](.github/workflows/release.yml) fires on the merged,
|
||||||
`release`-labeled PR and asserts, in order, each fail-loud and creating
|
`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
|
`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*
|
not impersonate the release it merely descends from. On the *manual*
|
||||||
tag path the bump stays yours: open the one-line PR after publishing.
|
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
|
## Labels — who sets what
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 --------------------------------------
|
// --- release.yml — the wiring, pinned --------------------------------------
|
||||||
// The workflow itself only runs on a tag push upstream, so its load-bearing
|
// 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
|
// pieces are pinned here, fail-closed (the house discipline: the labels
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue