forked from heavy-duty/ceremony
ci: replay the merge door's steps against a fixture — the caller ran zero of them
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 <noreply@anthropic.com>
This commit is contained in:
parent
ad8ebfe2c9
commit
4fd78615b3
3 changed files with 197 additions and 16 deletions
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
187
.github/workflows/release-exercise.yml
vendored
187
.github/workflows/release-exercise.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue