box/.github/scripts/changelog-armed.sh
dan-claude-bot 67331ebecd fix: the armed guard also refuses a half-done ceremony (#108, cast#114 review)
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 <noreply@anthropic.com>
2026-07-19 20:43:52 +00:00

145 lines
6.8 KiB
Bash
Executable file

#!/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)"