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>
219 lines
9.8 KiB
Bash
Executable file
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"
|