Merge pull request #110 from dan-claude-bot/fix/changelog-rearm

fix: the release ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108)
This commit is contained in:
Daniel Marin 2026-07-19 22:07:11 +01:00 committed by GitHub
commit 41bba74e10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 337 additions and 1 deletions

145
.github/scripts/changelog-armed.sh vendored Executable file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-armed.sh [<changelog>] [<version-file>] — 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 — 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
# 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}"
# 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; }
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 <<EOF
changelog-armed: VERSION is '$ver' (a development tree) but the top section of
$changelog is:
$top
A -dev tree MUST carry '## Unreleased' at the top. Without it, a PR that
wrote its entry under '## Unreleased' before the release merges CLEANLY into
the section above — the one that already shipped — and the changelog quietly
misattributes it (#108, heavy-duty/rig#66).
The fix is to re-arm: add an empty '## Unreleased' immediately above
'$top'. The release ceremony is supposed to do this in the same edit that
stamps the version — see CONTRIBUTING.md, "Releases".
EOF
exit 1
fi
;;
*)
# A bare VERSION is the ceremony tree and the merge commit that publishes
# it. Both arrangements are legal there: re-armed ('## Unreleased' back on
# top, above the section just stamped) or not yet re-armed (the stamped
# section still on top). What is NOT legal is a stamped top section naming
# some OTHER version — that is a ceremony that stamped the wrong number,
# and release.yml would publish a body that is not this release's.
if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]; then
cat >&2 <<EOF
changelog-armed: VERSION is '$ver' but the top section of $changelog is:
$top
A bare VERSION means this tree is a release. Its top section must be either
'## Unreleased' (re-armed after stamping) or the stamped section for '$ver'
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 <<EOF
changelog-armed: VERSION is '$ver' but $changelog has no non-empty section for
'$ver'. The top section is:
$top
This is a HALF-DONE ceremony: the version was bumped but its section was
never stamped — the stamp is MISSING, not misnumbered. A bare VERSION means
this tree is a release, and the section it is about to publish has to exist
and have prose in it. Left alone, this passes CI, merges, and only then does
release.yml refuse to extract the notes — on main, after the fact, with the
release already half-shipped.
The fix is the ceremony's first edit (CONTRIBUTING.md, "Releases"): stamp
'## Unreleased' into '## $ver — DATE', then put an empty '## Unreleased'
back above it.
EOF
exit 1
fi
;;
esac
echo "changelog-armed: VERSION '$ver' agrees with the top section ($top_ver)"

View file

@ -25,6 +25,12 @@ jobs:
run: bash test/labels-reconcile.sh run: bash test/labels-reconcile.sh
- name: release-flow tests - name: release-flow tests
run: bash test/release.sh run: bash test/release.sh
# The changelog is ARMED for the next entry (#108). Its own step rather
# than a line inside test/release.sh: this one asserts a fact about THIS
# tree, not about the release machinery, so when it goes red the log
# 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
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a # 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 # disposable VM, which is exactly the substrate the rehearsal needs. It runs

View file

@ -34,6 +34,51 @@ which records not just what changed but what each drill run proved.
### Fixed ### Fixed
- **The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let
`main` sit disarmed** (#108) — the ceremony stamps `## Unreleased` into
`## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main`
sat with no `## Unreleased` from the release until the next PR that
happened to re-create one. A PR authored *before* the release wrote its
entry under `## Unreleased`; with that heading gone, git lands the entry
under whatever now occupies the position — **the section that just
shipped** — and it merges **cleanly**. No conflict, no error, no red X:
the one signal an author would trust is absent exactly when the outcome
is wrong, and the changelog credits a released version with a change it
does not contain until a human reads the file. Confirmed in the sibling
repo (heavy-duty/rig#66); box has not drifted yet, and the reason is
luck rather than design — 0.6.0's ceremony (`77599ab`) added its heading
*without* removing `## Unreleased`, so main was never disarmed, while
0.7.0 did disarm it and left a window that nothing happened to cross.
Two halves land together. The ceremony step in `CONTRIBUTING.md` is now
explicitly **two edits**: stamp, then put an empty `## Unreleased` back
above the section just stamped — it belongs there and not in
`release.yml`, which only ever touches `VERSION`. And
`.github/scripts/changelog-armed.sh` enforces it in CI, keyed on
`VERSION` because the two states are genuinely different: a `-dev` tree
must carry `## Unreleased` on top, a bare-`VERSION` tree (the ceremony
PR, and the merge that publishes it) may carry either that or its own
stamped section. The keying is the whole design and not an
over-complication — box previously had **no** top-section guard at all,
and the obvious one, an unconditional `## Unreleased` requirement, is
false by construction on the ceremony PR's own tree, which is why rig#44
and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm
does not block the release; it turns `main` red on the very next push,
the automatic `-dev` bump the release itself makes. Leaving the bare
branch's top heading unconstrained is what keeps both ceremony shapes
legal, and a review round on the sibling fix (heavy-duty/cast#114) found
the gap that asymmetry leaves: a **half-ceremony** tree — `VERSION`
bumped, `## Unreleased` still populated on top, and the section for that
version never stamped — makes the wrong-number test false on its first
clause, short-circuits, and passes. Nothing then refuses until
`release.yml` extracts the notes, which is *after* the merge, on `main`,
with the release already half-shipped. So the bare branch now also
requires that the section it is about to publish exists and is non-empty,
and it asserts that by running `release-notes.sh` — the very script
`release.yml` runs — so the guard and the publisher cannot drift apart
over what a section is. The message is its own: a missing stamp is not a
misnumbered one, and an operator sent to correct a version number that is
already right will not find the real problem. Matches
heavy-duty/rig#67, so the three repos agree.
- **`box restore` asks before it destroys — and the confirmation prompt is - **`box restore` asks before it destroys — and the confirmation prompt is
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
discard user state, and only one of them asked. The table gave `restore` discard user state, and only one of them asked. The table gave `restore`

View file

@ -50,7 +50,48 @@ A release is a PR, and merging it ships it
1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION` 1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION`
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version + from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
date (feature PRs land their changelog entry as part of the PR, so the date (feature PRs land their changelog entry as part of the PR, so the
section is already written). This PR is where the release ritual hangs: section is already written).
**Stamping is two edits, not one — the second is re-arming.** After
rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
`## Unreleased` back at the top**, immediately above the section you just
stamped:
```markdown
## Unreleased
## 0.7.1 — 2026-07-19
### Fixed
...
```
Not cosmetic, and not deferrable to the next PR that happens to need it.
Between the stamp and the next re-creation of that heading, `main` has no
`## Unreleased`. A PR authored *before* the release wrote its entry under
that heading; with the heading gone, git lands the entry under whatever
now occupies the position — **the section that just shipped** — and it
merges **cleanly**, no conflict, no signal. The changelog then credits a
released version with a change it does not contain, and nothing but a
human reading the file will ever say so
([#108](https://github.com/heavy-duty/box/issues/108); confirmed in the
sibling repo as
[heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)).
CI enforces the arming rule with
[.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh),
keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a
bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may
carry either `## Unreleased` or its own stamped section. That is why the
guard cannot simply demand `## Unreleased` unconditionally — the
unconditional form is false on the ceremony PR's own tree and makes the
release unshippable, which is why rig and cast both reverted it. The
practical consequence: forgetting to re-arm does **not** block the release
PR, it turns `main` red on the very next push — the automatic `-dev` bump
the release itself makes. Do it in the ceremony PR and main is never
disarmed at all.
This PR is where the release ritual hangs:
the full drill on real hardware, recorded in the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every [drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
PR, a release still proves the boundary. PR, a release still proves the boundary.

View file

@ -179,6 +179,105 @@ check "release.yml: the release bumps main to the next -dev itself" 0 "" \
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \ check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
grep -qF "opening the bump PR instead" "$RY" grep -qF "opening the bump PR instead" "$RY"
# ---------------------------------------------------------------------------
# changelog-armed.sh (#108) — the changelog has a heading for the NEXT entry.
#
# The drift this catches produces no conflict and no error: the ceremony
# stamps '## Unreleased' away, and a PR authored before the release merges its
# entry cleanly into the section that just shipped. box has no top-section
# guard at all today — the two checks above pin only that the 0.6.0 and 0.5.0
# sections still extract, which a disarmed main passes happily.
#
# BOTH states are constructed as real trees and the real script is run against
# them, because the failure mode of the naive fix is precisely a state
# mismatch: an unconditional '## Unreleased' requirement is green on main and
# false on the ceremony PR's own tree, which is why rig#44 and
# heavy-duty/cast#108 both had to revert one. A test that only drives the
# -dev state would have shipped that bug again.
# ---------------------------------------------------------------------------
ARMED="$ROOT/.github/scripts/changelog-armed.sh"
check "changelog-armed: runnable bash" 0 "" bash -n "$ARMED"
# tree <dir> <version> <changelog-body...> — 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"
# --- the HALF-ceremony: the gap the two bare-VERSION clauses leave ---------
# VERSION bumped to the release, '## Unreleased' still populated on top, and
# the section for that version never stamped at all. The wrong-number test
# above is false on its FIRST clause here and short-circuits, so before
# heavy-duty/rig#67's rule this tree passed the guard and was refused instead
# by release.yml — at publish time, after the merge, on main, with the release
# already half-shipped. Caught here one step earlier, by running the same
# extraction release.yml runs.
T="$(tree rel-half 0.8.0 '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
check "armed: a bare VERSION whose section was never stamped fails (half-ceremony)" \
1 "no non-empty section" armed "$T"
check "armed: ...and names the stamp as MISSING, not misnumbered" \
1 "MISSING, not misnumbered" armed "$T"
# The wording is the whole point of the separate branch: an operator sent to
# fix a version number that is already correct will not find the real problem.
not_wrong_number() { ! armed "$1" 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"
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 # 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 # trick) and driven against a shim curl. The shim serves the ONE seam the