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