diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44e0c18..3b56200 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/actions/changelog-armed/action.yml b/actions/changelog-armed/action.yml new file mode 100644 index 0000000..6a63684 --- /dev/null +++ b/actions/changelog-armed/action.yml @@ -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" diff --git a/actions/changelog-armed/changelog-armed.sh b/actions/changelog-armed/changelog-armed.sh new file mode 100644 index 0000000..e0596a7 --- /dev/null +++ b/actions/changelog-armed/changelog-armed.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-armed.sh [] [] — 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@` 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 <&2 <&2 <&2 - exit 1 -} - -notice() { - printf 'NOTICE: %s\n' "$1" -} - -# Before the table: a missing fact is a fact-gathering bug upstream and must -# not fall through to "no"; a malformed fact is the same bug wearing a -# different hat. -if [ -z "${VER:-}" ]; then - refuse "VER is empty — the caller failed to establish the version at the pushed head. Refusing to decide — creating nothing." -fi -if [ -z "${BASE_VER:-}" ]; then - refuse "BASE_VER is empty — the caller failed to establish the version at the base. Refusing to decide — creating nothing." -fi -case "${RELEASED:-}" in - yes | no | '') ;; - *) refuse "RELEASED='${RELEASED}' — expected yes, no, or empty. Refusing to decide — creating nothing." ;; -esac -case "${LABELED:-}" in - yes | no | '') ;; - *) refuse "LABELED='${LABELED}' — expected yes, no, or empty. Refusing to decide — creating nothing." ;; -esac - -# Rows 1–2: a -dev tree decides on VER and BASE_VER alone. Only -dev is -# special-cased (version_is_dev): an rc is a pre-release, and an rc -# transition with a label is a shippable ceremony. -if version_is_dev "$VER"; then - if [ "$BASE_VER" = "$VER" ]; then - notice "the version '$VER' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish." - else - notice "the version changed ('$BASE_VER' -> '$VER') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish." - fi - echo "ceremony=no" - exit 0 -fi - -# Rows 3–4: bare and unchanged — RELEASED tells the post-release window -# apart from a label with no minted version. LABELED is not consulted. -if [ "$BASE_VER" = "$VER" ]; then - case "${RELEASED:-}" in - yes) - notice "the version '$VER' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish." - echo "ceremony=no" - exit 0 - ;; - no) - refuse \ - "the version '$VER' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." \ - "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. A first release whose version never carried -dev ships by the tag door — the known first-release edge.)" - ;; - *) - refuse "the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing." - ;; - esac -fi - -# Rows 5–6: a bare transition — now the LABEL, the operator's declared -# intent. No merged, release-labeled PR behind this commit = a transition -# nobody declared: refuse. -case "${LABELED:-}" in - yes) - echo "ceremony=yes" - ;; - no) - refuse "the version transitioned ('$BASE_VER' -> '$VER') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing." - ;; - *) - refuse "the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing." - ;; -esac diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh new file mode 100644 index 0000000..2f9dcac --- /dev/null +++ b/test/changelog-armed.test.sh @@ -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 — 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 — 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 diff --git a/test/decide.test.sh b/test/decide.test.sh deleted file mode 100644 index 6ab4c1a..0000000 --- a/test/decide.test.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# Contract tests for lib/decide.sh (issue #8) — every row of the decision -# table, offline. 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" - -DECIDE="$ROOT/lib/decide.sh" - -# decide — run the script with exactly -# these four facts in its environment. -decide() { - VER="$1" BASE_VER="$2" RELEASED="$3" LABELED="$4" bash "$DECIDE" -} - -# decide_bare — RELEASED and LABELED genuinely unset, not -# empty: the -dev rows must not require them at all, so the workflow can -# skip API calls it doesn't need. -decide_bare() { - env -u RELEASED -u LABELED VER="$1" BASE_VER="$2" bash "$DECIDE" -} - -# refuses_cleanly — exit 1 AND no ceremony= line on stdout: a -# refusal dies creating nothing, not even an output line for the workflow -# to append. -refuses_cleanly() { - local out - out="$("$@" 2>/dev/null)" - [ $? -eq 1 ] && ! printf '%s' "$out" | grep -q "ceremony=" -} - -# --- the six table rows ------------------------------------------------------ - -check "row 1: -dev unchanged -> ceremony=no" 0 "ceremony=no" \ - decide 1.2.3-dev 1.2.3-dev "" "" -check "row 1: notice names work under the label" 0 "work under the release label" \ - decide 1.2.3-dev 1.2.3-dev "" "" - -check "row 2: -dev changed -> ceremony=no" 0 "ceremony=no" \ - decide 1.2.4-dev 1.2.3 "" "" -check "row 2: notice names the dev tree" 0 "a dev tree is by definition not a release" \ - decide 1.2.4-dev 1.2.3 "" "" - -check "row 3: bare unchanged, released -> ceremony=no" 0 "ceremony=no" \ - decide 1.2.3 1.2.3 yes "" -check "row 3: notice names the post-release window" 0 "post-release window" \ - decide 1.2.3 1.2.3 yes "" - -check "row 4: bare unchanged, unreleased -> refuse" 1 "did not mint the version" \ - decide 1.2.3 1.2.3 no "" -check "row 4: refusal carries the first-release edge parenthetical" 1 "drop the label" \ - decide 1.2.3 1.2.3 no "" -check "row 4: refusal creates nothing" 0 "" \ - refuses_cleanly decide 1.2.3 1.2.3 no "" - -check "row 5: bare transition, unlabeled -> refuse" 1 "not a bare push" \ - decide 1.2.3 1.2.2 "" no -check "row 5: refusal creates nothing" 0 "" \ - refuses_cleanly decide 1.2.3 1.2.2 "" no - -check "row 6: bare transition, labeled -> ceremony=yes" 0 "ceremony=yes" \ - decide 1.2.3 1.2.3-dev "" yes - -# --- fact validation (before the table) -------------------------------------- - -check "empty VER refused" 1 "VER is empty" decide "" 1.2.3 yes yes -check "empty BASE_VER refused" 1 "BASE_VER is empty" decide 1.2.3 "" yes yes -check "garbage RELEASED refused" 1 "expected yes, no, or empty" \ - decide 1.2.3 1.2.3 maybe "" -check "garbage LABELED refused" 1 "expected yes, no, or empty" \ - decide 1.2.3 1.2.2 "" true - -# --- facts not consulted must not be required -------------------------------- - -check "row 1 with RELEASED/LABELED unset" 0 "ceremony=no" \ - decide_bare 1.2.3-dev 1.2.3-dev -check "row 2 with RELEASED/LABELED unset" 0 "ceremony=no" \ - decide_bare 1.2.4-dev 1.2.3-dev - -# --- facts consulted must not fall through to "no" --------------------------- - -check "bare unchanged with empty RELEASED refused" 1 "RELEASED is empty" \ - decide 1.2.3 1.2.3 "" "" -check "bare transition with empty LABELED refused" 1 "LABELED is empty" \ - decide 1.2.3 1.2.2 "" "" - -# --- rc versions behave as bare (only -dev is special-cased) ----------------- - -check "rc transition with a label is a shippable ceremony" 0 "ceremony=yes" \ - decide 1.2.3-rc1 1.2.3-dev "" yes -check "rc unchanged and released is the post-release window" 0 "ceremony=no" \ - decide 1.2.3-rc1 1.2.3-rc1 yes "" - -# --- purity: no git, no gh, no network (issue #8 acceptance) ----------------- - -# no_tool_calls — outside comments, the script never invokes git, gh, or a -# network client; the decision stays provable offline. -no_tool_calls() { - ! grep -v '^[[:space:]]*#' "$DECIDE" | grep -Ewq 'git|gh|curl|wget' -} -check "decide.sh calls no git/gh/network tools" 0 "" no_tool_calls - -summary