From ce57070c143f9386b1da142462ead964dd723efb Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 10:01:12 +0000 Subject: [PATCH 1/2] fix: refuse a PR that deletes a shipped changelog heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/scripts/changelog-monotonic.sh | 148 +++++++++++++++++++++++++ .github/workflows/ci.yml | 25 +++++ CHANGELOG.md | 21 ++++ CONTRIBUTING.md | 16 +++ test/release.sh | 128 +++++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100755 .github/scripts/changelog-monotonic.sh diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh new file mode 100755 index 0000000..16f681b --- /dev/null +++ b/.github/scripts/changelog-monotonic.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-monotonic.sh [] [] — 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: '## ...' where 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 <&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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76217b1..998313d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee67ae2..34f1db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 227250f..9c5866e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/test/release.sh b/test/release.sh index 23cf5e6..c68b734 100644 --- a/test/release.sh +++ b/test/release.sh @@ -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 — 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 — 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 -- 2.45.2 From cb72c71c33106005b76aac7860fa763c50b56a97 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 14:08:56 +0000 Subject: [PATCH 2/2] fix: the guard also refuses a DUPLICATED release heading, not just a deleted one Review found the first cut incomplete, and the gap is the shape the #118 incident actually had. Containment catches a heading that VANISHED. It cannot catch one that was DUPLICATED: 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`. Multiset comparison does not close it either, for the same reason: base {0.8.0} minus head {0.8.0, 0.8.0} is still empty. The assert that does close it is uniqueness of version headings ON HEAD, kept ALONGSIDE containment rather than replacing it, since containment remains the right check for deletions. That matters because the duplicate is not hypothetical. It is what a bad rebase of #116 produced an hour before this commit: two `## 0.8.0 - 2026-07-19` headings with the incoming entry stranded between them. Every other guard was green on that tree -- no conflict markers, changelog-armed.sh happy because the top section was still right for the VERSION, tests and shellcheck clean -- while release-notes.sh re-armed its grab on the second heading and folded post-cut prose into the shipped 0.8.0 body, and the stranded entry would have been dropped from the next release's notes as well. Uniqueness fires on nothing legitimate: the ceremony stamps a NEW version, and 'Unreleased' fails the version shape before it reaches here. Both trees are now pinned in test/release.sh side by side -- 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 separately. The duplicate case also pins that the containment arm stays silent on it, so a future simplification cannot collapse the two asserts into one. Proven non-vacuous: stubbing the uniqueness check to empty turns the suite red on exactly the three new assertions (117/3); restoring it returns 120/0. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-monotonic.sh | 56 +++++++++++++++++++++++++- CHANGELOG.md | 12 ++++++ test/release.sh | 21 ++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh index 16f681b..904efdf 100755 --- a/.github/scripts/changelog-monotonic.sh +++ b/.github/scripts/changelog-monotonic.sh @@ -83,11 +83,12 @@ merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)" # 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() { +headings_raw() { awk ' /^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 } - ' | sort -u + ' } +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. @@ -97,6 +98,57 @@ base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)" 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 <&2 + exit 1 +fi + base_headings="$(printf '%s\n' "$base_file" | headings)" head_headings="$(headings < "$changelog")" diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f1db0..e33cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,18 @@ which records not just what changed but what each drill run proved. `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 ### Added diff --git a/test/release.sh b/test/release.sh index c68b734..acb0f46 100644 --- a/test/release.sh +++ b/test/release.sh @@ -335,6 +335,27 @@ check "monotonic: ...and why nothing else says so (git merges it cleanly)" 1 "gi 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 -- 2.45.2