Merge pull request #126 from dan-claude-bot/fix/changelog-heading-monotonicity

fix: refuse a PR that deletes a shipped changelog heading
This commit is contained in:
Daniel Marin 2026-07-20 15:15:56 +01:00 committed by GitHub
commit add1c2a142
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 423 additions and 0 deletions

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

@ -0,0 +1,200 @@
#!/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_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 #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.8.0} minus head
# {0.8.0, 0.8.0} 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.
#
# This is the shape #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 — markers absent, changelog-armed.sh happy (the top
# section was still right), tests and shellcheck clean — while
# release-notes.sh re-armed its grab on the second heading and folded post-cut
# prose into the shipped release body.
#
# 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 #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 (#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,39 @@ 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.
Review of this PR found the guard's first cut incomplete, and the gap is the
shape the incident *actually* had. Containment catches a **deleted** heading;
it cannot catch a **duplicated** one, because 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`, and multiset comparison does not close it for
the same reason. So the guard now also asserts that version headings are
**unique on HEAD**, alongside containment rather than instead of it. Nothing
legitimate repeats one: the ceremony stamps a new version, and `Unreleased`
fails the version shape. Both trees are pinned in `test/release.sh` — the
deletion near-miss and the real duplicate — each with `changelog-armed.sh`
asserted green on it, which is the whole reason this script exists.
## 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,155 @@ 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 #118 incident as it ACTUALLY happened: a DUPLICATED heading -------
# The deletion case above is the near-miss. What the bad rebase really produced
# was two '## 0.8.0 — 2026-07-19' headings with the incoming entry stranded
# between them. Containment cannot see this: 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`, and multiset comparison does not close it for
# the same reason. Uniqueness on HEAD is the assert that does.
G="$(grepo mono-dup '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
head_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**'
check "monotonic: a DUPLICATED release heading fails (the #118 shape)" 1 "DUPLICATE release heading" mono "$G" main
check "monotonic: ...and names the repeated heading" 1 "## 0.8.0" mono "$G" main
check "monotonic: ...and says what a repeat does to release-notes extraction" 1 "re-arms its extraction" mono "$G" main
# Containment alone is green on this exact tree — nothing was deleted. Pinned
# so a future simplification cannot collapse the two asserts into one.
# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours
check "monotonic: ...on a tree where NOTHING was deleted (containment is blind)" 1 "" \
bash -c 'cd "$1" && bash "$2" main 2>&1 | grep -q "DELETES release heading"' _ "$G" "$MONO"
# And, as with the deletion case, the other guard calls this tree fine.
check "monotonic: ...on a tree changelog-armed.sh calls FINE" 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