fix: refuse a PR that deletes a shipped changelog heading

changelog-armed.sh asks only whether the TOP section agrees with VERSION.
A PR that replaces '## X.Y.Z — DATE' with its own '## Unreleased' block —
git merges the edit cleanly, and a shipped section is silently absorbed into
Unreleased. The damage only surfaces at the next release, when
release-notes.sh cannot find the section it extracts by heading.

Add changelog-monotonic.sh: the set of '^## X.Y.Z' headings on a branch must
be a superset of the set at the merge base. Release headings are append-only,
so the rule has no legitimate violation — and the ceremony's stamp passes by
construction, adding X.Y.Z and removing none.

Its own script, not a clause in changelog-armed.sh: the input is a git
history rather than two files, no base ref is a SKIP rather than a failure,
and changelog-armed.sh is driven by test/release.sh against constructed
non-git trees that cannot express the failure at all.

CI checks out with fetch-depth: 0 and sets CHANGELOG_MONOTONIC_STRICT=1, so
an unreachable base ref goes red there instead of degrading to the skip a
local run is allowed.

Closes #122

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-20 10:01:12 +00:00
parent cc0a1a9a79
commit ce57070c14
5 changed files with 338 additions and 0 deletions

148
.github/scripts/changelog-monotonic.sh vendored Executable file
View file

@ -0,0 +1,148 @@
#!/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.
#
# The failure it exists to catch (#122, caught in review of #118) leaves no
# trace either. An author adding an entry under '## Unreleased' REPLACES the
# line below it instead of inserting above it:
#
# -## 0.8.0 — 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.8.0 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.8.0's prose as if it were new.
#
# changelog-armed.sh is green on exactly that tree, correctly: it asks only
# whether the TOP section agrees with VERSION, and deleting '## 0.8.0' 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 (#96) adds one and never removes one; nothing else in the documented
# flow (CONTRIBUTING.md, "Releases") 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 — changelog-armed.sh owns that
# heading, keyed on VERSION, and the ceremony legitimately consumes it.
#
# A file of its own, NOT a clause inside changelog-armed.sh, 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
# changelog-armed.sh is driven by test/release.sh against constructed
# two-file trees that are not git repos at all — folding a git-dependent
# assert into it would make every one of those cases either skip or lie.
# Same discipline as release-notes.sh: its own file so a test 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 changelog-armed.sh 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() {
awk '
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
' | 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
}
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 (#96); nothing ever
legitimately removes one. So this is not a judgement call — it is a defect,
and almost always the same one (#122, caught in review of #118): 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 changelog-armed.sh 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"

View file

@ -8,6 +8,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# fetch-depth: 0, for the changelog-monotonic step below and only
# for it. That check is about a DIFF — which release headings the
# merge base had — so it needs the base branch's history present,
# and the default depth-1 checkout has none of it. An explicit
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# pure-bash tree costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0
- name: shellcheck
# -x follows `source`/`.` directives; box has no lib split today, but the
# flag costs nothing and keeps the invocation identical to rig's.
@ -56,6 +68,19 @@ jobs:
# says which check found the drift without anyone reading a suite.
- name: changelog is armed for the next entry
run: bash .github/scripts/changelog-armed.sh
# ...and no SHIPPED release heading was deleted (#122). Its own step for
# the same reason as the one above — when it goes red the log names the
# invariant that broke — but a DIFFERENT invariant: armed is a fact
# about this tree, monotonicity is a fact about this tree versus its
# merge base. Pull requests only: on a push to main the merge base IS
# HEAD, so the assert is vacuous and would only add a green step that
# proves nothing. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
- name: no shipped changelog heading was deleted
if: github.event_name == 'pull_request'
env:
CHANGELOG_MONOTONIC_STRICT: '1'
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}"
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
# disposable VM, which is exactly the substrate the rehearsal needs. It runs

View file

@ -81,6 +81,27 @@ which records not just what changed but what each drill run proved.
a shopt subtlety hides one. `eof_guard_sweep` itself carried the identical
blind spot — it rebuilds the same glob — and is widened the same way.
- **A PR can no longer delete a shipped changelog section and stay green**
(#122) — caught in review of #118, where an entry added under
`## Unreleased` *replaced* the line `## 0.8.0 — 2026-07-19` instead of
being inserted above it. The whole shipped 0.8.0 record was absorbed into
`## Unreleased`, git merged it cleanly — a one-line edit, no conflict, no
signal — and `changelog-armed.sh` was green on that exact tree, correctly:
it asks only whether the TOP section agrees with `VERSION`, and
`## Unreleased` was still on top. The damage would have surfaced at the
next release, when `release-notes.sh` could no longer find the section it
extracts by heading, or worse, republished the absorbed prose as new.
`.github/scripts/changelog-monotonic.sh` asserts the complementary
invariant on every PR: release headings are **append-only**, so the set of
`## X.Y.Z` headings on a branch must be a **superset** of the set at its
merge base. A separate script rather than a clause in `changelog-armed.sh`
because "a heading disappeared" is a property of a DIFF, not of a tree —
and because `changelog-armed.sh` is driven against constructed non-git
fixtures that could not express it. The ceremony's stamp passes by
construction (it adds `X.Y.Z`, removes none), and no base ref to compare
against is a loud SKIP locally but a hard failure in CI, which sets
`CHANGELOG_MONOTONIC_STRICT=1` and checks out with `fetch-depth: 0` so the
guard can never quietly stop guarding.
## 0.8.0 — 2026-07-19

View file

@ -91,6 +91,22 @@ A release is a PR, and merging it ships it
the release itself makes. Do it in the ceremony PR and main is never
disarmed at all.
**Release headings are append-only.** When you add your entry under
`## Unreleased`, *insert above* the heading below it — never type over
that line. Replacing `## 0.8.0 — 2026-07-19` with your own `## Unreleased`
block deletes a shipped section: its prose is absorbed into `Unreleased`,
`release-notes.sh` can no longer find the version it extracts by heading,
and the next release republishes the absorbed prose as if it were new. git
merges that edit cleanly and `changelog-armed.sh` stays green on it — the
top section is still the right one — so
[.github/scripts/changelog-monotonic.sh](.github/scripts/changelog-monotonic.sh)
asserts the other half on every PR: the set of `## X.Y.Z` headings on your
branch must be a **superset** of the set at the merge base
([#122](https://github.com/heavy-duty/box/issues/122), caught in review of
[#118](https://github.com/heavy-duty/box/pull/118)). The ceremony's own
stamp passes it by construction — rewriting `## Unreleased` into
`## X.Y.Z — DATE` adds a heading and removes none.
This PR is where the release ritual hangs:
the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every

View file

@ -278,6 +278,134 @@ check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \
check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \
grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md"
# ---------------------------------------------------------------------------
# changelog-monotonic.sh (#122) — no SHIPPED release heading may be DELETED.
#
# The complement of the guard above, and the reason it is a separate script:
# changelog-armed.sh asks about ONE tree ("does the top section agree with
# VERSION?"), which is exactly why it was green on #118's broken branch — the
# top section was still '## Unreleased'. "A heading disappeared" is not a
# property of a tree at all; it is a property of a DIFF. So these cases are
# driven against real, constructed GIT REPOS with a base commit and a branch
# commit, not the two-file trees above — a fixture without history cannot
# express the failure being guarded.
#
# The #118 shape is reconstructed verbatim as the first case: the line
# '## 0.8.0 — 2026-07-19' replaced by an entry written under '## Unreleased'.
# The ceremony's own stamp is driven right beside it, because a rule that
# fires on the stamp is unshippable for the same reason rig#44 and cast#108
# were — that pair, not the failing case alone, is what makes this a design.
# ---------------------------------------------------------------------------
MONO="$ROOT/.github/scripts/changelog-monotonic.sh"
check "changelog-monotonic: runnable bash" 0 "" bash -n "$MONO"
# grepo <name> <base-changelog-lines...> — a git repo whose `main` carries the
# given changelog, left checked out on a branch `pr` off it. Prints the dir.
grepo() {
local d="$WORK/$1"; shift
mkdir -p "$d"
git -C "$d" init -q -b main
git -C "$d" config user.email test@example.invalid
git -C "$d" config user.name test
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
git -C "$d" add CHANGELOG.md
git -C "$d" commit -qm base
git -C "$d" checkout -q -b pr
echo "$d"
}
# head_changelog <dir> <lines...> — the PR branch's version of the file
head_changelog() {
local d="$1"; shift
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
git -C "$d" commit -qam head
}
mono() { local d="$1"; shift; ( cd "$d" && bash "$MONO" "$@" ); }
mono_strict() { local d="$1"; shift; ( cd "$d" && CHANGELOG_MONOTONIC_STRICT=1 bash "$MONO" "$@" ); }
# --- the #118 incident, reconstructed --------------------------------------
G="$(grepo mono-118 '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '### Added' '' '- **Shipped prose**'
check "monotonic: a DELETED release heading fails (the #118 near-miss)" 1 "DELETES release heading" mono "$G" main
check "monotonic: ...and names the heading that vanished" 1 "## 0.8.0" mono "$G" main
check "monotonic: ...and the shape of the mistake (replaced, not inserted)" 1 "instead of being" mono "$G" main
check "monotonic: ...and why nothing else says so (git merges it cleanly)" 1 "git merges that edit cleanly" mono "$G" main
# The whole point of the issue: the OTHER guard is green on this same tree.
# Pinned here so a future 'just widen changelog-armed.sh' cannot quietly
# delete the reason this script exists.
check "monotonic: ...on a tree changelog-armed.sh calls FINE (the #122 gap)" 0 "agrees" \
bash "$ARMED" "$G/CHANGELOG.md" "$ROOT/VERSION"
# --- the release ceremony's stamp: an ADD, never a removal -----------------
# '## Unreleased' -> '## 0.8.1 — DATE' adds 0.8.1 and removes no X.Y.Z
# heading, because 'Unreleased' is not one. A false positive here would make
# every release unshippable — the rig#44 / cast#108 failure, one guard over.
G="$(grepo mono-stamp '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
head_changelog "$G" '## Unreleased' '' '## 0.8.1 — 2026-07-20' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
check "monotonic: the ceremony stamp passes (adds a heading, removes none)" 0 "still present" mono "$G" main
# --- the ordinary entry, done right ----------------------------------------
G="$(grepo mono-ok '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
check "monotonic: an entry INSERTED above the top section passes" 0 "still present" mono "$G" main
check "monotonic: ...and counts what it actually checked" 0 "all 1 release heading" mono "$G" main
# --- deletion is caught anywhere in the file, not just at the top ----------
G="$(grepo mono-mid '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.7.0 — 2026-07-18' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**')"
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**'
check "monotonic: a heading deleted MID-FILE is caught too" 1 "## 0.7.0" mono "$G" main
# ...and only the deleted one is named, so the message points at the edit.
notes_only_070() { ! mono "$1" main 2>&1 | grep -qE '^ ## 0\.(6|8)\.0$'; }
check "monotonic: ...naming only the heading that went missing" 0 "" notes_only_070 "$G"
# --- a rewritten Unreleased is NOT a violation (changelog-armed owns it) ---
G="$(grepo mono-unrel '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
check "monotonic: a deleted '## Unreleased' is NOT this guard's business" 0 "still present" mono "$G" main
# --- a changelog that did not exist at the base ----------------------------
G="$(grepo mono-new 'No sections yet.')"
head_changelog "$G" '## 0.1.0 — 2026-07-20' '' '- **First**'
check "monotonic: a base with no release headings passes (nothing to delete)" 0 "all 0 release heading" mono "$G" main
# --- degradation: no base to compare against -------------------------------
# A local run may genuinely have no base ref. That must SKIP loudly, not fail
# spuriously (which would make the script un-runnable off CI) and not pass
# silently (which is the failure shape this repo keeps refusing). CI closes
# the hole from the other side with STRICT.
G="$(grepo mono-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
check "monotonic: an unresolvable base ref SKIPS, saying nothing was checked" 0 "SKIPPED" mono "$G" no-such-ref
check "monotonic: ...naming the base ref it could not resolve" 0 "no-such-ref" mono "$G" no-such-ref
check "monotonic: ...and warning that CI treats it as a failure" 0 "hard failure" mono "$G" no-such-ref
check "monotonic: STRICT turns that skip into a red run" 1 "is a FAILURE, not a skip" mono_strict "$G" no-such-ref
check "monotonic: ...and points at the checkout, not the script" 1 "fetch-depth: 0" mono_strict "$G" no-such-ref
# Outside a work tree at all (a tarball, an unpacked release).
mkdir -p "$WORK/mono-nogit"
printf '%s\n' '# Changelog' '' '## 0.8.0 — 2026-07-19' > "$WORK/mono-nogit/CHANGELOG.md"
check "monotonic: outside a git work tree it skips rather than erroring" 0 "SKIPPED" \
mono "$WORK/mono-nogit" main
check "monotonic: a missing changelog refuses by path (never a skip)" 1 "no such file" \
bash "$MONO" main "$WORK/nope.md"
# --- and the real tree, through the real script ----------------------------
# HEAD as its own base: the merge base is HEAD, so the sets are identical by
# construction. Proves the script runs against the actual CHANGELOG.md and
# parses its real headings, without depending on an `origin/main` that a
# fresh clone or a detached CI checkout may not have.
check "monotonic: THIS tree passes against itself (the parser meets reality)" 0 "still present" \
mono "$ROOT" HEAD
# The guard is only a guard if CI runs it — and only if CI runs it with the
# history it needs. Fail-closed pins on all three, since a depth-1 checkout
# would silently downgrade every run to the SKIP path.
check "ci.yml: runs the changelog-monotonic guard" 0 "" \
grep -qF 'changelog-monotonic.sh' "$ROOT/.github/workflows/ci.yml"
check "ci.yml: ...with full history, or the merge base is unreachable" 0 "" \
grep -qF 'fetch-depth: 0' "$ROOT/.github/workflows/ci.yml"
check "ci.yml: ...and STRICT, so a skip is a red run and not a green one" 0 "" \
grep -qF 'CHANGELOG_MONOTONIC_STRICT' "$ROOT/.github/workflows/ci.yml"
check "CONTRIBUTING: names the append-only rule for release headings" 0 "" \
grep -qF 'changelog-monotonic.sh' "$ROOT/CONTRIBUTING.md"
# ---------------------------------------------------------------------------
# latest_release_tag — extracted from install.sh (the source-the-pure-function
# trick) and driven against a shim curl. The shim serves the ONE seam the