forked from heavy-duty/ceremony
783 lines
41 KiB
Markdown
783 lines
41 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
|
|
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/<issue>.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@<pinned-tag>
|
|
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
|
|
# 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@<pinned-tag>
|
|
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
|
|
# 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@<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.
|
|
|
|
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 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@<pinned-tag>
|
|
```
|
|
|
|
`refs-not-closing` is **unreleased** (#218) until the first tag carrying it.
|
|
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/<issue>.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@<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 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 (`gh workflow run`, 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 `gh workflow run` dispatch of the sweep caller (#209)
|
|
issues: write
|
|
pull-requests: write
|
|
jobs:
|
|
labels:
|
|
uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
|
# If the sweep caller below is named anything but labels-sweep.yml,
|
|
# say so: `with: { sweep_workflow: <filename> }`. 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@<pinned-tag>
|
|
# If this repo's PR-facing labels caller is named anything but `labels`,
|
|
# pass that name: `with: { pr_workflow_name: <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 `gh workflow run` 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 `gh workflow run`
|
|
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[<login>]=` 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.
|
|
There are no comment lines: every non-blank line must be the `panel=`
|
|
setting, a `panel[<login>]=` 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:
|
|
|
|
```sh
|
|
gh workflow run labels-sweep.yml -R <owner>/<repo>
|
|
```
|
|
|
|
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.
|
|
It is **unreleased** (#248) until that tag exists: consumers add
|
|
`.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by
|
|
copying it ahead of their pinned doctrine set.
|
|
|
|
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. 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/<issue>.md`, named for the authorizing issue —
|
|
`<repo>-<issue>.md` for cross-repo work carrying `Part of <repo>#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 <pinned-tag> https://github.com/heavy-duty/ceremony /tmp/ceremony
|
|
/tmp/ceremony/bin/changelog-assemble <X.Y.Z>
|
|
```
|
|
|
|
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@<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.
|