ci: close two CHANGELOG holes in version-guard (heading deletion, Unreleased drain) - #124
Merged
Conversation
…eleased drain) version-guard already proves a bumped version HAS a "## [X.Y.Z]" heading. Two ways to satisfy that while still losing the record stayed open: 1. Heading deletion. A PR that RENAMES an existing heading rather than adding one passes the current rule while erasing a published release — retitling "## [1.1.12]" to "## [1.1.13]" leaves 1.1.13 documented and 1.1.12 gone. apple-numbers-mcp #54 did exactly this; nothing downstream reads CHANGELOG.md, so it stayed invisible until an audit caught the one missing heading across every published version in the four repos. Now every "## [X.Y.Z]" heading present at the base must still be present. This check sits OUTSIDE the bump branch, because a rename can land in a PR that bumps or one that does not. 2. Unreleased drain. Nothing guarded notes left under "## [Unreleased]" on a bumping PR. The release publishes everything on main, so those notes ship while sitting under a heading claiming they are unreleased, and nothing renames the section later. Now a bump requires the section to be empty — and requires the marker itself to still exist, since dependabot-rebuild.yml hard-exits without it. Design notes: - The head side reads the CHECKED-OUT TREE, not "${HEAD_SHA}:CHANGELOG.md". Under pull_request the checkout is the merge result, so a branch left open across a release already contains main's newer headings; reading HEAD_SHA would false-fail every stale PR. Under workflow_dispatch the tree IS HEAD and BASE is the origin/main fork point. This also matches the existing style — the current CHANGELOG check and `newv` already read the working tree. - Membership uses a `while read` + `grep -qxF` loop rather than `comm`: no sort-order or locale assumptions, and version strings are full of regex metacharacters (conformance-check.sh's documented reasoning). - Both Unreleased checks use `index($0,"## [Unreleased]")==1` — conformance- check.sh's own idiom — avoiding awk `\[` escape portability questions. The terminator `index($0,"## ")==1` correctly ignores "### Fixed" sub-headings. - Shallow checkout: deepen via --unshallow, then HARD-FAIL if the base commit is still unreachable rather than skipping, so a future edit to fetch-depth cannot silently turn this into a no-op. - CHANGELOG.md deleted by the PR is an explicit hard fail (it is the maximal heading deletion, and would otherwise crash sed under `set -e`). Accepted trade-off: archiving old entries OUT of CHANGELOG.md now fails. That is intentional and documented inline — the file is the only record of what each published version contains, and the guard cannot distinguish deliberate archival from the rename that erased 1.1.12. Scope: this change is a PURE INSERTION of the two blocks — 70 lines added, 0 removed or altered. The file's header comment (lines 19-21) was deliberately NOT touched. Rewriting it was offered as an optional companion edit, but version-guard.yml is in conformance-check.sh's byte-identical set and no exact wording was specified, so four independently-authored rewrites would drift and break that invariant. The inserted blocks carry their own inline rationale. Workflow `name: version-guard`, job key `require-version-bump` and both triggers are untouched, so the required check contexts are unchanged. .github/ does not ship, so no version bump is owed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CI-only change to
.github/workflows/version-guard.yml..github/does not ship, so no version bump is owed and none is included.The two holes
version-guardalready proves a bumped version has a## [X.Y.Z]heading. Two ways to satisfy that while still losing the record stayed open.Hole 1 — heading deletion. The existing rule only proves the new version has a heading. A PR that renames an existing heading instead of adding one satisfies it while erasing a published release: retitling
## [1.1.12] - 2026-08-03to## [1.1.13] - 2026-08-04leaves 1.1.13 documented and 1.1.12 gone. Not hypothetical — apple-numbers-mcp #54 did exactly that, and since nothing downstream readsCHANGELOG.mdit stayed invisible until an audit found the one missing heading across every published version in the four repos.Hole 2 — the Unreleased drain. Nothing guarded this at all. A release publishes everything on
main, so notes left under## [Unreleased]ship as that version while sitting under a heading claiming they are unreleased — and nothing later renames the section. (unreleased-drain-is-unguardedwas a known open gap.)Design
Hole 1 is closed outside the bump branch, because a rename can land in a PR that bumps (numbers #54 did) or one that does not. Base side is
git show "${BASE_SHA}:CHANGELOG.md"; head side is the checked-out working tree, deliberately notgit show "${HEAD_SHA}:…"— underpull_requestthe checkout is the merge commit, so a stale branch already contains main's newer headings, and readingHEAD_SHAwould false-fail every PR left open across a release. Underworkflow_dispatchthe tree is HEAD and BASE is theorigin/mainfork point (an ancestor), so base headings are a subset unless the branch deleted one. This also matches the file's existing style: the current CHANGELOG check andnewvalready read the working tree.Membership is tested with a
while read+grep -qxFloop rather thancomm, copyingconformance-check.sh's documented reasoning — no sort-order or locale assumptions, and a version string is full of regex metacharacters.Hole 2 is closed inside the bump branch, right after the existing heading check. It first asserts the marker exists (
dependabot-rebuild.ymlhard-exits without it), then that the section body is whitespace-only, and prints the offending content indented so the fix is obvious. Both useindex($0,"## [Unreleased]")==1—conformance-check.sh's own idiom — avoiding awk\[escape portability questions; the terminatorindex($0,"## ")==1correctly ignores### Fixedsub-headings.Edge cases, explicit
--unshallowwhen--is-shallow-repositoryis true, then hard-fail if the base commit is still unreachable rather than skipping. Skipping would be the same false-pass classconformance-check.sh's preflights exist to prevent.CHANGELOG.mdabsent at base:git cat-file -e "${BASE_SHA}:CHANGELOG.md"fails →::notice::and continue. Distinguished from an unreachable base commit, which fails.CHANGELOG.mddeleted by the PR: explicit hard fail — it is the maximal heading deletion, and would otherwise crashsedunderset -e.set -euo pipefailsafety: every new pipeline is either anif/||condition or ends in a command that cannot fail. No unguarded pipefail exits.Verification (run locally against this repo)
The
run:block was extracted from the parsed YAML and executed end-to-end in scratch git repos, withnpmstubbed so the registry check is hermetic.2.6.16Unreleased## [Unreleased]Unreleased, no bump## [Unreleased]marker deleted + bump### Fixedcontent directly underUnreleased+ bumpCHANGELOG.mddeleted entirelydependabot-rebuild.yml's exact auto-bump node snippet run verbatim, then guardedworkflow_dispatchfallback (emptyBASE_SHA)CHANGELOG.mdat base (first-commit case)--depth 1shallow clone, base unreachable--depth 2, base reachable--unshallowpath safe underset -euo pipefailAlso verified:
1 file changed, 70 insertions(+), 0 lines removed or altered.name: version-guard, job keyrequire-version-bump, and both triggers (pull_request,workflow_dispatch) unchanged — so the required check contexts are untouched.bash -nclean on the extractedrun:block.prettier --checkclean on the workflow.pnpm lint/typecheck/format:check/test(519 tests, 20 files) all green.Accepted trade-off
Archiving old entries out of
CHANGELOG.mdnow fails. That is intentional and documented inline — the file is the only record of what each published version contains, and the guard cannot distinguish deliberate archival from the rename that erased 1.1.12.Second-order effect worth knowing: if
## [Unreleased]onmainever became non-empty, every bumping PR (including Dependabot's) would fail until the notes are filed. That is the drain being enforced, it fails loudly rather than silently, and all four repos have an empty## [Unreleased]onmaintoday.Note on the optional header-comment edit
The plan offered an optional companion edit rewriting the file's header comment (lines 19-21) to describe all three CHANGELOG rules. It was deliberately not applied.
.github/workflows/version-guard.ymlis inconformance-check.sh's byte-identical set, and no exact wording was specified — four independently-authored rewrites would drift and break that invariant. The inserted blocks carry their own inline rationale. If the header should be updated, it needs one authored text applied identically to all four in a follow-up.Rollout
This is one of four identical PRs (mail / notes / numbers / photos).
conformance-check.shwill reportDRIFTonversion-guard.ymluntil all four land — transient and expected during the rollout.