feat: actions/changelog-armed — the version-keyed arming guard
Port box's changelog-armed.sh as the first composite action, establishing the pattern #6 and #7 copy: action.yml passes inputs as env vars, the co-located script stays directly runnable, and the shared libs are sourced relative to the action so they travel with it at the consumer's pinned ref. Port deltas per issue #5: the version goes through version_read (#3) so the guard works for package-json trees, and the bare-version emptiness check consults changelog_section (#4) — the guard and the publisher cannot disagree about what a section is. Messages and the header essay keep the box#108 / rig#66 / rig#44 / cast#108 history; this is the guard rig and cast regain at adoption. CI gains an action-exercise job driving uses: ./actions/changelog-armed against a scratch armed tree — the composite wiring proven, not just the script. Closes #5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
366d08e2d4
commit
dde6b253f5
4 changed files with 419 additions and 0 deletions
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
|
|
@ -34,3 +34,25 @@ jobs:
|
|||
# quietly stop running (issue #3's test contract).
|
||||
CEREMONY_REQUIRE_NPM: 1
|
||||
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
|
||||
|
|
|
|||
24
actions/changelog-armed/action.yml
Normal file
24
actions/changelog-armed/action.yml
Normal 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"
|
||||
158
actions/changelog-armed/changelog-armed.sh
Normal file
158
actions/changelog-armed/changelog-armed.sh
Normal 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)"
|
||||
215
test/changelog-armed.test.sh
Normal file
215
test/changelog-armed.test.sh
Normal 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
|
||||
Loading…
Reference in a new issue