22 KiB
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; 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
maindefault branch by PR; fork PRs are fine — the merge door ridespushtomain, neverpull_request(release.yml, box#97), and the label read goes through the API (lib/facts.sh), so the ceremony never needs the PR's own context. No PAT, no secrets: every permission the flow uses is the caller-declaredGITHUB_TOKENgrant. -
Pick the version backend:
file(aVERSIONfile — box, rig, incubator) orpackage-json(theversionfield, 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 ("The configuration axes"). -
The
releaselabel must exist before the first ceremony PR — it is the merge door's declared-intent read (lib/facts.sh). Bootstrap it via the labels workflow'sworkflow_dispatch(Labels automation), or create it by hand, matching the core table (actions/labels-reconcile/labels-reconcile.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:
-
VERSIONatX.Y.Z-dev— never bare. A first version that never carried-devhits the decide table's refuse row and has to ship by the tag door (the known first-release edge, cast#111; lib/decide.sh). Bootstrapping at-devkeeps the repo clear of it entirely. (package-jsonbackend: theversionfield, same rule.) -
An armed
CHANGELOG.md: a preamble plus an empty## Unreleasedsection for the first entries to land under. -
drills/README.mddefining what a drill means in this repo — each repo names its own (the drill doctrine). Plaindrills, not a dot-directory (drill-recorded.sh). -
.github/workflows/release.yml— the caller, verbatim from Release workflow below. -
CI guard steps in the repo's
ci.yml:- uses: actions/checkout@v4 with: # changelog-monotonic compares 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@<pinned-tag> - uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag> - uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag> # Unreleased: runner-isolated is not in 0.1.0. Adopt this step with # the pin bump to the first tag that carries it; never mix refs. - uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>changelog-armedanddrill-recordedtakeversion-source: package-jsonwhere that is the backend; every guard's inputs and defaults are in itsaction.yml(actions/). Adopting the agent team flow adds thedocs-syncstep (below).runner-isolatedasserts that nopull_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). It fires on the PR that first mixes a PR trigger and a self-hostedruns-onin 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. New machinery is marked unreleased here until a release tag ships it. 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.0carries the three release guards above plusdocs-sync, but notrunner-isolated. -
Labels automation (optional but recommended): the caller from Labels automation, plus
.github/labels.conf(panel + the repo'sscope:*rows) and.github/labeler.yml(the path→scope globs). Runworkflow_dispatchonce — this bootstraps the taxonomy,releaselabel included. -
The artifact hook (optional):
.github/actions/release-artifact/per The artifact hook. No hook → the source tarball is the package.
From there the flow is the doctrine: ordinary PRs add their changelog line, the ceremony PR makes the three stamps, 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).
- Replace
.github/workflows/release.ymlwith the caller from Release workflow — whole file, keeping its load-bearing comments. Check the result has onepush:key carrying both filters: YAML maps are last-key-wins, and a second siblingpush:silently kills a door (rig's review catch). - Swap the guard script steps in
ci.ymlfor theuses:steps in the bootstrap list above (withfetch-depth: 0on the checkout). - Replace
labels.ymlwith the caller from Labels automation; extract.github/labels.conffrom the old reconciler's embedded config — thepanel=roster line and the repo'sscope:*rows (the format)..github/labeler.ymlstays as it is (path globs are inherently repo-specific). - Delete the now-shadowed copies — zero shared scripts remain:
.github/scripts/release-notes.sh(box, cast) orrelease-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 oftest/release.sh, cast'sinstall-shtests). A machinery test file goes whole when its subject moved (rig'stest/labels-reconcile.shsourced the deleted reconciler), and so do tests that pin the old workflow's shape — a grep or awk againstrelease.yml/ci.ymlinternals 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.mdand any labels doc typically cite the old.github/scripts/*.shby path; repoint them at the pinned actions. A repo carrying its own copy of a doc the mirror vendors (rig's rootLABELS.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 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. - What stays, per repo, forever:
VERSION(or thepackage.jsonversion),CHANGELOG.md,drills/,.github/labeler.yml,.github/labels.conf, the optional.github/actions/release-artifact/— the full kept-vs-moved table is in #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:
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@<pinned-tag>
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 gh release create —
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), or run gh release create by hand from
a fixed tree. 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 reusable labels workflow owns two independent jobs: additive path-based
scope:* labels and reconciliation of PR state, blockers, handoff, stale
status, 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). The consumer keeps its path mapping in
.github/labeler.yml and its review panel plus scope taxonomy in
.github/labels.conf.
The complete caller is:
name: labels
on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
# Unreleased — not in 0.1.0; add only with the first tag carrying ceremony#32.
issues:
types: [opened, labeled, unlabeled, assigned, unassigned, closed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
The issues: trigger is unreleased and is not in 0.1.0. A consumer
pinned to 0.1.0 omits it. Add it only when bumping every ceremony reference
to the first tag carrying ceremony#32; never mix refs to adopt it early.
pull_request_target is intentional: fork PRs need the base repository's
token to write labels. The reusable workflow executes no PR code. It checks
out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — the caller above is
unchanged since #18, so adopting them is a pin bump, not a stub edit.
.github/labels.conf has one mandatory panel setting, one mandatory
triage-actors setting, and then zero or more scope rows:
panel=claude-bot 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 also unreleased and is not
accepted 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, to the first tag carrying
ceremony#32 — 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.
There are no comment lines: every non-blank line must be the panel=
setting, 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 caller and configuration, run workflow_dispatch once to
bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only
reconcile; they do not repeatedly upsert the taxonomy.
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
(ceremony's docs/VENDORED.txt: AGENTS.md, TRIAGE.md, BUILDER.md,
REVIEWER.md, LABELS.md) 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.
The consumer's ci.yml gains the guard alongside the others:
- uses: actions/checkout@v4
- uses: heavy-duty/ceremony/actions/docs-sync@<pinned-tag>
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@<ref> 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:
curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony/<pinned-tag>/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.
Version pinning
- 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). Everyuses: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.mdsection for the new version (the release body on its releases page is that section, verbatim). One bump PR updates every ceremonyuses: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. - One pin governs machinery and doctrine. The ref in the consumer's
release.ymluses:line is the single pin:docs-syncreads 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 adds one line under
## Unreleased. - Insert above the heading below — never type over it. Replacing a
shipped
## X.Y.Zheading with your entry deletes that release's section, silently; this exact edit is why the monotonic guard exists (box#122). - One line: say what changed, and stop. Lead with the surface, not
the mechanism — "
state:needs-humanis set at handoff" beats "the labels workflow now also wakes onlabeled". 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:. - Group under
### Added/### Changed/### Fixed/### Removed.
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 ("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 rootAGENTS.mdstub ("governed by heavy-duty/ceremony; read.ceremony/AGENTS.mdfirst; 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-synccheck step alongside the other guards —```yaml - uses: heavy-duty/ceremony/actions/docs-sync@<pinned-tag> ``` (`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 rootAGENTS.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, thescope:*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_dispatchonce (above), or the hand commands in 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 — 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.