diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh new file mode 100755 index 0000000..904efdf --- /dev/null +++ b/.github/scripts/changelog-monotonic.sh @@ -0,0 +1,200 @@ +#!/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_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 <&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 <&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..e33cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..acb0f46 100644 --- a/test/release.sh +++ b/test/release.sh @@ -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 — 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 #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