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 armed the fragment way (#112) — changelog.d/ with its # marker and one fragment — then the ceremony merge: VERSION bumped # bare and the section stamped by the REAL assembler, the command # the real ceremony PR runs by hand (#112 D12), so the exercise # consumes the tool end to end instead of hand-writing its output. # 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 ## 0.6.8 — 2026-07-01 - An older entry. EOF mkdir changelog.d printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md git add VERSION CHANGELOG.md changelog.d git commit -qm "base" printf '0.7.0\n' > VERSION bash "$CEREMONY_DIR/bin/changelog-assemble" 0.7.0 2026-07-21 git add -A 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" if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; 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 printf '%s\n' "$diagnosis" >&2 exit 1 fi changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - name: an entry-less stamped fixture is refused by the notes predicate working-directory: ${{ runner.temp }}/fixture env: VER: ${{ steps.facts.outputs.ver }} run: | cp CHANGELOG.md "$RUNNER_TEMP/CHANGELOG.good.md" awk -v ver="$VER" ' /^## / { in_section = ($2 == ver) } in_section && /^[[:space:]]*[-*][[:space:]]/ { next } { print } ' "$RUNNER_TEMP/CHANGELOG.good.md" > CHANGELOG.md # shellcheck source=/dev/null . "$CEREMONY_DIR/lib/changelog.sh" if diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then echo "entry-less stamped section unexpectedly passed" >&2 exit 1 fi printf '%s\n' "$diagnosis" | grep -F "section '$VER' has no entries" cp "$RUNNER_TEMP/CHANGELOG.good.md" CHANGELOG.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