#!/usr/bin/env bash set -euo pipefail # changelog-armed.sh [] [] [] — # assert that the changelog is ARMED: that there is a place for the next PR's # entry to land, and that it is the right one for the state this tree is in. # # Ported from box .github/scripts/changelog-armed.sh (box#108, confirmed # cross-repo as rig#66) — box is the only repo that carries this guard # today. Rig and cast LOST it: the naive form — "always require # '## Unreleased' on top" — is false by construction on the ceremony PR's # own tree, which legitimately stamps that heading away, so rig#44 and # cast#108 both had to revert exactly that. This version-keyed form is the # guard rig and cast regain when they adopt ceremony (#13, #15). Anyone # tempted to simplify this back to the unconditional form should read # those two reverts first. # # Fragment mode makes the directory itself the arming (#115): every PR gets # its own issue-named file, so the box#108 clean-mismerge cannot happen because # there is no shared heading to disappear under an open PR. The guard instead # proves that the marker exists, Unreleased is gone, and every fragment is # publishable before its author lets go of the PR. A bare release has no # re-armed shape in this mode — there is nothing to re-arm — so it must have # consumed every fragment and stamped its exact publishable section. # # The failure it exists to catch (box#108, 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 the tree's version (a VERSION file or package.json, # per version-source), 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 # # 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 the release workflow 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 action.yml) so # test/changelog-armed.test.sh can drive it against constructed trees for # both states — the same discipline as the libs it sources. changelog="${1:-${CHANGELOG:-CHANGELOG.md}}" version_source="${2:-${VERSION_SOURCE:-file}}" fragments_dir="${3:-${FRAGMENTS_DIR:-changelog.d}}" # The shared libs travel with this action: a consumer's # `uses: heavy-duty/ceremony/actions/changelog-armed@` downloads this # whole repository at that ref, so ../../lib is always present and always at # the same ref — no checkout step, no version skew possible. here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/version.sh . "$here/../../lib/version.sh" # shellcheck source=lib/changelog.sh . "$here/../../lib/changelog.sh" [ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; } # version_read refuses loudly on a missing or empty source; the wrapper line # names the guard so a workflow log shows which check refused. ver="$(version_read "$version_source")" || { echo "changelog-armed: cannot read the version (version-source: $version_source)" >&2 exit 1 } if [ -d "$fragments_dir" ]; then [ -f "$fragments_dir/README.md" ] || { echo "changelog-armed: fragment mode requires the generated marker '$fragments_dir/README.md' — restore it so the empty directory remains tracked" >&2 exit 1 } if awk '$1 == "##" && $2 == "Unreleased" { found = 1 } END { exit !found }' "$changelog"; then echo "changelog-armed: a '## Unreleased' section survived the adoption — move its entries into '$fragments_dir/.md' and delete the heading" >&2 exit 1 fi fragments="$(changelog_fragments "$fragments_dir")" while IFS= read -r fragment; do [ -n "$fragment" ] || continue if ! diagnosis="$(changelog_fragment_problem "$fragment")"; then printf 'changelog-armed: %s\n' "$diagnosis" >&2 exit 1 fi done <<<"$fragments" if ! diagnosis="$(changelog_shape_problem "$changelog" "$fragments_dir")"; then printf 'changelog-armed: %s\n' "$diagnosis" >&2 exit 1 fi if version_is_dev "$ver"; then echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)" exit 0 fi if [ -n "$fragments" ]; then surviving="$(printf '%s\n' "$fragments" | awk ' BEGIN { separator = "" } { printf "%s%s", separator, $0; separator = ", " } ')" echo "changelog-armed: these fragments were not consumed: $surviving — re-run 'changelog-assemble $ver'" >&2 exit 1 fi top="$(grep -m1 '^## ' "$changelog" || true)" [ -n "$top" ] || { echo "changelog-armed: $changelog has no '## ' section at all — the release stamp for '$ver' is missing" >&2 exit 1 } if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then printf "changelog-armed: the stamped section for '%s' is not publishable: %s\n" \ "$ver" "$diagnosis" >&2 exit 1 fi top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')" if [ "$top_ver" != "$ver" ]; then cat >&2 <&2 exit 1 } # '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, the same shape # changelog_section matches on, so the two cannot disagree about what a # section header is. top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')" # version_is_dev is the single definition of the -dev special case (#3): an # rc is a pre-release, not a dev tree, and keys on the bare rules below. if version_is_dev "$ver"; then if [ "$top_ver" != "Unreleased" ]; then cat >&2 <&2 <&2 <