From 4fd78615b3af32ffcf9891d9b05b2ea9c6466c96 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 21:01:41 +0000 Subject: [PATCH] =?UTF-8?q?ci:=20replay=20the=20merge=20door's=20steps=20a?= =?UTF-8?q?gainst=20a=20fixture=20=E2=80=94=20the=20caller=20ran=20zero=20?= =?UTF-8?q?of=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: