From bf1666bd4f638ad46fe57a0725202f7dcaf0f85c Mon Sep 17 00:00:00 2001 From: probe Date: Sun, 2 Aug 2026 20:11:55 +0000 Subject: [PATCH] probe: ceremony CI gates at 9357f09 on a Forgejo runner --- .github/workflows/ci.yml | 107 ------- .github/workflows/labels.yml | 149 ---------- .github/workflows/release-exercise.yml | 211 -------------- .github/workflows/release.yml | 371 ------------------------- .github/workflows/self-labels.yml | 55 ---- .github/workflows/self-release.yml | 27 -- 6 files changed, 920 deletions(-) delete mode 100644 .github/workflows/labels.yml delete mode 100644 .github/workflows/release-exercise.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/self-labels.yml delete mode 100644 .github/workflows/self-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a6882..6a64de6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,110 +50,3 @@ jobs: # 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 - - # The self-guards (issue #11): this repo eats exactly what it serves. The - # guard actions run against the REAL tree — VERSION, CHANGELOG.md, - # drills/, .github/workflows/ — through the same `uses:` steps every - # consumer's CI carries. - # These steps are also the composite-action wiring proof (issue #5's - # acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the - # relative lib sourcing) that action-exercise carried with scratch files - # while this repo had no tree of its own to guard; the armed and - # drill-recorded scratch steps moved here per the armed step's own - # eviction note — the file backend hardcodes the VERSION name, so a - # scratch write would SHADOW the real file, not sit beside it. - self-guards: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # The monotonic guard compares HEAD against the merge base; a - # shallow checkout cannot resolve it, and in CI that is a hard - # failure, not a skip (the action's description). - fetch-depth: 0 - - uses: ./actions/changelog-armed - - uses: ./actions/changelog-monotonic - - uses: ./actions/changelog-assembled - - uses: ./actions/drill-recorded - - uses: ./actions/runner-isolated - - # Exercises changelog-monotonic the way a consumer does, against a - # CONSTRUCTED history. The self-guards job above runs the same action on - # the real tree, but there its containment half is only as interesting as - # the PR's own diff; this job commits a known base and an insert-above - # edit on top, so a real, non-vacuous containment run is standing - # evidence on every PR. (Armed and drill-recorded moved to self-guards — - # the real tree now exercises them; monotonic stays because it reads no - # version source, so it is immune to the VERSION-shadowing problem that - # evicted the other two.) - action-exercise: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Construct a scratch history for the monotonic guard - # The monotonic guard's input is a DIFF, so its exercise needs - # history, not just a file: commit a scratch changelog, mark that - # commit as the fixture base, then commit an insert-above edit on - # top — a real containment run, not just an action.yml parse. The - # base ref is the in-job branch, passed explicitly, because this - # job's shallow PR checkout carries no origin/main for the input's - # default to resolve (consumers get that via fetch-depth: 0, per - # the action's description). Scratch-named file so the real - # CHANGELOG.md is never shadowed; the commits live only in this - # job's checkout and are never pushed. - run: | - git config user.name ceremony-ci - git config user.email ceremony-ci@users.noreply.github.com - printf '# Changelog\n\n## Unreleased\n\n## 0.1.0 — 2026-07-01\n\n- Shipped entry.\n' > CHANGELOG.monotonic.scratch.md - git add CHANGELOG.monotonic.scratch.md - git commit -m 'fixture: monotonic base' - git branch monotonic-fixture-base - printf '# Changelog\n\n## Unreleased\n\n- Entry inserted above.\n\n## 0.1.0 — 2026-07-01\n\n- Shipped entry.\n' > CHANGELOG.monotonic.scratch.md - git commit -am 'fixture: insert above' - - uses: ./actions/changelog-monotonic - with: - changelog: CHANGELOG.monotonic.scratch.md - base-ref: monotonic-fixture-base - - # Exercises actions/docs-sync the way a consumer does (issue #19's - # acceptance criterion). Its own job, unlike the exercises above: the - # composite reads the CONSUMER's tree at the workspace root, and a - # `uses:` step cannot change directory — so the fixture consumer must BE - # the workspace root, with ceremony itself checked out to a subdirectory - # (that path also serves as the action reference and the --source - # override; no ref carrying docs/VENDORED.txt exists to fetch until this - # lands, and the exercised bytes should be THIS PR's anyway). - docs-sync-exercise: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - path: ceremony-src - - name: Construct a fixture consumer at the workspace root - # The pin ref is scratch — --source overrides the fetch, but the - # pin line itself is still parsed and required (one pin governs - # machinery and doctrine; a consumer without one has nothing for - # the mirror to be verified against). - run: | - mkdir -p .github/workflows - printf '%s\n' \ - 'name: release' \ - 'on:' \ - ' push:' \ - ' branches: [main]' \ - 'jobs:' \ - ' release:' \ - ' uses: heavy-duty/ceremony/.github/workflows/release.yml@0.0.0-fixture' \ - > .github/workflows/release.yml - - name: Bootstrap the mirror (--fix) - uses: ./ceremony-src/actions/docs-sync - with: - mode: fix - source: ceremony-src - - name: Verify the mirror (--check, the mode consumers run) - uses: ./ceremony-src/actions/docs-sync - with: - source: ceremony-src diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml deleted file mode 100644 index 86328ae..0000000 --- a/.github/workflows/labels.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: labels -# Reusable half of the labels automation. Triggers and permissions live in -# the caller; docs/CONSUMERS.md carries the complete caller stub. -# -# The caller uses pull_request_target, not pull_request: every PR in this -# family arrives from a fork, where pull_request runs with a READ-ONLY token -# and cannot label anything. _target is safe in this workflow because no PR -# code is ever checked out or executed — scope reads changed paths and the -# path mapping via the API and checks out only the ceremony implementation, -# and reconcile checks out the BASE branch only. Keep it that way. -# -# There is no pull_request_review_target, so a review landing cannot wake this -# workflow directly — which is why the caller's cron is load-bearing, not a -# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The -# cron is the sweep's only discovery path for every transition no subscribed -# event carries: a verdict landing, blocker:ci-red set/cleared, a -# blocker:conflict when another PR merges under this one, and the time-based -# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct — -# the handoff sets state:needs-human and the caller's `labeled` event confirms -# or corrects that optimistic write within seconds. -# -# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not -# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a -# PAT and therefore do trigger — exactly the asymmetry wanted. -on: - workflow_call: - -env: - # A called workflow arrives without its repository. Keep this literal pin - # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.4.0" - -jobs: - scope: - # Not on labeled/unlabeled: those events change no paths, so scope has - # nothing new to derive — and label churn is precisely what they are. - # review_requested/review_request_removed likewise change no paths — they - # exist to wake reconcile (#137) — and running labeler on them widens - # exactly the window #130 documents, where a label written during a - # scope run is clobbered. - if: >- - github.event_name == 'pull_request_target' && - github.event.action != 'labeled' && - github.event.action != 'unlabeled' && - github.event.action != 'review_requested' && - github.event.action != 'review_request_removed' - runs-on: ubuntu-latest - concurrency: - group: labels-scope-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - # actions/labeler@v5 held this seat until #130. Even with - # sync-labels: false it wrote the WHOLE label set — PUT of - # (labels-fetched-at-job-start ∪ derived) — so a label applied while - # the job ran was silently removed: ceremony#128 lost its `release`, - # the merge door's declared-intent read, two seconds after the - # builder set it. v6/v7 write the same way, so the step was replaced - # rather than repinned. labels-scope reads the consumer's - # .github/labeler.yml and the changed paths via the API, and its - # only write is an additive POST of the derived scopes: a label - # applied mid-job survives by construction. - # - # Still no PR code: both checkouts below fetch the ceremony - # implementation only. The dogfood checkout rides github.sha — the - # base-branch commit the workflow file itself came from, so the - # script and workflow can never skew — and doubles as the #11 - # bootstrap: ceremony's own labels must work before any release tag - # exists for the pinned checkout to fetch. - - uses: actions/checkout@v4 - if: github.repository == 'heavy-duty/ceremony' - with: - repository: ${{ github.repository }} - ref: ${{ github.sha }} - - uses: actions/checkout@v4 - if: github.repository != 'heavy-duty/ceremony' - with: - repository: heavy-duty/ceremony - ref: ${{ env.CEREMONY_SELF_REF }} - - uses: ./actions/labels-scope - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - # the BASE branch commit — a PR must not label itself by editing - # the mapping it is judged by - CONFIG_REF: ${{ github.sha }} - - reconcile: - runs-on: ubuntu-latest - # ONE shared group: every reconcile sweeps every open PR, so cron and - # PR-event runs must serialize or two sweeps race the same PR's labels - # and both pass the request-the-human-once guard. - concurrency: - group: labels-reconcile - cancel-in-progress: false - steps: - # pull_request_target is required for fork PR write permission. It is - # safe here because no PR code is ever checked out or executed: - # labels-scope reads the mapping and changed paths via the API, and - # reconcile checks out the BASE branch only. Keep it that way. - - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - ref: ${{ github.event.repository.default_branch }} - - uses: actions/checkout@v4 - # The self-consumption bypass — release.yml's twin, and load-bearing - # for the same reason (#11): ceremony's own labels bootstrap must - # run BEFORE any release tag exists for this checkout to fetch — the - # release label the merge door reads is created by that dispatch, so - # without the bypass the first release deadlocks on its own pin. The - # base-branch checkout above already IS ceremony on the dogfood - # path. - if: github.repository != 'heavy-duty/ceremony' - with: - repository: heavy-duty/ceremony - ref: ${{ env.CEREMONY_SELF_REF }} - path: .ceremony-src - # Two steps, mutually exclusive `if:`s, because a `uses:` path must be - # a literal — the same fork release.yml's CEREMONY_DIR env line - # papers over for `run:` steps, which composite `uses:` has no - # equivalent of. - - name: reconcile state + stale - if: github.repository != 'heavy-duty/ceremony' - uses: ./.ceremony-src/actions/labels-reconcile - with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile state + stale (dogfood — the workspace IS ceremony) - if: github.repository == 'heavy-duty/ceremony' - uses: ./actions/labels-reconcile - with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile issue flow - if: github.repository != 'heavy-duty/ceremony' - uses: ./.ceremony-src/actions/issueflow-reconcile - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile issue flow (dogfood — the workspace IS ceremony) - if: github.repository == 'heavy-duty/ceremony' - uses: ./actions/issueflow-reconcile - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml deleted file mode 100644 index 90201c4..0000000 --- a/.github/workflows/release-exercise.yml +++ /dev/null @@ -1,211 +0,0 @@ -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.\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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d604f0e..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,371 +0,0 @@ -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). 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: - 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.4.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" - 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: 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" - if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then - echo "CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; 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: 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/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml deleted file mode 100644 index 272f429..0000000 --- a/.github/workflows/self-labels.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: labels -# Ceremony's own caller for the labels automation — the dogfood of issue -# #11, wearing the same local-`uses:` deviation as self-release.yml and the -# same warning: consumers must NEVER copy the local form (it rides main, -# unpinned — correct only for the repo that IS the source). Consumers write: -# uses: heavy-duty/ceremony/.github/workflows/labels.yml@ -on: - # The consumer owns this cadence (#203). Hourly is the recommended default - # when no other engine drives board state: the cron is then the sweep's ONLY - # wake for four transition classes — a review verdict landing (there is no - # pull_request_review trigger here), blocker:ci-red set or cleared (no - # check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR - # merges under this one, and the time-based stale / 48h claim-reclaim. The - # events below carry the rest in seconds. Hourly trades ≤1h of latency on - # those four while cutting nominal scheduled sweeps from four an hour to one - # at GitHub's 1-minute billing floor. Do not delete the cron: it is their - # discovery path. If another engine writes some of those transitions, only - # the classes with no other writer bound the cadence; relax it only as that - # list shrinks. - schedule: [{cron: "0 * * * *"}] - # A manual full-board sweep, including taxonomy bootstrap on a fresh repo. - workflow_dispatch: - # Narrowed (#199) to the actions that carry a queue-state change the hourly - # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/ - # unassigned, which feed validation and the 48h claim clock (caught within one - # cadence) and were the dominant issues-churn source. Kept: `opened` (the - # mint→needs-triage check, issueflow's opened-only path), `closed` (the - # blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the - # `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179), - # `reopened` (a closed issue re-entering the queue wearing labels derived when - # it closed). The must-fail in #199 is exactly "a queue-state transition waits - # on the schedule when an event could have carried it", so edited/reopened stay - # on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so - # this does not touch the handoff. - issues: - types: [opened, closed, edited, reopened] - pull_request_target: - # Every PR arrives from a fork, so these carry the head/draft/review facts - # the sweep derives state:* from. labeled/unlabeled are the handoff wake — - # the author's optimistic state:needs-human write, confirmed or corrected - # here in seconds (#11); synchronize re-derives on every push; - # review_requested/review_request_removed wake the sweep that clears (or - # restores) blocker:unrequested — without them the one event that makes - # the label false could not clear it, and a quiet repo wore the red flag - # until the advisory cron (#137). - types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] -permissions: - contents: read - checks: read # mergeability/check-rollup read for PR state - statuses: read # commit-status rollup read for PR state - issues: write - pull-requests: write -jobs: - labels: - uses: ./.github/workflows/labels.yml diff --git a/.github/workflows/self-release.yml b/.github/workflows/self-release.yml deleted file mode 100644 index b9cf91c..0000000 --- a/.github/workflows/self-release.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: release -# Ceremony's own caller — the dogfood of issue #11. This is the consumer -# stub from docs/CONSUMERS.md with ONE deviation, and consumers must NEVER -# copy it: `uses:` below is a LOCAL path, so every run executes THIS tree's -# release.yml. No pin is the point here — ceremony's own release cannot -# check out a tag that the very run creates (#9's self-consumption bypass -# is the same fact one layer down) — and would be a bug anywhere else: a -# consumer without a pin rides main and eats every unreleased change. -# Consumers write: -# uses: heavy-duty/ceremony/.github/workflows/release.yml@ -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: ./.github/workflows/release.yml - with: - version-source: file