cast/.github/scripts/changelog-monotonic.sh
dan-claude-bot 72030511b9 fix: assert no shipped changelog heading is deleted or duplicated
Release headings are append-only: the ceremony (#111) adds one and nothing
in CONTRIBUTING's release flow ever removes one. Nothing asserted that.

The arming rule (test/release.test.ts, rig#66) is narrow by design — it asks
whether the TOP section agrees with package.json's version, about ONE
heading, the one a PR is about to write under. It says nothing about the rest
of the file, and cannot: "a heading disappeared" is not a property of a tree,
it is a property of a DIFF.

So an author adding an entry under '## Unreleased' who types OVER the heading
below it instead of inserting above it produces a tree every existing guard
calls green. git merges it cleanly — a one-line edit in a file nobody touched
concurrently, no conflict, no signal. The shipped section's body is now
sitting under '## Unreleased' and the version it belonged to has no section
at all. It surfaces at the NEXT release, when release-notes.sh cannot find
the section it extracts by heading, or worse republishes the absorbed prose.

Ports box's changelog-monotonic.sh (box#122, caught in review of box#118)
rather than reimplementing the invariant a third time in TypeScript, and
keeps both halves. Containment catches a DELETED heading; it cannot catch a
DUPLICATED one, because a duplicate is head-side surplus and base-minus-head
is blind to extras on the head side. Uniqueness on HEAD is asserted alongside
it, and that half matters more in cast than in box: release-notes.sh's awk
has no `exit`, so `grab` re-arms on every matching '## ' line and two copies
of a version heading make the published body ABSORB whatever sits between
them — with the stranded entry dropped from the next release's notes too.
(rig's extractor truncates instead; cast has the absorbing one.) The existing
"double re-arm" test covers duplicate '## Unreleased' only, not duplicate
VERSION headings, which are the ones that reach release-notes.sh.

Wired into ci.yml as its own step so a red run names the invariant that
broke; pull requests only, because on a push to main the merge base IS HEAD
and the assert is vacuous; STRICT=1 with fetch-depth: 0 so a checkout that
cannot reach the base ref fails loudly instead of skipping quietly forever.
'## Unreleased' stays outside the guarded set — the arming rule owns that
heading and the ceremony legitimately consumes it.

Closes #133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:00:12 +00:00

219 lines
9.8 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
# on HEAD must be a SUPERSET of the set at the merge base.
#
# Ported from box (heavy-duty/box#122, caught in review of box#118) for #133,
# because cast's release-notes.sh carries the exact awk shape that made box#118
# dangerous. The failure it exists to catch leaves no trace either. An author
# adding an entry under '## Unreleased' REPLACES the line below it instead of
# inserting above it:
#
# -## 0.1.1 — 2026-07-19
# +## Unreleased
# +
# +### Fixed
# +
# +- **An entry**
#
# git merges that cleanly — it is a one-line edit inside a file nobody has
# touched concurrently — and the shipped section's whole body is silently
# absorbed into '## Unreleased'. 0.1.1 no longer HAS a section; the notes
# anchor release-notes.sh extracts by is gone, and the next release cut from
# that state republishes 0.1.1's prose as if it were new.
#
# The ARMING rule (test/release.test.ts, "the changelog is armed for the next
# entry (rig#66)") is green on exactly that tree, correctly: it asks only
# whether the TOP section agrees with package.json's version, and deleting
# '## 0.1.1' leaves '## Unreleased' on top. It is not wrong, it is narrow — it
# guards ONE heading, the one a PR is about to write under. This guards the
# REST of the file, the part no single tree can be asked about at all, because
# "a heading disappeared" is not a property of a tree — it is a property of a
# DIFF.
#
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
# ceremony (#111) adds one and never removes one; nothing else in the
# documented flow (CONTRIBUTING.md, "Releasing") touches them. So SUPERSET is
# exact — it has no legitimate violation to carve an exception for. The stamp
# is covered for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS
# X.Y.Z and removes no X.Y.Z heading, because 'Unreleased' is not one.
# '## Unreleased' is deliberately NOT in the set this guards — the arming rule
# owns that heading, keyed on package.json's version, and the ceremony
# legitimately consumes it.
#
# A file of its own, NOT a clause inside the arming assertions, for three
# reasons. Its input is different (a git history, not two files). Its
# degradation is different (no base ref is a SKIP, not a failure). And the
# arming assertions run against constructed in-memory changelog strings that
# are not git repos at all — folding a git-dependent assert into them would
# make every one of those cases either skip or lie. Same discipline as
# release-notes.sh: its own file so test/release.test.ts can drive it.
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
changelog="${2:-CHANGELOG.md}"
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
# degradation becomes a red run there instead. A guard that can silently
# stop guarding is the failure shape this whole family of checks exists to
# refuse, so the skip path is loud and CI refuses to take it at all.
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
skip() {
if [ "$strict" = "1" ]; then
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
exit 1
fi
echo "changelog-monotonic: SKIPPED — $*"
echo " (Nothing was checked. In CI this same condition is a hard failure.)"
exit 0
}
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| skip "not inside a git work tree, so there is no history to compare against"
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
[ -n "$merge_base" ] \
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
# version. Field $2, the same split the arming rule and release-notes.sh use,
# so the three cannot disagree about what a section header is. 'Unreleased'
# fails the shape and is excluded by construction.
headings_raw() {
awk '
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
'
}
headings() { headings_raw | sort -u; }
# The changelog may not exist at the merge base at all (the commit that adds
# it). Nothing to have deleted, so nothing to assert.
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
[ -n "$base_file" ] || {
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted."
exit 0
}
# --- uniqueness on HEAD (the box#118 class) ----------------------------------
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
# extras on the head side — with or without `sort -u`, base {0.1.1} minus head
# {0.1.1, 0.1.1} is empty. Multiset comparison does not close it either, for
# the same reason. The assert that does is uniqueness of version headings ON
# HEAD, kept alongside containment rather than replacing it.
#
# cast is the MORE exposed of the two repos here (#133). release-notes.sh
# extracts with:
#
# /^## / { grab = ($2 == ver); next }
# grab { print }
#
# There is no `exit`. `grab` re-arms on every matching '## ' line, so two
# '## 0.1.1' headings make the published body ABSORB whatever sits between the
# copies — and an entry stranded there is dropped from the NEXT release's notes
# as well. (rig's extractor has `if (found) exit`, so it truncates instead of
# absorbing — same class, milder symptom. cast has the absorbing one.)
#
# This is the shape box#118's bad rebase actually produced: two
# '## 0.8.0 — 2026-07-19' headings with the incoming entry between them. Every
# other guard stayed green — the arming rule happy (the top section was still
# right), tests and `bash -n` clean — while release-notes.sh re-armed its grab
# on the second heading and folded post-cut prose into the shipped release
# body. Note the arming rule's "double re-arm" case counts duplicate
# '## Unreleased' headings only; duplicate VERSION headings, the ones that
# reach release-notes.sh, are this script's.
#
# Nothing legitimate repeats a version heading: the ceremony stamps a NEW
# version, and 'Unreleased' fails the version shape and never reaches here.
dupes="$(headings_raw < "$changelog" | sort | uniq -d)"
if [ -n "$dupes" ]; then
{
echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):"
echo
printf '%s\n' "$dupes" | sed 's/^/ ## /'
echo
cat <<EOF
Each version heading must appear exactly once. A repeat splits one release
into two same-named sections, and release-notes.sh re-arms its extraction on
every matching '## ' line — so the published body for that version absorbs
whatever sits between the copies, and an entry stranded there is dropped from
the NEXT release's notes as well.
This is the box#118 shape: an entry meant for '## Unreleased' was inserted
after a shipped heading, and the heading re-added below it. The fix is one
heading, with the entry above it under '## Unreleased':
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
Quick check on any changelog-touching rebase:
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
EOF
} >&2
exit 1
fi
base_headings="$(printf '%s\n' "$base_file" | headings)"
head_headings="$(headings < "$changelog")"
# comm -23: lines in the base set that are NOT in the head set — exactly the
# headings this branch removed.
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
if [ -n "$missing" ]; then
{
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
echo
printf '%s\n' "$missing" | sed 's/^/ ## /'
echo
cat <<EOF
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
Release headings are APPEND-ONLY. The ceremony adds one (#111); nothing ever
legitimately removes one. So this is not a judgement call — it is a defect,
and almost always the same one (#133, box#122): an entry written under
'## Unreleased' REPLACED the heading below it instead of being inserted
ABOVE it. The shipped section's body is now sitting under '## Unreleased',
and the version it belonged to has no section at all.
Nothing else will say so. git merges that edit cleanly — no conflict, no
signal — and the arming rule stays green, because the TOP section is still
the right one for this VERSION. The damage surfaces at the NEXT release,
when release-notes.sh cannot find the section it extracts by heading, or
worse, republishes the absorbed prose as if it were new.
The fix is to put the heading back and INSERT above it, never over it:
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
If you are genuinely renaming a released version, that is a rewrite of
history this guard is meant to stop; say so in the PR and change the guard
deliberately, in its own commit.
EOF
} >&2
exit 1
fi
count="$(printf '%s\n' "$base_headings" | grep -c . || true)"
echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog"