Skip to content

ci: close two CHANGELOG holes in version-guard (heading deletion, Unreleased drain) - #124

Merged
sweetrb merged 1 commit into
mainfrom
harden-version-guard
Aug 6, 2026
Merged

ci: close two CHANGELOG holes in version-guard (heading deletion, Unreleased drain)#124
sweetrb merged 1 commit into
mainfrom
harden-version-guard

Conversation

@sweetrb

@sweetrb sweetrb commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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-guard already 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-03 to ## [1.1.13] - 2026-08-04 leaves 1.1.13 documented and 1.1.12 gone. Not hypothetical — apple-numbers-mcp #54 did exactly that, and since nothing downstream reads CHANGELOG.md it 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-unguarded was 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 not git show "${HEAD_SHA}:…" — under pull_request the checkout is the merge commit, so a stale branch already contains main's newer headings, and reading HEAD_SHA would false-fail every PR left open across a release. Under workflow_dispatch the tree is HEAD and BASE is the origin/main fork 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 and newv already read the working tree.

Membership is tested with a while read + grep -qxF loop rather than comm, copying conformance-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.yml hard-exits without it), then that the section body is whitespace-only, and prints the offending content indented so the fix is obvious. Both use index($0,"## [Unreleased]")==1conformance-check.sh's own idiom — avoiding awk \[ escape portability questions; the terminator index($0,"## ")==1 correctly ignores ### Fixed sub-headings.

Edge cases, explicit

  • Shallow checkout: deepen with --unshallow when --is-shallow-repository is true, then hard-fail if the base commit is still unreachable rather than skipping. Skipping would be the same false-pass class conformance-check.sh's preflights exist to prevent.
  • First commit / CHANGELOG.md absent at base: git cat-file -e "${BASE_SHA}:CHANGELOG.md" fails → ::notice:: and continue. Distinguished from an unreachable base commit, which fails.
  • CHANGELOG.md deleted by the PR: explicit hard fail — it is the maximal heading deletion, and would otherwise crash sed under set -e.
  • set -euo pipefail safety: every new pipeline is either an if/|| 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, with npm stubbed so the registry check is hermetic.

# Scenario Expected Result
1 Heading rename (numbers #54 replay) on this repo's real 63-heading CHANGELOG FAIL FAIL, error names 2.6.16
2a Docs-only, no bump PASS PASS
2b Real release PR: bump + new heading + empty Unreleased PASS PASS
3a Bump + non-empty ## [Unreleased] FAIL FAIL, prints the stranded content
3b Same non-empty Unreleased, no bump PASS PASS — rule correctly scoped to bumps
3c ## [Unreleased] marker deleted + bump FAIL FAIL, cites dependabot-rebuild
3d ### Fixed content directly under Unreleased + bump FAIL FAIL — sub-heading is content, not a terminator
3e CHANGELOG.md deleted entirely FAIL FAIL
4 Dependabot: dependabot-rebuild.yml's exact auto-bump node snippet run verbatim, then guarded PASS PASS — bot PRs unaffected
4b workflow_dispatch fallback (empty BASE_SHA) PASS PASS
5a No CHANGELOG.md at base (first-commit case) PASS + notice PASS
5b Date-only heading edit, version preserved PASS PASS
5c Real --depth 1 shallow clone, base unreachable FAIL FAIL (fails closed at the pre-existing base diff)
6 Shallow --depth 2, base reachable PASS PASS — --unshallow path safe under set -euo pipefail

Also verified:

  • Pure insertion1 file changed, 70 insertions(+), 0 lines removed or altered.
  • YAML parses (PyYAML); name: version-guard, job key require-version-bump, and both triggers (pull_request, workflow_dispatch) unchanged — so the required check contexts are untouched.
  • bash -n clean on the extracted run: block.
  • prettier --check clean on the workflow.
  • pnpm lint / typecheck / format:check / test (519 tests, 20 files) all green.

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.

Second-order effect worth knowing: if ## [Unreleased] on main ever 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] on main today.

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.yml is in conformance-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.sh will report DRIFT on version-guard.yml until all four land — transient and expected during the rollout.

…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.
@sweetrb
sweetrb merged commit d076e4b into main Aug 6, 2026
8 checks passed
@sweetrb
sweetrb deleted the harden-version-guard branch August 6, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant