Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/version-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,55 @@ jobs:
newv="$(node -e 'console.log(require("./package.json").version)')"
echo "package.json version: base=${oldv} head=${newv}"

# ── CHANGELOG history is append-only ──────────────────────────────
# The rule below 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.
# So: every "## [X.Y.Z]" heading present at the base must still be
# present here. Adding is free; renaming or deleting one fails.
# Compared against the CHECKED-OUT tree — the merge result under
# pull_request, HEAD under workflow_dispatch — so a branch that is
# merely stale (main released while the PR sat open) is never blamed
# for headings it has not merged yet.
if [ ! -f CHANGELOG.md ]; then
echo "::error::CHANGELOG.md is missing from this branch. It is the only record of what each published version contains — restore it."
exit 1
fi
# Reading the base copy needs real history. The checkout above pins
# fetch-depth: 0; deepen defensively, then refuse to run rather than
# pass unevaluated, so a future edit to that input cannot silently
# turn this check into a no-op.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --no-tags --quiet --unshallow || true
fi
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
echo "::error::Base commit ${BASE_SHA} is not present in this clone (shallow checkout?), so the CHANGELOG history check cannot be evaluated. Restore 'fetch-depth: 0' on the checkout step."
exit 1
fi
if git cat-file -e "${BASE_SHA}:CHANGELOG.md" 2>/dev/null; then
base_heads="$(git show "${BASE_SHA}:CHANGELOG.md" | sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p')"
head_heads="$(sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' CHANGELOG.md)"
# Loop + `grep -qxF` rather than `comm`: no sort-order or locale
# assumptions, and a version string is full of regex metacharacters.
lost=""
while IFS= read -r v; do
[ -n "$v" ] || continue
printf '%s\n' "$head_heads" | grep -qxF -- "$v" || lost="${lost}${v} "
done <<< "$base_heads"
if [ -n "$lost" ]; then
echo "::error::CHANGELOG.md no longer has a '## [X.Y.Z]' heading for release(s) documented on the base: ${lost% }. A published release's section must never be renamed, retitled or removed — add a NEW heading for this release and restore the one(s) above."
exit 1
fi
echo "CHANGELOG.md preserves every release heading present on the base."
else
echo "::notice::No CHANGELOG.md at the base commit — no release headings to preserve."
fi

if [ "$oldv" != "$newv" ]; then
# Bump present: require it to be an increase (typo'd downgrades)…
OLDV="$oldv" NEWV="$newv" node -e '
Expand Down Expand Up @@ -159,6 +208,27 @@ jobs:
exit 1
fi
echo "CHANGELOG.md documents ${newv}."

# …and require "## [Unreleased]" to be EMPTY. The heading check
# above proves the new version is documented somewhere; it says
# nothing about notes still parked under "## [Unreleased]", which
# this release drains: everything on main ships in the next publish,
# so prose left under that marker describes released behaviour while
# claiming to be unreleased, and nothing later renames the section.
# Until now nothing guarded that at all. dependabot-rebuild.yml
# inserts its "## [X.Y.Z]" heading directly BELOW the marker and
# leaves it empty, so bot PRs pass unchanged.
if [ -z "$(awk 'index($0,"## [Unreleased]")==1{print "y"; exit}' CHANGELOG.md)" ]; then
echo "::error::CHANGELOG.md has no '## [Unreleased]' heading. Keep an empty one at the top — dependabot-rebuild.yml hard-exits without that marker, so dropping it silently breaks the Dependabot rebuild + auto-bump path."
exit 1
fi
unrel="$(awk 'index($0,"## [Unreleased]")==1{u=1;next} u&&index($0,"## ")==1{exit} u{print}' CHANGELOG.md)"
if [ -n "$(printf '%s' "$unrel" | tr -d '[:space:]')" ]; then
echo "::error::Version is bumped to ${newv} but '## [Unreleased]' is not empty. This release publishes everything on main, so those notes ship as ${newv} while sitting under a heading that says they are unreleased — and nothing renames that section later. Move them under '## [${newv}] - $(date -u +%Y-%m-%d)' and leave '## [Unreleased]' empty. Content found:"
printf '%s\n' "$unrel" | sed 's/^/ /'
exit 1
fi
echo "'## [Unreleased]' is empty."
fi

if [ -z "$code" ] && [ -z "$deps_changed" ]; then
Expand Down
Loading