diff --git a/.github/scripts/changelog-armed.sh b/.github/scripts/changelog-armed.sh new file mode 100755 index 0000000..c2c37df --- /dev/null +++ b/.github/scripts/changelog-armed.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-armed.sh [] [] — assert that +# CHANGELOG.md is ARMED: that there is a heading for the next PR's entry to +# land under, and that it is the right one for the state this tree is in. +# +# The failure it exists to catch (#108, heavy-duty/rig#66) leaves no trace: +# the ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and +# nothing puts the heading back. A PR authored BEFORE the release wrote its +# entry under '## Unreleased'; that heading is gone by the time it merges, so +# git lands the entry under whatever heading now occupies that position — the +# just-shipped section — CLEANLY, with no conflict. The one signal an author +# would trust ("git told me to look") is absent exactly when the result is +# wrong, and the drift is only ever discovered by reading the file. +# +# The rule, keyed on VERSION, because the two states are genuinely different: +# +# VERSION ends in -dev -> the top section MUST be '## Unreleased' +# VERSION is bare -> the top section may be '## Unreleased' (armed, +# the ceremony's own re-arm) or the stamped +# section for exactly that VERSION +# +# Keying on VERSION is the whole design, and the reason this is not simply +# "require '## Unreleased'". That unconditional form is what rig#44 and +# heavy-duty/cast#108 had to REVERT: it is false by construction on the +# ceremony PR's own tree, which makes the release unshippable through a green +# CI. Anyone tempted to simplify this back should read those two first. +# +# The consequence worth stating plainly: a ceremony PR that stamps and forgets +# to re-arm still passes here — its VERSION is bare, and a bare tree is +# allowed to be stamped. It goes red the moment the '-dev' bump lands on main, +# which release.yml does automatically in the same job as the publish. So the +# guard does not block the release; it refuses to let main SIT disarmed, which +# is the window a late PR can fall into. +# +# A file of its own (not inlined in ci.yml) so test/release.sh can drive it +# against constructed trees for both states — the same discipline as +# release-notes.sh. + +changelog="${1:-CHANGELOG.md}" +version_file="${2:-VERSION}" + +[ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; } +[ -f "$version_file" ] || { echo "changelog-armed: no such file: $version_file" >&2; exit 1; } + +ver="$(tr -d '[:space:]' < "$version_file")" +[ -n "$ver" ] || { echo "changelog-armed: $version_file is empty" >&2; exit 1; } + +# The TOP section: the first '## ' heading in the file. Everything above it is +# the changelog's own preamble and belongs to no section. +top="$(grep -m1 '^## ' "$changelog" || true)" +[ -n "$top" ] || { + echo "changelog-armed: $changelog has no '## ' section at all — nothing for a PR entry to land under" >&2 + exit 1 +} + +# '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, same shape +# release-notes.sh matches on, so the two cannot disagree about what a +# section header is. +top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')" + +case "$ver" in + *-dev) + if [ "$top_ver" != "Unreleased" ]; then + cat >&2 <&2 < — a two-file tree to run against +tree() { + local d="$WORK/$1" v="$2"; shift 2 + mkdir -p "$d" + printf '%s\n' "$v" > "$d/VERSION" + { echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md" + echo "$d" +} +armed() { bash "$ARMED" "$1/CHANGELOG.md" "$1/VERSION"; } + +# --- the -dev steady state: armed is the only legal shape ------------------ +T="$(tree dev-armed 0.7.1-dev '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" +check "armed: a -dev tree with '## Unreleased' on top passes" 0 "agrees" armed "$T" +T="$(tree dev-disarmed 0.7.1-dev '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" +check "armed: a -dev tree WITHOUT it fails — the #108 drift, caught" 1 "MUST carry" armed "$T" +check "armed: ...and the failure says how to fix it (re-arm)" 1 "re-arm" armed "$T" +check "armed: ...naming the issue and its origin" 1 "heavy-duty/rig#66" armed "$T" + +# --- the ceremony PR: bare VERSION, BOTH arrangements legal ---------------- +# This is the pair that the reverted guards got wrong. Neither may fail, or +# the release PR cannot go green and the ceremony is unshippable. +T="$(tree rel-stamped 0.7.1 '## 0.7.1 — 2026-07-19' '' '- **This release**')" +check "armed: a bare VERSION with its OWN stamped section on top passes" 0 "agrees" armed "$T" +T="$(tree rel-rearmed 0.7.1 '## Unreleased' '' '## 0.7.1 — 2026-07-19' '' '- **This release**')" +check "armed: ...and so does the RE-ARMED ceremony tree (the shape #108 asks for)" \ + 0 "agrees" armed "$T" +# The one bare-VERSION arrangement that is wrong: a stamp naming another +# version. release.yml would publish a body that is not this release's. +T="$(tree rel-wrong 0.7.1 '## 0.7.0 — 2026-07-19' '' '- **Some other release**')" +check "armed: a bare VERSION under someone ELSE's stamped section fails" 1 "wrong number" armed "$T" + +# --- degenerate trees refuse rather than pass by accident ------------------ +T="$(tree no-sections 0.7.1-dev 'Prose and no headings at all.')" +check "armed: a changelog with no '## ' section at all fails" 1 "no '## ' section at all" armed "$T" +check "armed: a missing changelog refuses by path" 1 "no such file" \ + bash "$ARMED" "$WORK/nope.md" "$ROOT/VERSION" +check "armed: a missing VERSION refuses by path" 1 "no such file" \ + bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/nope-version" +mkdir -p "$WORK/empty-ver"; : > "$WORK/empty-ver/VERSION" +check "armed: an empty VERSION refuses" 1 "is empty" \ + bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/empty-ver/VERSION" + +# --- and the tree under test, which is the assertion that actually fires --- +check "armed: THIS tree's VERSION and CHANGELOG.md agree" 0 "agrees" \ + bash "$ARMED" "$ROOT/CHANGELOG.md" "$ROOT/VERSION" + +# The guard is only a guard if CI runs it, and the ceremony is only re-armed +# if the ceremony step says so. Fail-closed pins on both, since a guard nobody +# invokes and a step nobody wrote are the two ways this reverts silently. +check "ci.yml: runs the changelog-armed guard" 0 "" \ + grep -qF 'changelog-armed.sh' "$ROOT/.github/workflows/ci.yml" +check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \ + grep -qF 'Stamping is two edits, not one' "$ROOT/CONTRIBUTING.md" +check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \ + grep -qF 'changelog-armed.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