feat: the reusable release workflow — both doors, one implementation #39

Merged
claude-bot-andresmgsl merged 2 commits from build/9-release-workflow into main 2026-07-22 21:22:48 +00:00
9 changed files with 1295 additions and 0 deletions

94
.github/scripts/self-ref-check.sh vendored Normal file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# The self-ref pin guard (issue #9; #1 D3). The reusable workflows carry a
# literal `CEREMONY_SELF_REF: "X.Y.Z"` — the ref consumers' runs check this
# repo out at — because a called workflow file arrives without its
# repository, and checkout `ref:`s must be literals. A stale pin must fail
# CI HERE, not a consumer's release. The rules, keyed on this repo's own
# version state (they presume #11's dogfood tree; before it, no VERSION
# exists and the rules cannot bind yet):
#
# * bare VERSION → pin == VERSION (the ceremony PR stamps the pin to the
# version it releases — the third stamp, alongside VERSION and the
# changelog).
# * -dev VERSION → pin == the newest stamped `## X.Y.Z` heading in
# CHANGELOG.md (the last release — reverse-chronological, so the first
# bare-X.Y.Z heading from the top; whole-field match, so an rc heading
# never satisfies it). No stamped heading yet (the pre-first-release
# tree) → pin == VERSION with -dev stripped.
#
# The self-consumption bypass in release.yml means the pin is never
# load-bearing for this repo's own releases — only for consumers'.
#
# Usage: self-ref-check.sh [tree-dir] (default: the repo root — the CI
# step; tests point it at fixture trees)
set -euo pipefail
# shellcheck source=lib/version.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/version.sh"
tree="${1:-.}"
fail() {
printf '%s\n' "$@" >&2
exit 1
}
# Every workflow carrying the pin must agree — labels.yml carries its own
# copy of the same env (one pin governs machinery and doctrine alike).
shopt -s nullglob
pins=()
carriers=()
for wf in "$tree"/.github/workflows/*.yml; do
pin="$(awk -F'"' '/^[[:space:]]*CEREMONY_SELF_REF:/ { print $2; exit }' "$wf")"
[ -n "$pin" ] || continue
pins+=("$pin")
carriers+=("$wf")
done
if [ "${#pins[@]}" -eq 0 ]; then
fail "self-ref-check: no CEREMONY_SELF_REF found under $tree/.github/workflows — the guard has nothing to guard, which is itself a failure (the reusable workflows carry the pin)."
fi
pin="${pins[0]}"
for i in "${!pins[@]}"; do
if [ "${pins[$i]}" != "$pin" ]; then
for j in "${!pins[@]}"; do
echo " ${carriers[$j]}: ${pins[$j]}" >&2
done
fail "self-ref-check: the pins disagree — every workflow must name the same ceremony release."
fi
done
if [ ! -f "$tree/VERSION" ]; then
# The pre-dogfood window: #11 adds VERSION and CHANGELOG.md, and this
# branch dies with it (VERSION, once added, never leaves). Until then the
# rules have no version state to key on.
echo "NOTICE: no VERSION file — the pre-dogfood tree (#11 adds it). The pin rules key on the version state and cannot bind yet; pin is '$pin', unchecked."
exit 0
fi
ver="$(version_read file "$tree")"
if version_is_dev "$ver"; then
want=""
if [ -f "$tree/CHANGELOG.md" ]; then
# mawk-compatible; whole-field match so 0.7.0-rc1 never satisfies the
# bare shape (#1 constraint 7).
want="$(awk '$1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ { print $2; exit }' "$tree/CHANGELOG.md")"
fi
if [ -n "$want" ]; then
reason="the newest stamped CHANGELOG.md heading (the last release)"
else
want="${ver%-dev}"
reason="VERSION with -dev stripped (no release stamped yet)"
fi
else
want="$ver"
reason="VERSION on a bare tree (the ceremony PR stamps the pin to the version it releases)"
fi
if [ "$pin" != "$want" ]; then
fail "self-ref-check: CEREMONY_SELF_REF is '$pin' but must be '$want' — $reason. A stale pin fails CI here, not a consumer's release."
fi
echo "self-ref-check: pin '$pin' agrees with the tree ($reason)."

View file

@ -27,6 +27,10 @@ jobs:
sudo install actionlint /usr/local/bin/actionlint
- name: Actionlint
run: bash .github/scripts/actionlint-all.sh
- name: Self-ref pin
# The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails
# CI here, not a consumer's release.
run: bash .github/scripts/self-ref-check.sh
- name: Tests
env:
# The npm-backed version_write case may skip locally when npm is
@ -35,6 +39,18 @@ jobs:
CEREMONY_REQUIRE_NPM: 1
run: bash test/run.sh
# The release exercise (issue #9's scratch caller) on every PR, so the
# parse proof and the merge door's step-replay are standing, reviewable
# evidence — not a dispatch someone must remember to run. PR-ONLY, and
# the gate is load-bearing: this CI also runs on push to main, and a
# workflow_call from THAT context would hand release.yml a genuine
# push+refs/heads/main event — the merge door's exact gate — opening a
# live door from CI. A pull_request event can never satisfy either
# door's `if:`.
release-exercise:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/release-exercise.yml
# 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

201
.github/workflows/release-exercise.yml vendored Normal file
View file

@ -0,0 +1,201 @@
name: release exercise
# The scratch caller (issue #9's acceptance criterion): dry wiring only —
# nothing is ever tagged, published, or bumped. Three jobs, three claims:
#
# * `call` — a workflow_call `uses:` validates and parses the called file
# when the run starts, so a green run proves release.yml parses and its
# input contract wires. Both jobs inside it are gated on the push event
# (rig's form), so a non-push caller — dispatch here, pull_request via
# ci.yml — skips them by design.
# * `step-replay` — the merge door's early step sequence executed for
# real (round 1's blocking catch: `call` proves the parse but runs
# zero steps): the two-checkout dance including the `path:
# .ceremony-src` checkout, both branches of the self-consumption
# bypass, the CEREMONY_DIR / RELEASE_ASSETS_DIR wiring, then facts →
# decide → notes through the real $GITHUB_OUTPUT step plumbing — all
# against a constructed fixture tree with a stubbed gh, so a wrong
# `path:`, an inverted bypass, or a CEREMONY_DIR pointing nowhere fails
# HERE, not in a consumer's release. The steps are release.yml's own,
# copied 1:1 where the context allows; where it cannot, the deviation
# is commented at the step.
# * `fixture-chain` — the same script chain offline, via the contract
# test CI runs on every PR (test/release-chain.test.sh).
#
# Runs on workflow_dispatch, and on every PR via ci.yml's workflow_call
# (PR-only there, on purpose — see ci.yml's gate comment). The live doors
# remain the stated honest gap, closed by #11 (ceremony's own 0.1.0
# release calls this exact workflow by local path) and the #13 pilot's
# rehearsal.
on:
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
call:
# Dry: version-source exercises the input contract; the doors stay shut
# on a non-push event. The real caller stub — triggers, permissions,
# the pinned ref — lives in release.yml's header and docs/CONSUMERS.md.
uses: ./.github/workflows/release.yml
with:
version-source: file
step-replay:
runs-on: ubuntu-latest
strategy:
matrix:
# release.yml keys its bypass on `github.repository ==
# 'heavy-duty/ceremony'`; the matrix stands in for that condition so
# BOTH branches run from this one repo — the dogfood repo can never
# take the consumer branch for real, and vice versa.
shape: [dogfood, consumer]
steps:
- uses: actions/checkout@v4
with:
# release.yml's first checkout, verbatim: the pushed head and its
# first parent.
ref: ${{ github.sha }}
fetch-depth: 2
- uses: actions/checkout@v4
# release.yml's second checkout — the consumer path's pinned
# ceremony source, same `path:` wiring. One forced deviation: the
# ref is github.sha, not CEREMONY_SELF_REF — the pinned tag cannot
# exist before the first release (the exact deadlock the bypass
# solves), and the pin's VALUE is already guarded by
# .github/scripts/self-ref-check.sh in CI. What this step proves is
# the wiring: the checkout lands in .ceremony-src and every later
# step resolves libs through it.
if: matrix.shape == 'consumer'
with:
repository: ${{ github.repository }}
ref: ${{ github.sha }}
path: .ceremony-src
- name: wire CEREMONY_DIR and the assets dir
env:
SHAPE: ${{ matrix.shape }}
# release.yml's wiring step with the matrix standing in for the
# GITHUB_REPOSITORY test (comment on the matrix above).
run: |
if [ "$SHAPE" = "dogfood" ]; then
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
else
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
fi
mkdir -p "$RUNNER_TEMP/release-assets"
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: construct the fixture consumer tree and the gh stub
# The fixture release.yml's steps run against (below): a base at
# 0.6.9-dev with an armed changelog, then the ceremony merge —
# VERSION bumped bare, Unreleased stamped. Same shape as
# test/release-chain.test.sh. The gh stub answers the one API fact
# the ceremony path consults (the merged release-labeled PR) so
# nothing here talks to GitHub.
run: |
mkdir -p "$RUNNER_TEMP/stub"
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
if [ "$1" = api ]; then echo true; exit 0; fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
chmod +x "$RUNNER_TEMP/stub/gh"
echo "$RUNNER_TEMP/stub" >> "$GITHUB_PATH"
git init -q "$RUNNER_TEMP/fixture"
cd "$RUNNER_TEMP/fixture"
git config user.email fixture@example.invalid
git config user.name fixture
printf '0.6.9-dev\n' > VERSION
cat > CHANGELOG.md <<'EOF'
# Changelog
## Unreleased
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git add VERSION CHANGELOG.md
git commit -qm "base"
printf '0.7.0\n' > VERSION
cat > CHANGELOG.md <<'EOF'
# Changelog
## Unreleased
## 0.7.0 — 2026-07-21
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git add VERSION CHANGELOG.md
git commit -qm "release: 0.7.0"
echo "FIXTURE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: gather the facts — version, base version, released, labeled
id: facts
working-directory: ${{ runner.temp }}/fixture
env:
MERGE_SHA: ${{ env.FIXTURE_SHA }}
# Empty exercises the branch-create fallback: facts.sh must fall
# back to the merge commit's first parent (#1 constraint 10).
EVENT_BEFORE: ""
VERSION_SOURCE: file
# release.yml's step verbatim — same invocation, same
# $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the
# workspace (the one thing a replay cannot inherit).
run: bash "$CEREMONY_DIR/lib/facts.sh" >> "$GITHUB_OUTPUT"
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
env:
VER: ${{ steps.facts.outputs.ver }}
BASE_VER: ${{ steps.facts.outputs.base_ver }}
RELEASED: ${{ steps.facts.outputs.released }}
LABELED: ${{ steps.facts.outputs.labeled }}
# release.yml's step verbatim.
run: |
out="$(bash "$CEREMONY_DIR/lib/decide.sh")"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^ceremony=' >> "$GITHUB_OUTPUT"
- name: release notes — the version's own changelog section
if: steps.decide.outputs.ceremony == 'yes'
working-directory: ${{ runner.temp }}/fixture
env:
VER: ${{ steps.facts.outputs.ver }}
# release.yml's step verbatim, cwd'd at the fixture.
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
exit 1
fi
cat "$RUNNER_TEMP/notes.md"
- name: the chain must land where the fixture says it lands
env:
CEREMONY: ${{ steps.decide.outputs.ceremony }}
VER: ${{ steps.facts.outputs.ver }}
BASE_VER: ${{ steps.facts.outputs.base_ver }}
# Not a release.yml step — the replay's own assertion that the real
# steps produced the facts and verdict the fixture encodes, so a
# green job means the wiring carried real values, not empties.
run: |
[ "$VER" = "0.7.0" ] || { echo "ver: got '$VER'" >&2; exit 1; }
[ "$BASE_VER" = "0.6.9-dev" ] || { echo "base_ver: got '$BASE_VER'" >&2; exit 1; }
[ "$CEREMONY" = "yes" ] || { echo "ceremony: got '$CEREMONY'" >&2; exit 1; }
grep -q "The entry this release ships" "$RUNNER_TEMP/notes.md" \
|| { echo "notes.md missing the fixture's entry" >&2; exit 1; }
echo "step-replay ($CEREMONY_DIR): facts -> decide -> notes carried real values end to end"
fixture-chain:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: the merge door's script chain against a fixture ceremony
run: bash test/release-chain.test.sh

369
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,369 @@
name: release
# THE reusable release workflow — two doors into one act, implemented once
# for the whole family (issue #9; lineage box#83/#96 · rig#32/#47 ·
# cast#96/#111 — this essay is condensed from those three sources, and every
# rule in it was bought with an incident).
#
# ## The two doors
#
# * The MERGE door: a release is a PR — `release: X.Y.Z`, carrying the
# hand-set `release` label, bumping the version from X.Y.Z-dev to bare
# X.Y.Z and stamping the changelog — and MERGING it is the ship decision.
# The label is the intent, the version transition is the interlock: the
# 5-state table (lib/decide.sh, issue #8) tells a ceremony apart from
# release-flow work under the same label, turns every legitimate
# non-ceremony into a green NOTICE no-op, and refuses every half-ceremony
# loudly, creating nothing. The job then tags the merge commit via the API
# and publishes in the SAME job, on purpose: a GITHUB_TOKEN-created tag
# fires no workflows (GitHub's anti-recursion), so that tag can never
# re-enter the tag door below and double-publish — this job is the
# release's only chance to publish, and the nothing-exists assert covers a
# manual tag racing the merge. Afterwards the job re-arms main itself:
# bump to X.Y.(Z+1)-dev, pushed directly with the job's token (fires
# nothing), falling back to a labeled PR if branch protection refuses —
# loudly, never leaving main armed to impersonate the release.
#
# * The TAG door: a bare X.Y.Z tag push (no 'v' prefix — box's 0.6.0 set
# the scheme) is the documented manual fallback and backfill. The tag must
# name the tree's own version; a mismatch fails loudly and creates
# nothing. No decide and no label check — the tag is the operator's
# explicit act — and no bump: the fallback does not rewrite main (cast's
# precedent).
#
# Both doors publish the release body from the version's own CHANGELOG.md
# section (lib/changelog.sh — the one canonical extractor): the curated
# prose, never the generated PR list. Assets come only from the consumer's
# optional artifact hook (below); with no hook, GitHub's source tarball for
# the tag IS the package (box, rig).
#
# ## The caller contract
#
# This is the consumer's ENTIRE release.yml (also in docs/CONSUMERS.md).
# Triggers and permissions MUST live in the caller — a called workflow
# cannot define them:
#
# name: release
# on:
# # ONE push key, both filters — YAML maps are last-key-wins; a second
# # sibling `push:` silently replaces the first and kills a door (rig's
# # review catch: the tag fallback had stopped triggering).
# push:
# tags: ["**"] # every tag — a wrong tag must FAIL the assert
# # loudly below, never be skipped by a shape
# # filter that didn't match
# branches: [main]
# permissions:
# contents: write # tag ref create + release create + the bump push
# pull-requests: write # the label read; the bump-fallback `gh pr create`
# issues: write # --label on that fallback PR rides the issues API
# jobs:
# release:
# uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
# with:
# version-source: file # or: package-json
#
# The called workflow runs in the CALLER's context: the caller's event
# payload (github.ref / github.sha / github.event.before), the caller's
# GITHUB_TOKEN, the caller's permission grant. The doors split on the pushed
# ref exactly as the sources did, and the anti-recursion property is
# unchanged: tags and pushes created with GITHUB_TOKEN fire no workflows.
# The merge door MUST keep riding `push` to main, never `pull_request`: a
# pull_request run from a public FORK gets a READ-ONLY token that
# `permissions:` cannot raise (box#97) — and every ceremony PR in this org
# is cross-repo from a bot fork — so the asserts would pass and the tag
# create would 403, red on main, every release.
#
# ## The self-ref pin (#1 D3)
#
# A called workflow file arrives alone; it does not bring its repository.
# So each door checks out heavy-duty/ceremony at the literal pinned
# CEREMONY_SELF_REF below (into .ceremony-src, inside the workspace) to get
# lib/ at run time — except on the dogfood path: when the caller IS
# heavy-duty/ceremony, the workspace already holds this repo at the merge
# commit, libs included, and fetching tag X.Y.Z from the very run that
# creates it would deadlock (#11). Every script call goes through
# CEREMONY_DIR, so the bypass is one `if:` plus one env line.
#
# ## The artifact hook (#1 D4)
#
# If the consumer carries .github/actions/release-artifact/action.yml, both
# doors invoke it — after the tag exists, before `gh release create` — with
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
# drops there is uploaded as a release asset. Exit non-zero to abort the
# release. No hook → no assets.
#
# ## What is honestly untested
#
# Every decision this workflow takes lives in a tested script: version state
# (lib/version.sh), the 5-state verdict (lib/decide.sh), fact gathering
# (lib/facts.sh), notes extraction (lib/changelog.sh), and the facts →
# decide → notes chain is rehearsed end-to-end against fixtures
# (test/release-chain.test.sh). The merge door's early step sequence — both
# checkout shapes, both branches of the self-consumption bypass, the
# CEREMONY_DIR wiring, and the facts → decide → notes steps with their real
# $GITHUB_OUTPUT plumbing — is executed against a fixture by
# release-exercise.yml's step-replay job, on every PR via ci.yml. What
# remains, honestly untested until it runs live: the doors themselves —
# door gating on a real push event, tag create, publish, and bump. That gap
# is closed by #11 (ceremony's own 0.1.0 release runs this exact workflow
# via a local-path call) and by the #13 pilot's rehearsal.
on:
workflow_call:
inputs:
version-source:
description: >-
Where the tree's version lives: "file" (a VERSION file — box, rig,
incubator) or "package-json" (the version field, lockfile kept in
sync on bump — cast)
type: string
required: false
default: file
env:
# A called workflow arrives without its repository. This literal pin is
# stamped by ceremony's own release PR to the version being released —
# one more line in the same ritual as stamping the changelog (#11) — and
# .github/scripts/self-ref-check.sh fails ceremony's own CI when it is
# stale: a stale pin dies here, not in a consumer's release. checkout's
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:`
# references.
CEREMONY_SELF_REF: "0.1.0"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:
release-on-merge:
# The merge door. Gated on the push EVENT as well as the ref (rig's
# form): a workflow_dispatch of a caller sitting on main — this repo's
# own release-exercise.yml — must stay dry wiring, never a live door.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The pushed head is what ships; its first parent (fetch-depth: 2)
# is main the instant before the PR landed, which the version
# transition is measured against (lib/facts.sh adds the
# belt-and-braces fetch of event.before — cast's precedent).
ref: ${{ github.sha }}
fetch-depth: 2
- uses: actions/checkout@v4
# The self-consumption bypass (load-bearing — without it, ceremony's
# own release deadlocks): on the dogfood path the workspace IS this
# repo at the merge commit, libs included, so nothing is fetched —
# the 0.1.0 run would otherwise check out tag 0.1.0, which is
# created only AFTER that very run succeeds (#11).
if: github.repository != 'heavy-duty/ceremony'
with:
repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src
- name: wire CEREMONY_DIR and the assets dir
run: |
if [ "$GITHUB_REPOSITORY" = "heavy-duty/ceremony" ]; then
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
else
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
fi
mkdir -p "$RUNNER_TEMP/release-assets"
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: gather the facts — version, base version, released, labeled
id: facts
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.sha }}
EVENT_BEFORE: ${{ github.event.before }}
# Facts on stdout in $GITHUB_OUTPUT form, diagnostics on stderr;
# the API facts are gathered only in the states that consult them.
run: bash "$CEREMONY_DIR/lib/facts.sh" >> "$GITHUB_OUTPUT"
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
env:
VER: ${{ steps.facts.outputs.ver }}
BASE_VER: ${{ steps.facts.outputs.base_ver }}
RELEASED: ${{ steps.facts.outputs.released }}
LABELED: ${{ steps.facts.outputs.labeled }}
# The 5-state table lives in lib/decide.sh (issue #8) — pure, so it
# is contract-tested offline. `ceremony=no` ends this job green (the
# NOTICE already printed); a refusal is red with nothing created.
run: |
out="$(bash "$CEREMONY_DIR/lib/decide.sh")"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^ceremony=' >> "$GITHUB_OUTPUT"
- name: release notes — the version's own changelog section
if: steps.decide.outputs.ceremony == 'yes'
env:
VER: ${{ steps.facts.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
exit 1
fi
cat "$RUNNER_TEMP/notes.md"
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
# What makes a re-run of a completed ceremony refuse instead of
# clobber, and what catches a manual tag racing the merge.
run: |
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
exit 1
fi
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: tag the merge commit — same job as the publish, on purpose
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
MERGE_SHA: ${{ github.sha }}
# A GITHUB_TOKEN-created tag triggers nothing (anti-recursion), so
# the tag door cannot double-fire off this tag — and this job is
# the only chance to publish (the sources' central comment).
run: |
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA"
- name: artifact hook — the consumer's own release-artifact action
# Runs after the tag exists, before the publish (#1 D4). The local
# path resolves in the consumer checkout at the workspace root —
# legal in a called workflow because the action is on disk. Hook
# contract: drop finished files into $RELEASE_ASSETS_DIR; exit
# non-zero to abort the release (docs/CONSUMERS.md).
if: steps.decide.outputs.ceremony == 'yes' && hashFiles('.github/actions/release-artifact/action.yml') != ''
uses: ./.github/actions/release-artifact
with:
version: ${{ steps.facts.outputs.ver }}
- name: publish the release
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
run: |
assets=()
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
"${assets[@]}"
# The post-release bump, folded into the release act (the sources'
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
# refuses anything but bare X.Y.Z). A GITHUB_TOKEN push fires no
# workflows (anti-recursion), so the bump triggers neither this door
# nor a red run; should branch protection refuse the direct push, the
# step opens the bump PR itself and says so, loudly, instead of
# leaving main armed to impersonate the release.
- name: bump main to the next -dev — the release re-arms main itself
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/version.sh"
next="$(version_next_dev "$VER")"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# main may have moved since the merge; release+1 lands on the
# newer head — the intended arithmetic either way (cast's
# comment).
git fetch origin main
git checkout -B main origin/main
version_write "$VERSION_SOURCE" "$next"
# NEVER `git add -A` here: .ceremony-src sits UNTRACKED in this
# workspace on the consumer path, and -A would commit the whole
# ceremony checkout into the consumer's main. Exactly the files
# the bump wrote, nothing else.
case "$VERSION_SOURCE" in
file) git add VERSION ;;
package-json) git add package.json package-lock.json ;;
esac
git commit -m "chore: bump main to $next — a dev install must not impersonate $VER"
if ! git push origin main; then
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
--title "chore: bump main to $next" \
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
--label release
fi
release-on-tag:
# The tag door — the manual fallback and backfill. The tag is the
# operator's explicit act: no decide, no label check — and no bump
# (cast's precedent: the fallback does not rewrite main). Event-gated
# like the merge door: dispatch runs stay dry.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
# The self-consumption bypass — see the merge door's twin step.
if: github.repository != 'heavy-duty/ceremony'
with:
repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src
- name: wire CEREMONY_DIR and the assets dir
run: |
if [ "$GITHUB_REPOSITORY" = "heavy-duty/ceremony" ]; then
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
else
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
fi
mkdir -p "$RUNNER_TEMP/release-assets"
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: the tag must name the tree's own version
id: assert
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/version.sh"
ver="$(version_read "$VERSION_SOURCE")"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
echo "ver=$ver" >> "$GITHUB_OUTPUT"
- name: release notes — the version's own changelog section
env:
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release" >&2
exit 1
fi
cat "$RUNNER_TEMP/notes.md"
- name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step.
if: hashFiles('.github/actions/release-artifact/action.yml') != ''
uses: ./.github/actions/release-artifact
with:
version: ${{ steps.assert.outputs.ver }}
- name: publish the release
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
assets=()
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
"${assets[@]}"

View file

@ -1,5 +1,72 @@
# Consumer setup
## Release workflow
The reusable release workflow implements both doors of the ceremony — the
merge door (merging the `release`-labeled ceremony PR ships it) and the tag
door (a bare `X.Y.Z` tag push as the manual fallback and backfill). The
design essay lives in the workflow's own header comment; the doctrine in
issue #1.
The consumer's **entire** `release.yml`:
```yaml
name: release
# Triggers and permissions MUST live here (a called workflow cannot define them):
on:
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
# `push:` silently replaces the first and kills a door (rig's review catch).
push:
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# never be skipped by a shape filter that didn't match
branches: [main]
permissions:
contents: write # tag ref create + release create + the bump push
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
issues: write # --label on that fallback PR rides the issues API
jobs:
release:
uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
with:
version-source: file # or: package-json
```
`version-source` is the only input: `file` (a `VERSION` file — box, rig,
incubator) or `package-json` (the version field, lockfile kept in sync on
the post-release bump — cast). Everything else a repo might vary is a change
to the ceremony itself, made in this repo, once.
Keep the merge door on `push` to `main` — never `pull_request`: a
`pull_request` run from a public fork gets a read-only `GITHUB_TOKEN` that
`permissions:` cannot raise (box#97), and every ceremony PR in this org is
cross-repo from a bot fork.
Bootstrap the version at `X.Y.Z-dev`, not bare: a first version that never
carried `-dev` hits the decide table's refuse row and has to ship by the
tag door instead (the known first-release edge, cast#111).
### The artifact hook
If the repository contains `.github/actions/release-artifact/action.yml`,
both doors invoke it — after the tag exists, before `gh release create`
with the release `version` as input and `RELEASE_ASSETS_DIR` exported.
Contract for hook authors:
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
uploaded as a release asset.
- Exit non-zero to abort the release.
- The hook owns its own toolchain (checkout is done; install node, docker,
whatever it needs, itself).
A failed hook leaves the tag created but no release published. Recovery is
the tag door's semantics: fix the cause, then delete and re-push the same
tag (the tag door publishes for it), or run `gh release create` by hand from
a fixed tree. The merge door's nothing-exists assert will refuse a re-run of
the completed merge, by design.
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
tag IS the package.
## Labels automation
The reusable labels workflow owns two independent jobs: additive path-based

108
lib/facts.sh Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# lib/facts.sh — the merge door's fact gathering (issue #9).
#
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
# 5-state verdict. This script is the impure half that establishes those
# facts. It runs inside the consumer's checkout (the working directory),
# talks to git and gh, and prints the facts in $GITHUB_OUTPUT form:
#
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
#
# stdout carries exclusively those lines — the release workflow appends the
# whole stream to $GITHUB_OUTPUT — so every diagnostic goes to stderr.
#
# Env in:
# VERSION_SOURCE file | package-json (the workflow's one input)
# MERGE_SHA the pushed head (github.sha)
# EVENT_BEFORE github.event.before — may be empty or all-zeros
# GITHUB_REPOSITORY for the two API facts
# GH_TOKEN for gh (unused when no API state is consulted)
#
# The API calls run only in the states that consult them (decide tolerates
# empty facts — issue #8): RELEASED only for a bare unchanged version,
# LABELED only for a bare transition. A -dev tree — every ordinary merge —
# decides on the two versions alone and never touches the API.
set -euo pipefail
# shellcheck source=lib/version.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
case "$VERSION_SOURCE" in
file) src=VERSION ;;
package-json) src=package.json ;;
*)
echo "facts: unknown VERSION_SOURCE '$VERSION_SOURCE' — expected file or package-json" >&2
exit 1
;;
esac
ver="$(version_read "$VERSION_SOURCE")"
# event.before is all-zeros on a branch-create push, and absent outside push
# events; the pushed head's first parent is main the instant before, either
# way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char").
base_sha="${EVENT_BEFORE:-}"
case "$base_sha" in
*[!0]*) ;;
*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;;
esac
# Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves
# the first parent, but event.before can predate it when pushes raced. If
# the fetch still cannot produce it, the git show below is the loud failure.
git cat-file -e "$base_sha" 2>/dev/null \
|| git fetch --depth=1 origin "$base_sha" >&2 \
|| true
base_dir="$(mktemp -d)"
trap 'rm -rf "$base_dir"' EXIT
if git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then
base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")"
else
# The base tree has no version source at all: the merge that ADDS the
# version machinery (a consumer's adoption PR, a greenfield repo's first
# caller). "(none)" is not a version, so decide sees a changed version
# and the table still governs: a -dev head is work (row 2, the guided
# bootstrap path), a bare head still demands the merged release label
# (rows 56). Nothing releases silently either way.
base_ver="(none)"
fi
released=""
labeled=""
if ! version_is_dev "$ver"; then
if [ "$base_ver" = "$ver" ]; then
# Any gh failure reads as "not released" — the sources' semantics; the
# verdict this feeds (row 4) is a refusal, and the ceremony path
# re-checks existence in the nothing-exists assert before creating
# anything.
if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
released=yes
else
released=no
fi
else
# The sources' exact jq: merged PRs only, `release` among the label
# names. Read via the API because a push event carries no PR payload —
# and the PR itself lives on a fork (the trigger comment in the
# workflow). A failed API call reads as "no label", which row 5
# refuses: fail-closed.
if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \
| grep -qx true; then
labeled=yes
else
labeled=no
fi
fi
fi
echo "facts: ver='$ver' base_ver='$base_ver' released='$released' labeled='$labeled'" >&2
printf 'ver=%s\n' "$ver"
printf 'base_ver=%s\n' "$base_ver"
printf 'released=%s\n' "$released"
printf 'labeled=%s\n' "$labeled"

175
test/facts.test.sh Normal file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Contract tests for lib/facts.sh (issue #9) — the merge door's impure half.
# Constructed git repos stand in for the consumer checkout, and a gh stub on
# PATH stands in for the API, so every fact row is proven offline —
# including the rows that must NOT touch the API at all (the stub's default
# mode fails the test if gh is called). 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"
FACTS="$ROOT/lib/facts.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
ZEROS="0000000000000000000000000000000000000000"
# The gh stub: behavior selected per call site by GH_STUB. The default
# refuses — the -dev rows and every ordinary merge must never call the API.
mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
case "${GH_STUB:-none}" in
labeled-yes | labeled-no)
if [ "$1" != api ]; then
echo "gh stub: expected an api call, got: gh $*" >&2
exit 97
fi
[ "${GH_STUB}" = labeled-yes ] && echo true || echo false
;;
released-yes | released-no)
if [ "$1" != release ]; then
echo "gh stub: expected a release call, got: gh $*" >&2
exit 97
fi
[ "${GH_STUB}" = released-yes ] && exit 0 || exit 1
;;
*)
echo "gh stub: gh must not be called in this state (gh $*)" >&2
exit 97
;;
esac
EOF
chmod +x "$TMP/stub/gh"
# repo <name> — a fixture git repo; commit <name> <msg> [file content...]
repo() {
git init -q "$TMP/$1"
git -C "$TMP/$1" config user.email fixture@example.invalid
git -C "$TMP/$1" config user.name fixture
}
# commit <repo> <file> <content> — write one file, commit, print the sha.
commit() {
printf '%s\n' "$3" >"$TMP/$1/$2"
git -C "$TMP/$1" add "$2"
git -C "$TMP/$1" commit -qm "set $2"
git -C "$TMP/$1" rev-parse HEAD
}
# facts_in <repo> <env assignments...> — run facts.sh inside the fixture
# with the stub first on PATH.
facts_in() {
local dir="$1"
shift
(cd "$TMP/$dir" \
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
"$@" bash "$FACTS")
}
# --- the ceremony transition (bare, changed → labeled consulted) -------------
repo ceremony
base_sha="$(commit ceremony VERSION 0.6.9-dev)"
head_sha="$(commit ceremony VERSION 0.7.0)"
check "transition: ver is the head's" 0 "ver=0.7.0" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
check "transition: base_ver is the base's" 0 "base_ver=0.6.9-dev" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
check "transition: labeled=yes from the API" 0 "labeled=yes" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
# The quoted stderr summary is the emptiness assertion: released='' can
# only appear when the API was genuinely skipped (a call would have set
# yes/no, or tripped the stub's default refusal).
check "transition: released stays empty (not consulted)" 0 "released=''" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-yes
check "transition: labeled=no from the API" 0 "labeled=no" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-no
# event.before all-zeros (branch-create push) and empty (non-push caller)
# both fall back to the head's first parent (#1 constraint 10).
check "all-zeros event.before falls back to the first parent" 0 "base_ver=0.6.9-dev" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$ZEROS" GH_STUB=labeled-yes
check "empty event.before falls back to the first parent" 0 "base_ver=0.6.9-dev" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE= GH_STUB=labeled-yes
# --- the -dev rows: no API call, ever (the stub would exit 97) ---------------
repo dev-work
dev_base="$(commit dev-work VERSION 0.7.1-dev)"
dev_head="$(commit dev-work notes.txt "ordinary work")"
check "-dev unchanged: no API calls made" 0 "base_ver=0.7.1-dev" \
facts_in dev-work VERSION_SOURCE=file MERGE_SHA="$dev_head" EVENT_BEFORE="$dev_base"
check "-dev unchanged: released and labeled stay empty" 0 "released='' labeled=''" \
facts_in dev-work VERSION_SOURCE=file MERGE_SHA="$dev_head" EVENT_BEFORE="$dev_base"
repo dev-bump
bump_base="$(commit dev-bump VERSION 0.7.0)"
bump_head="$(commit dev-bump VERSION 0.7.1-dev)"
check "the post-release bump (bare -> -dev): no API calls" 0 "ver=0.7.1-dev" \
facts_in dev-bump VERSION_SOURCE=file MERGE_SHA="$bump_head" EVENT_BEFORE="$bump_base"
# --- bare, unchanged → released consulted, labeled skipped -------------------
repo window
win_base="$(commit window VERSION 0.7.0)"
win_head="$(commit window notes.txt "post-release window work")"
check "bare unchanged: released=yes from the API" 0 "released=yes" \
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-yes
check "bare unchanged: released=no from the API" 0 "released=no" \
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-no
check "bare unchanged: labeled stays empty (not consulted)" 0 "labeled=''" \
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-yes
# --- a base tree with no version source at all -------------------------------
# The merge that ADDS the version machinery (a consumer's adoption PR).
# "(none)" is not a version, so decide still governs: -dev head is work,
# bare head still demands the label. Nothing releases silently.
repo adoption
adopt_base="$(commit adoption README.md "pre-ceremony tree")"
adopt_head="$(commit adoption VERSION 0.1.0-dev)"
check "absent-at-base reads as (none), -dev head consults no API" 0 "base_ver=(none)" \
facts_in adoption VERSION_SOURCE=file MERGE_SHA="$adopt_head" EVENT_BEFORE="$adopt_base"
repo adoption-bare
adoptb_base="$(commit adoption-bare README.md "pre-ceremony tree")"
adoptb_head="$(commit adoption-bare VERSION 0.1.0)"
check "absent-at-base with a bare head still asks for the label" 0 "labeled=yes" \
facts_in adoption-bare VERSION_SOURCE=file MERGE_SHA="$adoptb_head" EVENT_BEFORE="$adoptb_base" GH_STUB=labeled-yes
# --- the package-json backend ------------------------------------------------
repo pkg
pkg_base="$(commit pkg package.json '{ "name": "fixture", "version": "1.1.9-dev" }')"
pkg_head="$(commit pkg package.json '{ "name": "fixture", "version": "1.2.0" }')"
check "package-json: head version via node" 0 "ver=1.2.0" \
facts_in pkg VERSION_SOURCE=package-json MERGE_SHA="$pkg_head" EVENT_BEFORE="$pkg_base" GH_STUB=labeled-yes
check "package-json: base version via node" 0 "base_ver=1.1.9-dev" \
facts_in pkg VERSION_SOURCE=package-json MERGE_SHA="$pkg_head" EVENT_BEFORE="$pkg_base" GH_STUB=labeled-yes
# --- refusals ----------------------------------------------------------------
check "missing VERSION_SOURCE refuses" 1 "VERSION_SOURCE is required" \
facts_in ceremony MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha"
check "missing MERGE_SHA refuses" 1 "MERGE_SHA is required" \
facts_in ceremony VERSION_SOURCE=file EVENT_BEFORE="$base_sha"
check "unknown backend refuses" 1 "unknown VERSION_SOURCE" \
facts_in ceremony VERSION_SOURCE=carrier-pigeon MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha"
repo no-version
nv_base="$(commit no-version README.md "a tree")"
nv_head="$(commit no-version README.md "with no version at the head either")"
check "no version at the head fails loudly" 1 "no such file" \
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
summary

128
test/release-chain.test.sh Normal file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env bash
# The merge door's script chain, composed end-to-end (issue #9): facts →
# decide → notes against a constructed fixture ceremony, exactly the way
# release.yml wires them (facts' $GITHUB_OUTPUT lines become decide's env;
# the notes come from the one canonical extractor). facts.test.sh proves the
# fact rows and decide's own suite proves the table; this file proves the
# HANDOFF between them. Also run by release-exercise.yml on dispatch. 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"
FACTS="$ROOT/lib/facts.sh"
DECIDE="$ROOT/lib/decide.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A gh stub for the one API fact the ceremony path needs: the merged,
# release-labeled PR behind the commit.
mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
if [ "$1" = api ]; then echo true; exit 0; fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
chmod +x "$TMP/stub/gh"
# The fixture: a base tree at 0.6.9-dev with an armed changelog, then the
# ceremony merge — VERSION bumped bare, Unreleased stamped, re-armed above.
git init -q "$TMP/repo"
git -C "$TMP/repo" config user.email fixture@example.invalid
git -C "$TMP/repo" config user.name fixture
printf '0.6.9-dev\n' >"$TMP/repo/VERSION"
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git -C "$TMP/repo" add VERSION CHANGELOG.md
git -C "$TMP/repo" commit -qm "base"
BASE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
printf '0.7.0\n' >"$TMP/repo/VERSION"
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
## 0.7.0 — 2026-07-21
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git -C "$TMP/repo" add VERSION CHANGELOG.md
git -C "$TMP/repo" commit -qm "release: 0.7.0"
MERGE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
# chain <merge_sha> <event_before> — facts, then decide fed from facts'
# output lines, then the notes extraction, printing each stage's result.
chain() {
(
cd "$TMP/repo" || exit 1
facts_out="$(env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture \
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
bash "$FACTS")" || exit 1
printf '%s\n' "$facts_out"
ver="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "ver" { print $2 }')"
base_ver="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "base_ver" { print $2 }')"
released="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "released" { print $2 }')"
labeled="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "labeled" { print $2 }')"
decide_out="$(env VER="$ver" BASE_VER="$base_ver" RELEASED="$released" \
LABELED="$labeled" bash "$DECIDE")" || exit 1
printf '%s\n' "$decide_out"
case "$decide_out" in
*ceremony=yes*)
# shellcheck source=lib/changelog.sh
. "$ROOT/lib/changelog.sh"
notes="$(changelog_section CHANGELOG.md "$ver")"
if [ -z "$notes" ]; then
echo "chain: the changelog section for $ver is empty" >&2
exit 1
fi
printf 'notes: %s\n' "$notes"
;;
esac
)
}
check "the ceremony merge decides ceremony=yes" 0 "ceremony=yes" \
chain "$MERGE_SHA" "$BASE_SHA"
check "the notes are the stamped section's prose" 0 \
"notes: - The entry this release ships." chain "$MERGE_SHA" "$BASE_SHA"
# The same chain on an ordinary merge: -dev, unchanged — a green NOTICE
# no-op that never consults the API (the stub would refuse a release view).
printf 'ordinary work\n' >"$TMP/repo/notes.txt"
git -C "$TMP/repo" add notes.txt
git -C "$TMP/repo" commit -qm "ordinary work"
WORK_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
printf '0.7.1-dev\n' >"$TMP/repo/VERSION"
git -C "$TMP/repo" add VERSION
git -C "$TMP/repo" commit -qm "chore: bump main to 0.7.1-dev"
BUMP_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
printf 'more ordinary work\n' >"$TMP/repo/notes.txt"
git -C "$TMP/repo" add notes.txt
git -C "$TMP/repo" commit -qm "more ordinary work"
WORK2_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
check "the post-release bump decides ceremony=no" 0 "ceremony=no" \
chain "$BUMP_SHA" "$WORK_SHA"
check "an ordinary -dev merge decides ceremony=no" 0 "ceremony=no" \
chain "$WORK2_SHA" "$BUMP_SHA"
summary

137
test/self-ref.test.sh Normal file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Contract tests for .github/scripts/self-ref-check.sh (issue #9; #1 D3) —
# the pin rules, driven against constructed fixture trees. The CI step runs
# the same script against the real tree. 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"
CHECK="$ROOT/.github/scripts/self-ref-check.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# tree <name> <pin...> — a fixture tree whose workflows carry the pins (one
# workflow file per pin, mimicking release.yml + labels.yml).
tree() {
local name="$1"
shift
mkdir -p "$TMP/$name/.github/workflows"
local i=0 pin
for pin in "$@"; do
i=$((i + 1))
printf 'name: w%s\nenv:\n CEREMONY_SELF_REF: "%s"\n' "$i" "$pin" \
>"$TMP/$name/.github/workflows/w$i.yml"
done
}
run_check() {
bash "$CHECK" "$TMP/$1"
}
# --- the pre-dogfood window (no VERSION yet — the tree #9 lands on) ---------
tree pre-dogfood 0.1.0
check "no VERSION: pin unchecked, loud notice, green" 0 "cannot bind yet" \
run_check pre-dogfood
# --- a bare tree: pin must equal VERSION -------------------------------------
tree bare-good 0.3.0
printf '0.3.0\n' >"$TMP/bare-good/VERSION"
check "bare tree, pin == VERSION passes" 0 "agrees" run_check bare-good
tree bare-stale 0.2.0
printf '0.3.0\n' >"$TMP/bare-stale/VERSION"
check "bare tree, stale pin fails" 1 "must be '0.3.0'" run_check bare-stale
# --- a -dev tree: pin must equal the newest stamped heading ------------------
tree dev-good 0.3.0
printf '0.4.0-dev\n' >"$TMP/dev-good/VERSION"
cat >"$TMP/dev-good/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
- Pending entry.
## 0.3.0 — 2026-07-20
- The shipped entry.
## 0.2.0 — 2026-07-01
- Older entry.
EOF
check "-dev tree, pin == newest stamped heading passes" 0 "agrees" \
run_check dev-good
tree dev-stale 0.2.0
printf '0.4.0-dev\n' >"$TMP/dev-stale/VERSION"
cp "$TMP/dev-good/CHANGELOG.md" "$TMP/dev-stale/CHANGELOG.md"
check "-dev tree, pin behind the last release fails" 1 "must be '0.3.0'" \
run_check dev-stale
# Whole-field match: an rc heading never satisfies the bare X.Y.Z shape —
# the newest BARE heading below it is the last release.
tree dev-rc 0.3.0
printf '0.4.0-dev\n' >"$TMP/dev-rc/VERSION"
cat >"$TMP/dev-rc/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
## 0.4.0-rc1 — 2026-07-21
- The candidate's entry.
## 0.3.0 — 2026-07-20
- The shipped entry.
EOF
check "-dev tree: an rc heading is skipped, the bare one governs" 0 "agrees" \
run_check dev-rc
# --- the pre-first-release tree: pin == VERSION with -dev stripped -----------
tree first-good 0.1.0
printf '0.1.0-dev\n' >"$TMP/first-good/VERSION"
printf '# Changelog\n\n## Unreleased\n\n- Everything so far.\n' \
>"$TMP/first-good/CHANGELOG.md"
check "pre-first-release: pin == VERSION minus -dev passes" 0 "agrees" \
run_check first-good
tree first-stale 0.0.1
printf '0.1.0-dev\n' >"$TMP/first-stale/VERSION"
check "pre-first-release: any other pin fails" 1 "must be '0.1.0'" \
run_check first-stale
# --- degenerate trees --------------------------------------------------------
mkdir -p "$TMP/no-pin/.github/workflows"
printf 'name: w\non: push\n' >"$TMP/no-pin/.github/workflows/w.yml"
check "no pin anywhere fails — the guard must have something to guard" 1 \
"nothing to guard" run_check no-pin
tree split-pin 0.3.0 0.2.0
printf '0.3.0\n' >"$TMP/split-pin/VERSION"
check "disagreeing pins across workflows fail" 1 "disagree" run_check split-pin
tree empty-version 0.1.0
: >"$TMP/empty-version/VERSION"
check "an empty VERSION fails loudly" 1 "is empty" run_check empty-version
# --- the real tree -----------------------------------------------------------
# Whatever state the repo is in (pre-dogfood today, versioned after #11),
# the guard must hold on it — this is the CI step's exact invocation.
real_check() {
(cd "$ROOT" && bash "$CHECK")
}
check "the real tree passes its own guard" 0 "" real_check
summary