From 1a9d96168d9eebc6113e6f1ed82edf0c7b9d5e3f Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 19:44:32 +0000 Subject: [PATCH 1/2] fix: the ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108) Stamping '## Unreleased' away left main with no heading for a PR authored before the release to land in, so its entry merged cleanly into the section that just shipped. The ceremony now re-arms, and changelog-armed.sh enforces it keyed on VERSION -- so the ceremony PR's own bare-VERSION tree stays legal (rig#44 / cast#108). Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-armed.sh | 108 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 6 ++ CHANGELOG.md | 30 ++++++++ CONTRIBUTING.md | 43 +++++++++++- test/release.sh | 75 ++++++++++++++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/changelog-armed.sh 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 -- 2.45.2 From 67331ebecd0cb1400c018c8830b0a50e5e90837b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 20:43:52 +0000 Subject: [PATCH 2/2] fix: the armed guard also refuses a half-done ceremony (#108, cast#114 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-VERSION branch left the top heading unconstrained on purpose — both ceremony shapes have to stay legal, which is the rig#44 / cast#108 lesson. A review round on the sibling fix found the gap that asymmetry leaves: a tree with VERSION bumped, '## Unreleased' still populated on top, and no stamped section for that version makes the wrong-number test false on its first clause, short-circuits, and passes. release.yml then refuses at publish time — after the merge, on main, with the release already half-shipped. So the bare branch now also requires the section it is about to publish to exist and be non-empty, asserted by running release-notes.sh itself so the guard and the publisher cannot drift over what a section is. The message is distinct from the wrong-number case: a missing stamp is not a misnumbered one. Matches heavy-duty/rig#67. test/release.sh constructs the half-ceremony tree and the stamped-but-empty tree and drives the real script at both — all three new assertions fail against the previous guard — while the re-armed and un-re-armed ceremony trees stay green. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/changelog-armed.sh | 39 +++++++++++++++++++++++++++++- CHANGELOG.md | 17 ++++++++++++- test/release.sh | 24 ++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/.github/scripts/changelog-armed.sh b/.github/scripts/changelog-armed.sh index c2c37df..a5bf4c7 100755 --- a/.github/scripts/changelog-armed.sh +++ b/.github/scripts/changelog-armed.sh @@ -19,7 +19,9 @@ set -euo pipefail # 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 +# section for exactly that VERSION — AND the +# section for that VERSION must exist and carry +# prose, because it is the one about to ship # # Keying on VERSION is the whole design, and the reason this is not simply # "require '## Unreleased'". That unconditional form is what rig#44 and @@ -41,6 +43,11 @@ set -euo pipefail changelog="${1:-CHANGELOG.md}" version_file="${2:-VERSION}" +# release-notes.sh lives beside this script; the bare-VERSION branch runs it +# rather than re-implementing the extraction, so the guard and the publisher +# cannot disagree about what a section is or when one counts as empty. +here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + [ -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; } @@ -99,6 +106,36 @@ changelog-armed: VERSION is '$ver' but the top section of $changelog is: itself. A stamped section naming a different version means the ceremony stamped the wrong number, and the published release body would come from the wrong section. +EOF + exit 1 + fi + # The top heading is deliberately left UNCONSTRAINED above — both ceremony + # shapes must stay legal, which is the #44 / cast#108 lesson and is not + # negotiable. That asymmetry leaves a gap of its own, the HALF-ceremony + # tree: VERSION bumped to the release, a populated '## Unreleased' still on + # top, and no stamped section for the version anywhere. The test above is + # false on its first clause, short-circuits, and passes. Nothing else + # refuses until release.yml extracts the notes — which happens AFTER the + # merge, on main, and publishes a release with an empty body, the worst + # place for this to land. So make the same assert one step earlier by + # running the very script release.yml runs (heavy-duty/rig#67). + if ! bash "$here/release-notes.sh" "$ver" "$changelog" >/dev/null 2>&1; then + cat >&2 <&1 | grep -qF 'wrong number'; } +check "armed: ...and not as the wrong-number case, which has a different fix" \ + 0 "" not_wrong_number "$T" +# A section that exists but carries no prose is the same failure: release.yml +# would publish an empty body, which is what release-notes.sh already refuses. +T="$(tree rel-empty 0.7.1 '## 0.7.1 — 2026-07-19' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" +check "armed: a bare VERSION whose section is stamped but EMPTY fails" \ + 1 "no non-empty section" 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" -- 2.45.2