forked from heavy-duty/ceremony
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
227 lines
11 KiB
YAML
227 lines
11 KiB
YAML
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 a forge.
|
|
#
|
|
# The stub is gh-shaped, so the facts step below pins
|
|
# CEREMONY_FORGE=github: since #191 facts.sh selects a backend, and
|
|
# on a Forgejo runner it would otherwise pick the forgejo backend,
|
|
# which speaks curl and would walk straight past this stub to the
|
|
# real instance — reading the exercise's fixture SHA against the
|
|
# live repository and refusing it. The exercise rehearses the
|
|
# WIRING; which backend answers is lib/forge.sh's own contract,
|
|
# covered in test/forge*.test.sh.
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/stub"
|
|
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
# The label read is GET commits/{sha}/pulls — a JSON array (#191).
|
|
if [ "$1" = api ]; then
|
|
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
|
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
|
|
# The stub above is gh-shaped; pin the backend that uses it.
|
|
CEREMONY_FORGE: github
|
|
GITHUB_REPOSITORY: fixture/fixture
|
|
# 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
|