# Consumer setup How a repo adopts the ceremony — bootstrap for a greenfield repo, a conversion checklist for a repo carrying its own copy of the machinery, and the policies that keep either honest afterwards. The doctrine (what a release *is*, the doors, the guards, the drill) lives in [../README.md](../README.md); this guide is the how-to. It is meant to be sufficient on its own: a conversion executed from this guide should need zero out-of-band knowledge, and gaps found while converting are filed as edits to this guide (#12). ## Prerequisites - **Repo shape**: work lands on a `main` default branch by PR; fork PRs are fine — the merge door rides `push` to `main`, never `pull_request` ([release.yml](../.github/workflows/release.yml#L70-L74), box#97), and the label read goes through the API ([lib/facts.sh](../lib/facts.sh#L88-L101)), so the ceremony never needs the PR's own context. No PAT, no secrets: every permission the flow uses is the caller-declared `GITHUB_TOKEN` grant. - **Pick the version backend**: `file` (a `VERSION` file — box, rig, incubator) or `package-json` (the `version` field, lockfile kept in sync on the post-release bump — cast). This is the workflow's one input; the full configuration surface of the ceremony is enumerated in [#1](https://github.com/heavy-duty/ceremony/issues/1) ("The configuration axes"). - **The `release` label must exist** before the first ceremony PR — it is the merge door's declared-intent read ([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels sweep caller's `workflow_dispatch` ([Labels automation](#labels-automation)), or create it by hand, matching the core table ([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)): ```sh gh label create release --color 0E8A16 \ --description "Release flow and version/packaging work" ``` ## Bootstrap a new repo The greenfield path (incubator's, #16) — the repo never owns a copy of the machinery at all: 1. **`VERSION` at `X.Y.Z-dev` — never bare.** A first version that never carried `-dev` hits the decide table's refuse row and has to ship by the tag door (the known first-release edge, cast#111; [lib/decide.sh](../lib/decide.sh#L70-L74)). Bootstrapping at `-dev` keeps the repo clear of it entirely. (`package-json` backend: the `version` field, same rule.) 2. **An armed changelog: a `CHANGELOG.md` preamble plus `changelog.d/`.** The changelog file starts as preamble only — no section; the first release writes the first one. The fragments directory beside it is the arming (#112): it carries a `README.md` marker naming the assembler and the doctrine — take ceremony's own [changelog.d/README.md](../changelog.d/README.md) at the pin — which is what keeps the directory tracked while it holds no fragments and what `changelog-armed` asserts. Every behavior-change PR then writes `changelog.d/.md` ([The changelog rule](#the-changelog-rule)); the release PR assembles the section ([Assembling a release section](#assembling-a-release-section)). Fragment mode is available at `0.2.0` and later, and not in `0.1.0`. A consumer pinned to `0.1.0` bootstraps the legacy shape instead — the preamble plus an empty `## Unreleased` section for entries to land under — and converts on the pin bump to `0.2.0` or later; never mix refs to adopt it early. 3. **`drills/README.md`** defining what a drill *means* in this repo — each repo names its own ([the drill doctrine](../README.md#the-drill-doctrine)). Plain `drills`, not a dot-directory ([drill-recorded.sh](../actions/drill-recorded/drill-recorded.sh#L49-L52)). 4. **`.github/workflows/release.yml`** — the caller, verbatim from [Release workflow](#release-workflow) below. 5. **CI guard steps** in the repo's `ci.yml`: ```yaml - uses: actions/checkout@v4 with: # changelog-monotonic and changelog-assembled compare HEAD # against the merge base; a checkout that cannot resolve it is # a hard failure in CI, not a skip (a guard that can quietly # stop guarding is the failure shape these checks exist to # refuse). fetch-depth: 0 - uses: heavy-duty/ceremony/actions/changelog-armed@ - uses: heavy-duty/ceremony/actions/changelog-monotonic@ # changelog-assembled is available at 0.2.0 and later, not in # 0.1.0. Adopt this step with the pin bump to 0.2.0 or later; # never mix refs. Green NOTICE on every non-release PR; on a # release PR it asserts the stamped section is exactly the # fragments it consumed. - uses: heavy-duty/ceremony/actions/changelog-assembled@ - uses: heavy-duty/ceremony/actions/drill-recorded@ # runner-isolated is available at 0.2.0 and later, not in 0.1.0. # Adopt this step with the pin bump to 0.2.0 or later; never mix # refs. - uses: heavy-duty/ceremony/actions/runner-isolated@ ``` `changelog-armed` and `drill-recorded` take `version-source: package-json` where that is the backend; every guard's inputs and defaults are in its `action.yml` ([actions/](../actions/)). Adopting the agent team flow adds the `docs-sync` step ([below](#adopting-the-agent-team-flow)). `runner-isolated` asserts that no `pull_request`-triggered workflow names a self-hosted runner — a PR workflow runs the branch's code, and unreviewed fork code must never execute on your own hardware ([#58](https://github.com/heavy-duty/ceremony/issues/58)). It fires on the PR that first mixes a PR trigger and a self-hosted `runs-on` in one file; the unblock is splitting the workflow. A repo with **no** self-hosted runner still wants it: the guard's value is the day somebody adds one. This guide documents `main`. A marker is the literal token `**unreleased**` immediately followed by its issue citation (for example, `(#238)`); whitespace between them may include a line break. A citation is mandatory, because a marker the guard cannot trace is a marker it cannot prove false. A token inside an inline-code span is a mention, not a marker; spans are ignored individually, so unrelated inline code cannot hide one. A marker for this repository's own issue uses bare `#N`. Cross-repo citations such as `(crew#293)` satisfy the traceability rule but are not compared with this repository's release section. The ceremony-only `marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR, every marker its own assembled section makes false: the section cites its issues, each marker cites the same issue, and the release PR's diff is the one place both halves are visible at once (#221). If an action does not exist at the consumer's pinned tag, adopt it with the pin bump to the first tag that carries it; never mix a moving or newer ref into an otherwise exact-pin consumer. In particular, `0.1.0` carries `changelog-armed`, `changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not `changelog-assembled` or `runner-isolated`. 6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own caller because `edited` is load-bearing: #200 gained its accidental closing keyword after the PR opened, with no push to wake ordinary CI. It costs the consumer one read-only workflow file and no other machinery: ```yaml name: Refs guard on: pull_request: types: [opened, edited, reopened, synchronize] permissions: contents: read pull-requests: read jobs: refs-not-closing: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: heavy-duty/ceremony/actions/refs-not-closing@ ``` `refs-not-closing` is available at `0.6.0` and later (#218). Adopt this caller with that ordinary pin bump; never point only this file at a moving or newer ref. 7. **Labels automation** (optional but recommended): the two callers from [Labels automation](#labels-automation) — the event-facing labels caller and the sweep caller (#209) — plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the path→scope globs). Run the sweep caller's `workflow_dispatch` once — **this bootstraps the taxonomy, `release` label included** — and use it again whenever an operator needs a full-board sweep immediately. 8. **The artifact hook** (optional): `.github/actions/release-artifact/` per [The artifact hook](#the-artifact-hook). No hook → the source tarball is the package. From there the flow is the doctrine: ordinary PRs write their fragment, the ceremony PR makes [the three stamps](../README.md#what-a-release-is), a human merges, the machine transcribes. ## Convert an existing repo The box/rig/cast path — the repo carries its own copy of the machinery and hands it over. The conversion PR is release-flow work: label it `release` if the repo's conventions ask for that, and either way it lands as a green `NOTICE` no-op on main — the decide table's green rows exist precisely so the machinery is safe to work on ([lib/decide.sh](../lib/decide.sh#L6-L12)). - [ ] Replace `.github/workflows/release.yml` with the caller from [Release workflow](#release-workflow) — **whole file**, keeping its load-bearing comments. Check the result has **one** `push:` key carrying both filters: YAML maps are last-key-wins, and a second sibling `push:` silently kills a door (rig's review catch). - [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in the bootstrap list above (with `fetch-depth: 0` on the checkout). - [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony pin as the release caller and CI guard steps. - [ ] Replace `labels.yml` with the caller from [Labels automation](#labels-automation) and add the sweep caller `labels-sweep.yml` beside it (#209); extract `.github/labels.conf` from the old reconciler's embedded config — the `panel=` roster line and the repo's `scope:*` rows ([the format](#labels-automation)). `.github/labeler.yml` stays as it is (path globs are inherently repo-specific). - [ ] Convert the changelog to fragments (requires a pin at the first tag carrying fragment mode — not `0.1.0`): move every entry under `## Unreleased` to `changelog.d/.md`, verbatim — the filename is derivable from the entry's own `(#N)`; an entry citing several issues goes to the file for the first cited — delete the `## Unreleased` heading, and add the `changelog.d/README.md` marker ([bootstrap step 2](#bootstrap-a-new-repo)). Published sections stay byte-identical; `changelog-monotonic` proves that on the conversion PR, and `changelog-armed` refuses a surviving `## Unreleased` the moment the directory exists. Rewrite the repo's own contributor docs that say "add a line under `## Unreleased`" in the same PR — split either way, main lies for as long as the split lasts. - [ ] Delete the now-shadowed copies — zero shared scripts remain: `.github/scripts/release-notes.sh` (box, cast) or `release-lib.sh` (rig), `changelog-armed.sh` (box), `changelog-monotonic.sh`, `drill-recorded.sh`, `labels-reconcile.sh`. - [ ] Trim the repo's test suite to repo-specific tests: the machinery tests go — they live in this repo's `test/` now, run by its CI — while the repo's own surfaces stay (box/rig's install-channel halves of `test/release.sh`, cast's `install-sh` tests). A machinery test *file* goes whole when its subject moved (rig's `test/labels-reconcile.sh` sourced the deleted reconciler), and so do tests that pin the old workflow's shape — a grep or awk against `release.yml`/`ci.yml` internals fails against the caller stub, not because the stub is wrong (rig #13's conversion). - [ ] Sweep the repo's other docs for pointers at the deleted paths — `drills/README.md` and any labels doc typically cite the old `.github/scripts/*.sh` by path; repoint them at the pinned actions. A repo carrying its own copy of a doc the mirror vendors (rig's root `LABELS.md`) retires it in the same PR: a hand-maintained copy beside a machine-verified mirror is the drift the mirror exists to end. - [ ] Shrink CONTRIBUTING's release section to a pointer at [this repo's README](../README.md) plus what is genuinely per-repo: the drill meaning (`drills/README.md`), artifact notes, the changelog house style if it differs from [the portable rule](#the-changelog-rule). - [ ] What stays, per repo, forever: `VERSION` (or the `package.json` version), `CHANGELOG.md`, `changelog.d/`, `drills/`, `.github/labeler.yml`, `.github/labels.conf`, the optional `.github/actions/release-artifact/` — the full kept-vs-moved table is in [#1](https://github.com/heavy-duty/ceremony/issues/1). ## Release workflow The reusable release workflow implements both doors of the ceremony — the merge door (merging the `release`-labeled ceremony PR ships it) and the tag door (a bare `X.Y.Z` tag push as the manual fallback and backfill). The design essay lives in the workflow's own header comment; the doctrine in issue #1. The consumer's **entire** `release.yml`: ```yaml name: release # Triggers and permissions MUST live here (a called workflow cannot define them): 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: heavy-duty/ceremony/.github/workflows/release.yml@ with: version-source: file # or: package-json ``` `version-source` is the only input: `file` (a `VERSION` file — box, rig, incubator) or `package-json` (the version field, lockfile kept in sync on the post-release bump — cast). Everything else a repo might vary is a change to the ceremony itself, made in this repo, once. Keep the merge door on `push` to `main` — never `pull_request`: a `pull_request` run from a public fork gets a read-only `GITHUB_TOKEN` that `permissions:` cannot raise (box#97), and every ceremony PR in this org is cross-repo from a bot fork. Bootstrap the version at `X.Y.Z-dev`, not bare: a first version that never carried `-dev` hits the decide table's refuse row and has to ship by the tag door instead (the known first-release edge, cast#111). ### The artifact hook If the repository contains `.github/actions/release-artifact/action.yml`, both doors invoke it — after the tag exists, before the release is published — with the release `version` as input and `RELEASE_ASSETS_DIR` exported. Contract for hook authors: - Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is uploaded as a release asset. - Exit non-zero to abort the release. - The hook owns its own toolchain (checkout is done; install node, docker, whatever it needs, itself). A failed hook leaves the tag created but no release published. Recovery is the tag door's semantics: fix the cause, then delete and re-push the same tag — the tag door publishes for it. That path is forge-neutral and is the one to prefer. If you must publish by hand instead, use whatever your forge provides; ceremony itself no longer names a client here, because on a Forgejo runner there is no `gh` to name (#191): ```sh # GitHub gh release create "$VER" --verify-tag --title "$VER" \ --notes-file notes.md -R "$OWNER/$REPO" # Forgejo / Gitea — POST /repos/{owner}/{repo}/releases curl -sS -X POST -H "Authorization: token $TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -nc --arg t "$VER" --rawfile b notes.md \ '{tag_name:$t,name:$t,body:$b}')" \ "$FORGE/api/v1/repos/$OWNER/$REPO/releases" ``` The merge door's nothing-exists assert will refuse a re-run of the completed merge, by design. No hook → no assets: for a pure-bash tree, GitHub's source tarball for the tag IS the package. Worked examples land with the conversions: cast's tgz build (#15) and incubator's GHCR image push (#16). ## Labels automation The labels automation is two reusable workflows since #209, adopted together at the same pin: - **`labels.yml`** — the event-facing half, called on PR and issue events. Two jobs: additive path-based `scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by dispatching the consumer's sweep caller (a REST `POST` to the forge's own `${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`, plain `GITHUB_TOKEN` — `workflow_dispatch` is one of the two documented exemptions from the token's no-retrigger rule, so no PAT anywhere in the path and no loop: the sweep dispatches nothing). - **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers, handoff, stale status, the issue work queue, and the `needs-ruling` invariants on both surfaces — the bare-flag check and the 7-day comment-only nudge (#52; the sweep reads that flag and never writes it). Detached from PR-triggered runs on purpose: all sweeps serialize through one shared concurrency group, and GitHub records every queue-displaced run as CANCELLED — harmless (the surviving sweep does its work) until it rode a `pull_request_target` run and the ❌ landed on that PR's checks as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun` and its `--failed`/`--job` forms all decline a queue-displaced run). Behind its own caller, a displaced sweep cancels on the Actions tab, attached to no PR; PR checks show `scope` and the green `trigger` only. The consumer keeps its path mapping in `.github/labeler.yml` and its review panel plus scope taxonomy in `.github/labels.conf`. **Additive means additive** (available at `0.3.0` and later — #130): the scope job's only label write is `POST /issues/{n}/labels`, which adds the derived scopes and removes nothing, so a label applied while the job runs survives it. Earlier tags used `actions/labeler@v5`, which — even under `sync-labels: false` — replaces the whole label set and silently drops a label written mid-job (ceremony#128 lost its `release` that way). With the same pin bump, `.github/labeler.yml` keeps its format but the accepted shape becomes exactly the one this guide has always shown: label → `changed-files` → `any-glob-to-any-file`, block or flow style, globs over `**`, `*` and `?` (`**` crosses `/`, the others do not; the whole path must match). Any other labeler key — `all-globs-to-all-files`, branch matchers, negations — fails the run loudly instead of being half-honoured. The reconcile sweep also warns (never sets) when a non-draft PR carries a bare `X.Y.Z` version differing from its base but no `release` label — the merge door would refuse that merge, and the sweep says so first. The complete event-facing caller is: ```yaml name: labels on: pull_request_target: # Fork PRs; these carry the head/draft/review facts state:* derives from. # labeled/unlabeled are the handoff wake (state:needs-human confirmed here); # synchronize re-derives on every push. review_requested/review_request_removed # (shipped in 0.3.0, ceremony#137) wake the sweep that clears # blocker:unrequested when the panel is asked. types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] # Available at 0.2.0 and later (the first tag carrying ceremony#32); a # consumer pinned to 0.1.0 omits this block. issues: # Narrowed (#199) to the actions carrying a queue-state change the hourly # cron cannot wait one cadence for: opened → the mint→needs-triage check, # closed → the blocker-closes→ready self-heal, edited → a body rewrite of the # `Blocked by #N` declaration the sweep parses, reopened → a closed issue # re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned — # validation + the 48h claim clock, caught within one cadence, and # labeled/unlabeled were the issues-churn source. The handoff wake is # pull_request_target:labeled, not issues, so this leaves it intact. types: [opened, closed, edited, reopened] permissions: contents: read checks: read # mergeability/check-rollup read for PR state statuses: read # commit-status rollup read for PR state actions: write # the trigger job's dispatch of the sweep caller (#209, #205) issues: write pull-requests: write jobs: labels: uses: heavy-duty/ceremony/.github/workflows/labels.yml@ # If the sweep caller below is named anything but labels-sweep.yml, # say so: `with: { sweep_workflow: }`. Ceremony's own # dogfood does (self-labels-sweep.yml). ``` And the complete sweep caller, `labels-sweep.yml` beside it — the hourly cron lives HERE since #209, not on the labels caller: ```yaml name: labels-sweep 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 (no # pull_request_review trigger on the labels caller), blocker:ci-red # set/cleared, blocker:conflict when another PR merges under this one, and # time-based stale / 48h claim-reclaim. The labels caller's events carry the # rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of # latency on those four while cutting nominal scheduled sweeps from four an # hour to one at GitHub's 1-minute 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. A bare dispatch (input default "yes") also # bootstraps the taxonomy on a fresh repo. The labels caller's trigger job # wakes this workflow with bootstrap=no on every board event, so the # declared input is part of the contract: a dispatch naming an undeclared # input is refused, and the trigger job goes loudly red. workflow_dispatch: inputs: bootstrap: description: Bootstrap the label taxonomy before sweeping type: choice options: ["yes", "no"] default: "yes" permissions: contents: read checks: read # mergeability/check-rollup read for PR state statuses: read # commit-status rollup read for PR state actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60) issues: write pull-requests: write jobs: sweep: uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ with: # Pass the dispatch input through the workflow_call boundary # explicitly — a called workflow must not rely on reading the caller's # event inputs (ceremony#215 measured that failing). Empty (schedule) # maps to "no" explicitly, so a cron-woken sweep never re-upserts the # taxonomy. bootstrap: ${{ inputs.bootstrap || 'no' }} # If this repo's PR-facing labels caller is named anything but `labels`, # pass that name alongside: `pr_workflow_name: `. The sweep exports # it as SELF_WORKFLOW so the label machinery's own check entries (scope, # trigger) never count toward blocker:ci-red — a red trigger means "fix # the caller", which no PR edit can do (#208 reads it). ``` Naming any permission sets every unnamed permission to `none`. Public repositories allow check data to be read regardless, but a private consumer needs the explicit reads above; without them the failure appears as an empty `state:*` axis on the board rather than a red workflow run. The labels caller's `actions: write` is different — it is required everywhere, public repos included: the trigger job's dispatch is a write, and without it every event run goes red at the trigger. **The failure mode to know before bumping**: a consumer that bumps its pin to a #209-carrying tag without adding the sweep caller keeps green-looking silence nowhere — the trigger job goes **red on every PR and issue event** (workflow-not-found; likewise on a sweep caller missing its `bootstrap` input, or a labels caller missing `actions: write`), and event-woken sweeps stop until the caller lands. That loudness is deliberate: never read silence, or a green `scope` alone, as health. Make the adoption one atomic PR — pin bump, sweep caller file, `actions: write` line together. The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt it only by bumping every ceremony reference to `0.2.0` or later; never mix refs to adopt it early. The type list has grown then narrowed across tags: `0.2.0` (ceremony#32) shipped `[opened, labeled, unlabeled, assigned, unassigned, closed]`; `0.3.0` (ceremony#144) added `edited` and `reopened`; ceremony#199 narrows it to `[opened, closed, edited, reopened]` and relaxes the cron to hourly, so a consumer picks up the smaller trigger surface at the pin bump to the first tag carrying ceremony#199. The narrowing drops `labeled`/`unlabeled`/`assigned`/`unassigned` — validation and the 48h claim clock, which the hourly cron catches within one cadence, and `labeled`/ `unlabeled` were the issues-churn source — while **keeping** #144's `edited`/ `reopened`: those carry a queue-state change an event uniquely carries (a body rewrite of `Blocked by #N`, and a closed issue re-entering the queue), so the must-fail in ceremony#199 keeps them on events. `opened` drives the mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal; the stub and ceremony's own caller stay byte-for-byte identical, the parity #144 established. The two-caller split (ceremony#209) is available at `0.4.1` and later. A consumer pinned to `0.4.0` or earlier keeps the previous single-caller shape — the labels caller carrying the cron, `workflow_dispatch`, and `actions: read` — and adopts the split at the pin bump to `0.4.1` or later. Never mix refs to adopt it early. The migration is **one atomic PR** with exactly four edits — crew, the consumer whose displaced-check evidence drove #209 (crew#227, crew#250), is the worked example; written here against `0.4.1`, the first tag carrying the split: 1. **Pin bump, every reference together** ([Version pinning](#version-pinning)): `0.4.0` → `0.4.1` in the labels caller's `uses:` line **and in every other ceremony `uses:` in the repo** — crew also pins in `release.yml` and its `ci.yml` guard steps. A repo on the doctrine mirror re-runs `docs-sync --fix` in the same PR. 2. **New file `.github/workflows/labels-sweep.yml`** — the sweep caller stub above, verbatim, `bootstrap` input included (the trigger's `-f bootstrap=no` dispatch is refused if the input is undeclared). 3. **The hourly cron RELOCATES — it is moved, never copied.** Delete the `schedule:` block (and the bare `workflow_dispatch:`) from the labels caller in the same edit that adds the sweep caller. **Warning**: a consumer that copies the sweep caller and leaves the old schedule on the labels caller gets DOUBLE sweeps — every cron tick fires both callers into the one shared `labels-reconcile` group — so displacement goes **up**, and the fix reads as the bug getting worse. 4. **`actions: write` on the labels caller** — consumers carry `actions: read` today (crew does); the trigger job's dispatch is a write. The sweep caller keeps `actions: read`. Bump without the sweep caller and the trigger job goes red on every PR and issue event — the loud failure mode above — so never split these four edits across PRs. `pull_request_target` is intentional: fork PRs need the base repository's token to write labels. The reusable workflows execute no PR code. They check out only the consumer's base branch and the pinned ceremony implementation. The #52 ruling invariants ride exactly these triggers — but the caller above is no longer the #18 shape, so adopting current triggers is a stub edit, not a bare pin bump. `review_requested` and `review_request_removed` on `pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that clears `blocker:unrequested` the moment the panel is asked, without which a quiet repo wears that flag until the backstop cron; a consumer picks them up by pinning `0.3.0` or later, never through mixed refs. `.github/labels.conf` has one mandatory panel setting, one mandatory `triage-actors` setting, zero or more optional per-author panel rows, and then zero or more scope rows: ```text panel=claude-bot example-codex-bot example-grok-bot panel[example-builder]=example-codex-bot example-grok-bot triage-actors=example-triage-bot scope:cli|C5DEF5|The command-line surface scope:docs|C5DEF5|Documentation ``` The mandatory `triage-actors=` setting is likewise accepted at `0.2.0` and later, and not by `0.1.0`. At that tag the file contains `panel=` plus scope rows only; adding `triage-actors=` is a parse failure, not an ignored setting. Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later — never before it and never through mixed refs. The optional `panel[]=` rows are available at `0.5.0` and later (#224). A row names the effective panel for PRs authored by exactly that login — the reconciler computes that PR's required set from the row, minus the author as always — and every other author keeps the base `panel=`, which stays mandatory. The panel is configured or it is the base one: ceremony never infers a reviewer set from the model behind a login. On any earlier pin a bracketed row is a **parse failure, not an ignored setting** — the same shape `triage-actors=` bought at `0.2.0`, but harsher in practice: the reconcile job dies on every PR event and every sweep until the row is removed, so the whole label board goes down. Add the row only at or after the pin bump that carries it, never before it and never through mixed refs. Both actor lists are whitespace-separated. `triage-actors` names the identities allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly `name|color|description`; blank lines are ignored and extra pipes are refused. **Every account in `panel=` must be able to read the repository.** Requesting a review from someone without read access is refused by the forge, not silently dropped — on Forgejo with `422 Reviewer can't read`, naming the account (#188). On a public repo this is satisfied already; on a **private** consumer it is a real failure mode when a panel member is not on the collaborator list, and the sweep will report it rather than sweep blind. There are no comment lines: every non-blank line must be the `panel=` setting, a `panel[]=` row, the `triage-actors=` setting, or a label row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's conversion found this the hard way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository. After adding the callers and configuration, dispatch the sweep caller once to bootstrap labels on a fresh repository. A bare dispatch is also the operator's general manual full-board sweep — the answer when the board looks wrong now rather than after the next scheduled cadence: On GitHub, with the `gh` CLI: ```sh gh workflow run labels-sweep.yml -R / ``` On any forge — including Forgejo, whose runners carry no `gh` — the same dispatch over REST, which is what the trigger job itself sends (#205): ```sh curl -sS -X POST \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"ref":"main","inputs":{"bootstrap":"yes"}}' \ "$API/repos///actions/workflows/labels-sweep.yml/dispatches" ``` `$API` is the forge's API root — `https://api.github.com` on GitHub, `/api/v1` on Forgejo — and success is `204` with an empty body. Ceremony dogfoods the callers under the filenames `self-labels.yml` and `self-labels-sweep.yml`, so the equivalent command in this repository substitutes that filename. Scheduled and trigger-driven runs only reconcile; they do not repeatedly upsert the taxonomy (the trigger's dispatch carries `bootstrap=no`). When a ceremony pin bump adds a core label, bump the pin first and then re-dispatch; the scheduled sweep warns when the pinned taxonomy declares a core label the repository lacks. ## Doctrine mirror Machinery is consumed by reference — GitHub fetches the workflows and actions above from the pin at run time — but documents have no runtime: an agent reads the working tree it stands in. So the agent-facing doc set declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**, byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md` marking the directory machine-managed. `actions/docs-sync` owns the copy: `--fix` writes it (and deletes what the manifest dropped — mirror means mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing. `RELEASES.md` joins that mirror with the first tag carrying ceremony#248, and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by copying it ahead of their pinned doctrine set. ### Read the manifest, never a copy of it Anything on the consumer's side that needs to know *which* documents are vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names the files itself. The manifest is available at the pinned ref from `0.1.0` and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the same commit, and that tool has read it rather than a list since — and it is one path per line, relative to ceremony's root, blank lines ignored: ```sh # the vendored doc set at the ref this repo is pinned to curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony//docs/VENDORED.txt" ``` That is the whole benefit: a doctrine file added in ceremony — `RELEASES.md` was the last, ceremony#248 — reaches every consumer at its next **ordinary pin bump**, with **zero list edits** anywhere. A hardcoded list propagates nothing, and its staleness is silent rather than red: `docs-sync --check` asserts byte-identity for the files the list names and says nothing at all about one it omits, so a consumer keeps a green guard while governing itself with doctrine it no longer has. What makes reading the manifest *sufficient* — rather than merely better than a copy — is that ceremony's CI now refuses a root doctrine file that is declared in neither the manifest nor a short in-script exemption list (`.github/scripts/vendored-check.sh`), so the manifest at a tag is the complete set as of that tag. That guarantee holds at `0.6.0` and later (#251); the manifest is worth reading at every earlier pin regardless, since it is what `actions/docs-sync` has always mirrored. The consumer's ci.yml gains the guard alongside the others: ```yaml - uses: actions/checkout@v4 - uses: heavy-duty/ceremony/actions/docs-sync@ ``` `mode` defaults to `check`. There is no ref input: the action reads the pin from the consumer's own `.github/workflows/release.yml` — the same single `uses: …/release.yml@` line that pins the machinery, so one pin governs machinery and doctrine alike, and a second pin cannot fall out of sync. **Bootstrap on adoption**: add the release and labels callers first (the pin must exist — the mirror is verified against it), then run `--fix` once from the repo root and commit `.ceremony/` together with the callers: ```sh curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony//actions/docs-sync/docs-sync.sh" \ | bash -s -- --fix ``` If the repo has no root `AGENTS.md`, `--fix` also scaffolds the thin stub that routes agents to `.ceremony/AGENTS.md` — created once, never overwritten; it is per-repo content the moment you edit it, so `--check` asserts only that it exists. Bumping the pin re-syncs the mirror in the same PR — [the pin-bump procedure](#the-pin-bump-procedure). ## Version pinning **Two ceremonies answer to the same version number.** `heavy-duty/ceremony` exists on GitHub and on `forgejo.heavyduty.builders`, and the forge tree tracks upstream's version numbers deliberately (ceremony#197 D2) — so `0.6.0` names a different tree on each, differing by the forge-compatibility delta. They are not forks that drifted: the forge tree carries upstream's content and adds to it (`docs/UPSTREAM-SYNC.md`). What that means for a consumer: - **Name the forge you pinned, not just the tag.** `heavy-duty/ceremony@0.6.0` is ambiguous on its own; the host in your `uses:` line is what disambiguates it, so do not describe your pin anywhere without it. - **A tag that exists upstream may not exist here yet.** The forge tree's `CEREMONY_SELF_REF` takes upstream's number as soon as the sync lands, which is *before* the release ceremony cuts that tag here. Do not bump a pin to a version whose tag you have not confirmed on the forge you consume from. - **The forge tree's `CHANGELOG.md` header names the upstream commit it carries**, and `.upstream-ref` records the same SHA. That is how you tell which `0.6.0` you are actually running. - **Pin an exact ceremony release tag** — `@0.1.0`, never a branch and never a moving major pointer: the family pins things and reviews updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)). Every `uses:` of this repo in the consumer — the two workflow callers and the guard steps — names the same tag. - **Bump by PR, every reference together.** Before bumping, read the ceremony's own `CHANGELOG.md` section for the new version (the release body on its [releases page](https://github.com/heavy-duty/ceremony/releases) is that section, verbatim). One bump PR updates **every** ceremony `uses:` reference in the repo to the new tag — the workflow callers *and* each guard step. The exact count is tag-dependent: it is the workflow caller or callers plus the guards that the pinned tag carries. Changing only one line leaves the consumer split across ceremony versions, which the same-tag rule above forbids. A repo that has adopted the agent team flow additionally bumps the mirror in the same PR — [the pin-bump procedure](#the-pin-bump-procedure). - **One pin governs machinery and doctrine.** The ref in the consumer's `release.yml` `uses:` line is the single pin: `docs-sync` reads it from exactly there and verifies the `.ceremony/` mirror against it — there is no second pin to fall out of sync (#19). ## The changelog rule The portable version of the family's contributor rule — the repo's own CONTRIBUTING may sharpen it, but this is the floor the guards assume: - **Every PR that changes behavior writes one fragment**: `changelog.d/.md`, named for the authorizing issue — `-.md` for cross-repo work carrying `Part of #N` — so the name is known at claim time and two builders can only collide by working the same issue (#112 D2). Never an edit to `CHANGELOG.md`: the release PR assembles the section ([below](#assembling-a-release-section)). The sole exception is the release PR: it writes no fragment. It consumes the directory and stamps the section, so a fragment it created would be absent from [`changelog-assembled`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-assembled/changelog-assembled.sh)'s merge-base replay if consumed, or refused by [`changelog-armed`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-armed/changelog-armed.sh) if left to survive into the next release. A change that must ship inside the release PR therefore ships without an entry. If it can wait and wants an entry, land it as an ordinary PR before the release PR, then rebase and re-assemble the release. - **The fragment is the prose, not a description of it** (#112 D3): the exact lines that will be published — no front-matter, no `## ` heading (that one is the assembler's to write). `changelog-armed` refuses a malformed fragment on the PR that wrote it. - **Grouped repos group inside the fragment**: `### Added`, `### Changed`, `### Fixed` headings with bullets under them; create `Deprecated`, `Removed`, or `Security` only when a change genuinely needs that rarer kind. A repo is grouped or flat, never both (#112 D4). The assembler merges groups in canonical order — Added, Changed, Fixed, Removed, Deprecated, Security, then anything else first-seen — and inside a group entries read newest issue first (#112 D5). Which shape binds is inferred from the newest published section, unless an optional sentinel `changelog.d/shape` — one line, exactly `flat` or `grouped` — declares it and outranks the inference (#182). To flip a repo's shape, land one PR that adds the sentinel and converts every pending fragment to the declared shape, bullets byte-identical; the sentinel stays after the release, as the declaration a reader in the directory finds. - **One line: say what changed, and stop.** Lead with the surface, not the mechanism — "`state:needs-human` is set at handoff" beats "the labels workflow now also wakes on `labeled`". The why and the how belong in the PR body, where anyone chasing the reasoning already goes. - **Cite the issue or PR** — `(#141)`. - **Mark a breaking change** with a leading `BREAKING:`. - A repo not yet on fragment mode — no `changelog.d/` — keeps the legacy floor until its conversion: one line under `## Unreleased`, inserted **above** the heading below it, never over it (replacing a shipped heading deletes that release's section silently — box#122, why the [monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only) exists), appended under a standing `### ` heading where the repo groups. ## Assembling a release section The ceremony PR's changelog stamp is one command, run **by hand, never in CI** — the assembled section must land in the release PR's diff, where the panel reads it (#112 D12). A consumer runs the tool from a ceremony checkout at its own pin: ```sh git clone --depth 1 --branch https://github.com/heavy-duty/ceremony /tmp/ceremony /tmp/ceremony/bin/changelog-assemble ``` Run it at the repo root. It folds every `changelog.d/` fragment into a new `## X.Y.Z — DATE` section on top of `CHANGELOG.md` (DATE is today's UTC date; pass one as a second argument to choose it) and deletes the fragments it consumed — commit both halves together. `--check` prints the would-be section body without touching anything; read it before running the real thing. In CI, `changelog-assembled` replays the run from the merge base and refuses a stamp that is not byte-for-byte what the fragments assemble to — a mis-run hand step fails the PR, not the published release. ## Adopting the agent team flow The team flow (discussion → triage → issue → build → review → human merge) is **optional per repo and separable from the release ceremony**: a repo can adopt release-only and take the team flow later — incubator's initial posture (#16). The model is this repo's own [CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this"); this is the checklist: - [ ] **Enable Discussions** — the triage door exists or the pipeline has no intake. - [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize `.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md` — byte-identical to this repo at the pinned ref — plus the generated `.ceremony/README.md` (machine-managed marker) and, if the repo has none, the thin root `AGENTS.md` stub ("governed by heavy-duty/ceremony; read `.ceremony/AGENTS.md` first; repo specifics in CONTRIBUTING"). The stub is scaffolded once and never overwritten; the mirror is machine-written and never hand-edited. Commit `.ceremony/` together with the workflow callers. - [ ] **Guard the mirror in CI**: add the `docs-sync` check step alongside the other guards — ```yaml - uses: heavy-duty/ceremony/actions/docs-sync@ ``` (`mode: check` is the default.) Hand-editing a vendored file, or bumping the pin without re-syncing, goes red (#19). - [ ] **Reduce tool-specific files** (`CLAUDE.md`, …) to one pointer line at the root `AGENTS.md`, so every harness converges on the same router. - [ ] **Point CONTRIBUTING at the mirror**: a short header telling agents to read `.ceremony/` first — agents never leave the working tree to read the rules — followed by only what is genuinely per-repo: the review panel roster, the `scope:*` set, the drill meaning, the repo's code conventions. - [ ] **Name the review panel**: the roster table in CONTRIBUTING and the `panel=` line in `.github/labels.conf` — the required verdicts for any PR are the panel minus its author (#10). - [ ] **Bootstrap the issue-flow labels**: the labels `workflow_dispatch` once ([above](#labels-automation)), or the hand commands in [LABELS.md](../LABELS.md). - [ ] **State the single-writer rule** in the repo's own docs: only triage mints issues; everyone else opens discussions. ### The pin-bump procedure Bumping the ceremony pin is **one PR carrying both halves**: every ceremony `uses:` reference — the workflow callers *and* each guard step, [all to the same new tag](#version-pinning) — and the re-synced `.ceremony/` mirror — run `docs-sync --fix` locally, or let the red `--check` on the bump PR tell you what is stale. The CI guard is what makes a half-done bump — pin without mirror, or mirror without pin — unmergeable (#19). This is how a process change rolls out to a governed repo: deliberately, per repo, reviewed.