ceremony/docs/CONSUMERS.md

423 lines
20 KiB
Markdown

# 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
workflow'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.md`**: a preamble plus an empty `## Unreleased`
section for the first entries to land under.
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 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>
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
```
`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.
6. **Labels automation** (optional but recommended): the caller from
[Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
path→scope globs). Run `workflow_dispatch` once — **this bootstraps
the taxonomy, `release` label included**.
7. **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 add their changelog
line, 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).
- [ ] Replace `labels.yml` with the caller from
[Labels automation](#labels-automation); 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).
- [ ] 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`, `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@<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, and stale
status. 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:
```yaml
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]
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>
```
`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.
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, and then zero or more scope rows:
```text
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
```
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:
```yaml
- 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:
```sh
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](#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](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; a release-only setup already has five (the
[release caller](#release-workflow) plus the
[four CI guards](#bootstrap-a-new-repo)), and 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 adds one line** under `## Unreleased`.
- **Insert above the heading below — never type over it.** Replacing a
shipped `## X.Y.Z` heading with your entry deletes that release's
section, silently; this exact edit is why the
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only)
exists (box#122).
- **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:`.
- 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](../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@<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 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.