Resolve the release heading from the top of the guide, not anywhere in it

Merged
JE
2mo ago
+514-25

Repairs the release-guard regression #808 shipped, flagged by Copilot's final-head review (suppressed block) 26 minutes before the merge and not absorbed — the review discipline failed, not the reviewer. All three suppressed findings taken:

  1. The historical-heading bypass (the dangerous one). The tagged promote-migrating.sh matched # v$VERSION anywhere in MIGRATING.md, so after v0.15.0's promotion, make bump VERSION=0.14.0 found the historical v0.14.0 heading, reported "already promoted", and rewrote every version file backward; make release used the same global grep and would have pushed the rollback to main before failing on the existing tag. The script now resolves the current release heading as the first release heading in the file, with a closed verdict set: promote / idempotent no-op / no-notes forward release (succeed, untouched) / backward release (refuse) / rollback-duplicate (refuse). make release calls --check mode instead of the two global greps.

  2. The no-notes release aborted mid-bump. A release with nothing migration-worthy legitimately has neither heading (the guide's one-section-per-release-that-breaks-something convention); the old script errored on it — after the version files were already rewritten. The no-notes forward case now succeeds explicitly, and the promotion runs as bump step 0, before any file is touched, so every refusal aborts with a clean tree.

  3. The tagged trailer pointed at a removed heading. "Every change the # Unreleased section describes…" — promotion renames that heading, so the v0.15.0 guide referenced a section that no longer existed. Now release-neutral ("the newest release section above").

Proofs. scripts/test-promote-migrating.sh: 15 assertions covering every verdict, including a fence-quoted-heading case; wired into check-targets and its own CI step (check-targets membership alone is not CI coverage in this repo). Mutation proof: the self-test run against the tagged script fails 5 assertions — the backward-bump regression first among them — then passes 15/15 restored. make release VERSION=0.15.0 on this branch prints the idempotent verdict and proceeds to the expected dirty-tree stop, proving the --check wiring executes.


Summary by cubic

Resolves the release heading from the top of MIGRATING.md and blocks rollbacks by using the remote tag list as the only ordering authority. Old behavior matched any historical “# vX.Y.Z”; new behavior treats the first release heading as current and requires the target to be strictly newer than the newest shipped tag.

  • Heading checks and rewrites read prose only with a CommonMark-correct fence parser, so fenced examples never count.
  • Refuses multiple or misplaced “# Unreleased”, rejects any abandoned unshipped section, and re‑promotes pending headings instead of orphaning notes.
  • --check refuses already-tagged targets, and all paths fail closed when the remote cannot be reached.
  • Promotion runs before version bumps; a .PHONY self-test is wired into check-targets; CI runs make test-promote-migrating.
  • The self-test pins shipped versions via a --released argument; environment overrides are ignored to prevent inheriting stale lists.
  • The self-test exercises live file:// remotes (remote-present, local-only, unreachable), large-guide SIGPIPE nets, and exotic fences.
  • MIGRATING.md’s trailer names the v0.15.0 section directly.