From ad8ebfe2c921c354f4ff9f40c8b61c9890aed799 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 19:55:39 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20the=20reusable=20release=20workflow?= =?UTF-8?q?=20=E2=80=94=20both=20doors,=20one=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #9. .github/workflows/release.yml (workflow_call) replaces box's, rig's and cast's release.yml: the merge door (facts -> decide -> notes -> tag+publish+bump, every decision in a tested lib script) and the tag door (the manual fallback and backfill, no bump). Plus lib/facts.sh (the merge door's impure half, contract-tested against fixture repos with a stubbed gh), the self-ref pin guard (.github/scripts/self-ref-check.sh + CI step + tests), the release-exercise scratch caller (dry wiring), the end-to-end script-chain rehearsal, and the caller + artifact-hook contracts in docs/CONSUMERS.md. Co-Authored-By: Claude Fable 5 --- .github/scripts/self-ref-check.sh | 94 +++++++ .github/workflows/ci.yml | 4 + .github/workflows/release-exercise.yml | 36 +++ .github/workflows/release.yml | 365 +++++++++++++++++++++++++ docs/CONSUMERS.md | 67 +++++ lib/facts.sh | 108 ++++++++ test/facts.test.sh | 175 ++++++++++++ test/release-chain.test.sh | 128 +++++++++ test/self-ref.test.sh | 137 ++++++++++ 9 files changed, 1114 insertions(+) create mode 100644 .github/scripts/self-ref-check.sh create mode 100644 .github/workflows/release-exercise.yml create mode 100644 .github/workflows/release.yml create mode 100644 lib/facts.sh create mode 100644 test/facts.test.sh create mode 100644 test/release-chain.test.sh create mode 100644 test/self-ref.test.sh diff --git a/.github/scripts/self-ref-check.sh b/.github/scripts/self-ref-check.sh new file mode 100644 index 0000000..7c38fff --- /dev/null +++ b/.github/scripts/self-ref-check.sh @@ -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)." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b56200..7bc746a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml new file mode 100644 index 0000000..b891fd7 --- /dev/null +++ b/.github/workflows/release-exercise.yml @@ -0,0 +1,36 @@ +name: release exercise +# The scratch caller (issue #9's acceptance criterion): workflow_dispatch, +# dry wiring only. A dispatch proves two things without opening a door: +# +# * `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 dispatch — even from main — skips them by design: +# nothing is tagged, published, or bumped. +# * `fixture-chain` — the merge door's script chain (facts → decide → +# notes) runs end-to-end against a constructed fixture repo with a +# stubbed gh, via the same contract test CI runs on every PR. +# +# The live doors are 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 + +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 + + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b12fcf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,365 @@ +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@ +# 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, also run by release-exercise.yml). The YAML +# itself — checkouts, door gating, step wiring — is covered by actionlint +# plus one honest gap: the live doors. 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[@]}" diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 5361615..f430e4b 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -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@ + 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 diff --git a/lib/facts.sh b/lib/facts.sh new file mode 100644 index 0000000..e667524 --- /dev/null +++ b/lib/facts.sh @@ -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 5–6). 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" diff --git a/test/facts.test.sh b/test/facts.test.sh new file mode 100644 index 0000000..83cc16d --- /dev/null +++ b/test/facts.test.sh @@ -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 — a fixture git repo; commit [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 — 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 — 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 diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh new file mode 100644 index 0000000..5973353 --- /dev/null +++ b/test/release-chain.test.sh @@ -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 — 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 diff --git a/test/self-ref.test.sh b/test/self-ref.test.sh new file mode 100644 index 0000000..d04aa89 --- /dev/null +++ b/test/self-ref.test.sh @@ -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 — 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 -- 2.45.2 From 4fd78615b3af32ffcf9891d9b05b2ea9c6466c96 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 21:01:41 +0000 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20replay=20the=20merge=20door's=20steps?= =?UTF-8?q?=20against=20a=20fixture=20=E2=80=94=20the=20caller=20ran=20zer?= =?UTF-8?q?o=20of=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1's shared blocking catch: release-exercise's call job proves the parse but executes no steps (both doors are push-gated, by design), and fixture-chain drives the scripts, not the workflow. The new step-replay job executes the merge door's early sequence for real — both checkout shapes including path: .ceremony-src, both branches of the bypass via a matrix standing in for the repository test, the CEREMONY_DIR wiring, and facts → decide → notes through genuine $GITHUB_OUTPUT plumbing — against a fixture tree with a stubbed gh. Wired into PR CI as standing evidence; PR-only, because a push-to-main workflow_call would hand release.yml the merge door's exact gate. release.yml's honest-gap paragraph narrows to what stays untested until #11: the doors themselves. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 12 ++ .github/workflows/release-exercise.yml | 187 +++++++++++++++++++++++-- .github/workflows/release.yml | 14 +- 3 files changed, 197 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bc746a..1983ccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,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 diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index b891fd7..e75940a 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -1,20 +1,34 @@ name: release exercise -# The scratch caller (issue #9's acceptance criterion): workflow_dispatch, -# dry wiring only. A dispatch proves two things without opening a door: +# 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 dispatch — even from main — skips them by design: -# nothing is tagged, published, or bumped. -# * `fixture-chain` — the merge door's script chain (facts → decide → -# notes) runs end-to-end against a constructed fixture repo with a -# stubbed gh, via the same contract test CI runs on every PR. +# (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). # -# The live doors are 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 +# 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 @@ -28,6 +42,157 @@ jobs: 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b12fcf..6e5ce98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,11 +98,15 @@ name: release # (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, also run by release-exercise.yml). The YAML -# itself — checkouts, door gating, step wiring — is covered by actionlint -# plus one honest gap: the live doors. 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. +# (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: -- 2.45.2