Merge pull request #33 from claude-bot-andresmgsl/build/5-changelog-armed

feat: actions/changelog-armed — the version-keyed arming guard
This commit is contained in:
Daniel Marin 2026-07-22 20:31:15 +01:00 committed by GitHub
commit 1c8992f2fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 419 additions and 0 deletions

View file

@ -34,3 +34,25 @@ jobs:
# quietly stop running (issue #3's test contract). # quietly stop running (issue #3's test contract).
CEREMONY_REQUIRE_NPM: 1 CEREMONY_REQUIRE_NPM: 1
run: bash test/run.sh run: bash test/run.sh
# Exercises the composite actions the way a consumer does — action.yml
# resolving, $GITHUB_ACTION_PATH, the relative lib sourcing — which the
# test suite, driving the scripts directly, cannot prove (issue #5's
# acceptance criterion).
action-exercise:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Construct an armed scratch tree in the workspace
# This repo carries no VERSION or CHANGELOG.md of its own (until
# #11 dogfoods); the guard reads the workspace, so give it a
# transient armed tree there. The changelog is scratch-named so a
# future real CHANGELOG.md is never shadowed; VERSION cannot be —
# the file backend hardcodes it — so if #11 ever adds a real one,
# this write must go.
run: |
printf '0.0.1-dev\n' > VERSION
printf '# Changelog\n\n## Unreleased\n\n- Scratch entry.\n' > CHANGELOG.scratch.md
- uses: ./actions/changelog-armed
with:
changelog: CHANGELOG.scratch.md

View file

@ -0,0 +1,24 @@
name: Changelog armed
description: >-
Assert the changelog is armed for the tree's version state — the
version-keyed guard (box#108, rig#66; the unconditional form was reverted
by rig#44 and cast#108). The caller must have checked out its own
repository first: the guard reads the consumer's tree at the workspace.
inputs:
version-source:
description: Where the tree's version lives ("file" or "package-json")
required: false
default: file
changelog:
description: Path to the changelog, relative to the workspace
required: false
default: CHANGELOG.md
runs:
using: composite
steps:
- name: changelog armed
shell: bash
env:
CHANGELOG: ${{ inputs.changelog }}
VERSION_SOURCE: ${{ inputs.version-source }}
run: bash "$GITHUB_ACTION_PATH/changelog-armed.sh"

View file

@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-armed.sh [<changelog>] [<version-source>] — assert that the
# changelog 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.
#
# 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.
#
# 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}}"
# The shared libs travel with this action: a consumer's
# `uses: heavy-duty/ceremony/actions/changelog-armed@<tag>` 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
}
# 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, 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 <<EOF
changelog-armed: the 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 (box#108, 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.
EOF
exit 1
fi
else
# 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 the release workflow would publish a body that is not this release's.
if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]; then
cat >&2 <<EOF
changelog-armed: the 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 rig#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 the publisher 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 through
# the very extractor the publisher uses — changelog_section (#4) — so the
# guard and the publisher cannot disagree about what a section is or when
# one counts as empty (rig#67).
if [ -z "$(changelog_section "$changelog" "$ver")" ]; then
cat >&2 <<EOF
changelog-armed: the 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
the publisher refuse to extract the notes — on main, after the fact, with
the release already half-shipped.
The fix is the ceremony's first edit: stamp '## Unreleased' into
'## $ver — DATE', then put an empty '## Unreleased' back above it.
EOF
exit 1
fi
fi
echo "changelog-armed: version '$ver' agrees with the top section ($top_ver)"

View file

@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Contract tests for actions/changelog-armed (issue #5). Constructed fixture
# trees — a dir with a changelog plus a VERSION file or package.json, not
# git repos — the same discipline as the box suite this guard is ported
# from. set -u, not -e: failing commands are behavior for the harness to
# inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
SCRIPT="$ROOT/actions/changelog-armed/changelog-armed.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# The guard reads the consumer's tree at its working directory, so every
# case runs from inside a constructed fixture tree.
in_tree() {
local dir="$1"
shift
(cd "$TMP/$dir" && bash "$SCRIPT" "$@")
}
# tree <name> <version> — a fixture tree with a VERSION file; the changelog
# body arrives on stdin.
tree() {
mkdir -p "$TMP/$1"
printf '%s\n' "$2" >"$TMP/$1/VERSION"
cat >"$TMP/$1/CHANGELOG.md"
}
# pkg_tree <name> <version> — the same, package-json backend.
pkg_tree() {
mkdir -p "$TMP/$1"
printf '{ "name": "fixture", "version": "%s" }\n' "$2" >"$TMP/$1/package.json"
cat >"$TMP/$1/CHANGELOG.md"
}
# --- the -dev rows: top section MUST be '## Unreleased' ----------------------
tree dev-armed 1.2.4-dev <<'EOF'
# Changelog
## Unreleased
- Pending entry.
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
check "-dev + Unreleased on top passes" 0 "agrees" in_tree dev-armed
tree dev-stamped 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
check "-dev + stamped top fails" 1 "development tree" in_tree dev-stamped
check "-dev failure names the file" 1 "CHANGELOG.md" in_tree dev-stamped
check "-dev failure teaches the re-arm fix" 1 "re-arm" in_tree dev-stamped
# --- the bare rows: both ceremony shapes legal, half-ceremonies refused -----
tree bare-armed 1.2.3 <<'EOF'
# Changelog
## Unreleased
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
check "bare + re-armed tree passes" 0 "agrees" in_tree bare-armed
tree bare-stamped 1.2.3 <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
## 1.2.2 — 2026-07-01
- Older entry.
EOF
check "bare + own stamped section on top passes" 0 "agrees" in_tree bare-stamped
tree bare-empty-stamp 1.2.3 <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
## 1.2.2 — 2026-07-01
- Older entry.
EOF
check "bare + own stamped section but EMPTY fails" 1 "no non-empty" \
in_tree bare-empty-stamp
tree bare-wrong-stamp 1.2.3 <<'EOF'
# Changelog
## 9.9.9 — 2026-07-20
- An entry under the wrong number.
EOF
check "bare + top section naming another version fails" 1 "stamped the wrong number" \
in_tree bare-wrong-stamp
tree bare-half-ceremony 1.2.3 <<'EOF'
# Changelog
## Unreleased
- Pending entry that was never stamped.
## 1.2.2 — 2026-07-01
- Older entry.
EOF
check "bare + no section for the version anywhere fails" 1 "HALF-DONE ceremony" \
in_tree bare-half-ceremony
# Whole-version matching: 1.2.3 must not be satisfied by a 1.2.3-rc1 section.
tree bare-rc-only 1.2.3 <<'EOF'
# Changelog
## Unreleased
## 1.2.3-rc1 — 2026-07-15
- The candidate's entry.
EOF
check "bare: an rc section never satisfies the bare version" 1 "HALF-DONE ceremony" \
in_tree bare-rc-only
# An rc is a pre-release, not a dev tree (#3's version_is_dev): it keys on
# the bare rules, so a stamped rc section of its own is shippable.
tree rc-stamped 2.0.0-rc1 <<'EOF'
# Changelog
## Unreleased
## 2.0.0-rc1 — 2026-07-20
- The candidate's entry.
EOF
check "rc keys as bare, own stamped section passes" 0 "agrees" in_tree rc-stamped
# --- degenerate trees --------------------------------------------------------
tree no-sections 1.2.3-dev <<'EOF'
# Changelog
Only preamble prose, no sections.
EOF
check "changelog with no '## ' at all fails" 1 "nothing for a PR entry to land under" \
in_tree no-sections
mkdir -p "$TMP/no-changelog"
printf '1.2.3\n' >"$TMP/no-changelog/VERSION"
check "missing changelog fails" 1 "no such file" in_tree no-changelog
mkdir -p "$TMP/no-version"
printf '# Changelog\n\n## Unreleased\n' >"$TMP/no-version/CHANGELOG.md"
check "missing version source fails" 1 "cannot read the version" in_tree no-version
check "unknown version-source refused" 1 "unknown backend" \
in_tree dev-armed CHANGELOG.md carrier-pigeon
# --- the package-json backend ------------------------------------------------
pkg_tree pkg-dev-armed 0.2.0-dev <<'EOF'
# Changelog
## Unreleased
- Pending entry.
## 0.1.0 — 2026-07-20
- The shipped entry.
EOF
check "package-json: -dev + armed passes" 0 "agrees" \
in_tree pkg-dev-armed CHANGELOG.md package-json
pkg_tree pkg-bare-armed 0.1.0 <<'EOF'
# Changelog
## Unreleased
## 0.1.0 — 2026-07-20
- The shipped entry.
EOF
check "package-json: bare + armed passes" 0 "agrees" \
in_tree pkg-bare-armed CHANGELOG.md package-json
# --- the action's wiring: inputs arrive as env vars --------------------------
mkdir -p "$TMP/env-tree"
printf '1.2.4-dev\n' >"$TMP/env-tree/VERSION"
printf '# Changelog\n\n## Unreleased\n\n- Pending.\n' >"$TMP/env-tree/NOTES.md"
# A non-default changelog name proves the env var is honored, not the default.
env_tree() {
(cd "$TMP/env-tree" && CHANGELOG=NOTES.md VERSION_SOURCE=file bash "$SCRIPT")
}
check "env vars drive the script the way action.yml does" 0 "agrees" env_tree
summary