From bb3bbf150a2ed7472e3f1f4447feaa66970b7614 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 22:36:54 +0000 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20README=20=E2=86=92=20README.md=20?= =?UTF-8?q?=E2=80=94=20the=20doctrine=20document=20gets=20its=20real=20nam?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #12 writes the doctrine into README.md; the extensionless placeholder was the 12-byte stub triage's readiness note called out. Co-Authored-By: Claude Fable 5 --- README => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README => README.md (100%) diff --git a/README b/README.md similarity index 100% rename from README rename to README.md From 4363f5ad228c9ac819246cb8e5377efbe657ba8d Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 22:43:59 +0000 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20README.md=20=E2=80=94=20the=20doctr?= =?UTF-8?q?ine,=20moved=20here=20once=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a release is (the three stamps, box#108's two-edit rule), the two doors, the decide table rendered for operators, the three guards with their incidents, the drill doctrine, the verbatim refusal catalog with cause and remedy, and the design lineage. Condensed from the box/rig/cast CONTRIBUTING essays per #1 D8; every workflow-behavior claim links the line or the spec issue. Co-Authored-By: Claude Fable 5 --- README.md | 386 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 385 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d1f70c..96aeaf4 100644 --- a/README.md +++ b/README.md @@ -1 +1,385 @@ -## ceremony +# ceremony + +One release ceremony for the whole heavy-duty family — implemented once, +tested once, documented here, consumed everywhere else by reference. The +approach and its constraints live in +[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the +operator-facing doctrine that used to live, three times over, in the +consumers' CONTRIBUTINGs. + +- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md). +- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you; + [CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics. +- **Operating a release, or staring at a red run on main** → read on. + +## What a release is + +**A release is a PR, and merging it ships it** (box#96, building on box#83; +rig#47, cast#111 converged on the same doctrine). The ceremony PR — +`release: X.Y.Z`, carrying the hand-set `release` label — makes three +stamps: + +1. **The version goes bare**: `X.Y.Z-dev` → `X.Y.Z` + ([lib/version.sh](lib/version.sh)). +2. **The changelog is stamped *and re-armed* — two edits, not one** + (box#108). `## Unreleased` becomes `## X.Y.Z — DATE`, and an **empty + `## Unreleased` goes back on top**, immediately above it: + + ```markdown + ## Unreleased + + ## 0.7.1 — 2026-07-19 + + ### Fixed + ... + ``` + + The second edit is not cosmetic and not deferrable. Between the stamp + and the next re-creation of that heading, main has no `## Unreleased`. A + PR authored *before* the release wrote its entry under that heading; + with the heading gone, git lands the entry under whatever now occupies + the position — **the section that just shipped** — and it merges + cleanly, no conflict, no signal. The changelog then credits a released + version with a change it does not contain, and nothing but a human + reading the file will ever say so (box#108; confirmed cross-repo as + rig#66). The [armed guard](#changelog-armed--main-never-sits-disarmed) + exists because of exactly this edit. + +3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the + evidence the release rests on + ([the drill doctrine](#the-drill-doctrine)). + +(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the +ref consumers' runs fetch this repo at — moves to the version being +released, in [release.yml](.github/workflows/release.yml#L123-L132) and +every other workflow that carries it. +[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not +a consumer's release, when it is stale.) + +**The merge is the ship decision; the tag is transcription.** After the +merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its +way to certainty, tags the merge commit, publishes the GitHub release with +the version's own changelog section as the body — the curated prose, never +the generated PR list +([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) — +and re-arms main by bumping to `X.Y.(Z+1)-dev` +([release.yml](.github/workflows/release.yml#L266-L300)). The machine does +the transcription because humans err silently and machines fail loudly: +**everything asserts its way to certainty and fails loudly, creating +nothing** — a wrong release is worse than a missing one, so every failed +assert leaves zero artifacts: no tag, no release, no bump. + +## The two doors + +- **The merge door — the paved road.** A push to main + ([release.yml](.github/workflows/release.yml#L140)) runs the + [decide table](#what-happens-when-my-pr-lands-on-main); a merged, + `release`-labeled PR whose version transitioned to bare is the ceremony, + everything legitimate that isn't one is a green no-op, and every + half-ceremony dies loudly. Use it for every normal release. + +- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag + push — **no `v` prefix**, box's 0.6.0 set the scheme + ([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the + same way. The tag is the operator's explicit act, so there is no decide + and no label check; the one assert is that **the tag names the tree's + own version**, and a mismatch refuses, creating nothing. No `-dev` bump + either — the fallback does not rewrite main (cast's precedent). Use it + when the merge path is red, for backfills, and for the + [first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4). + +Tag + publish (+ the consumer's artifact hook) happen **in the same job, +on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's +anti-recursion), so the merge door's tag can never re-enter the tag door +and double-publish — and that job is the release's only chance to publish +([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2). + +## What happens when my PR lands on main + +The merge door runs on **every** push to main, and the `release` label +legitimately means two things (release ceremonies, and ordinary work *on* +the release machinery), so the door's first act is a decision: the 5-state +table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment +block *is* the spec, and the table is contract-tested offline). Rendered +for operators: + +| # | the tree your merge produced | the run | what it means — and your move | +|---|---|---|---| +| 1 | version `-dev`, unchanged | green `NOTICE`, no-op | Almost every PR — including release-flow work under the `release` label. Nothing to publish, nothing to do. | +| 2 | version changed, still `-dev` | green `NOTICE`, no-op | The post-release bump, or a renumber. "A dev tree is by definition not a release." Nothing to do. | +| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. | +| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). | +| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. | +| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. | + +The green rows are the point as much as the red ones: the machinery must +be safe to work on, so every legitimate non-ceremony is a green `NOTICE` +no-op — never a red run on main per infra PR +([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and +automation never guesses; the version transition is the interlock, and +label-without-transition (row 4) and transition-without-label (row 5) both +refuse (#1 constraint 8). + +## The guards + +Three composite actions run in every consumer's CI (and in this repo's +own). Shared shape: version-keyed where the tree's state matters, loud +where it fails, and **a file of its own so a test can drive it**. The full +war stories are in the scripts' header comments — authoritative and longer +than this; what follows is the operator's cut. + +### changelog-armed — main never sits disarmed + +**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh#L27-L36)), +keyed on the tree's version: + +- `-dev` tree → the top section **must** be `## Unreleased`. +- bare tree (the ceremony PR and its merge) → the top section may be + `## Unreleased` (re-armed) *or* the stamped section for exactly that + version — **and** that version's section must exist and carry prose, + because it is the one about to ship (the half-ceremony refusal, rig#67: + version bumped, stamp missing — asserted through the very extractor the + publisher uses, so the two cannot disagree about what a section is). + +**The incident**: box#108 / rig#66 — the silent mislanding described +[above](#what-a-release-is). **Red means** a PR entry has nowhere safe to +land; **the fix** is to re-arm: add an empty `## Unreleased` above the top +stamped section. + +**Do not "simplify" this to "always require `## Unreleased`".** The +unconditional form is false by construction on the ceremony PR's own tree +— it makes every release unshippable — and rig#44 and cast#108 both had +to revert exactly that +([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)). +The version-keyed form is what rig and cast get back by adopting this repo. + +One consequence worth knowing before it happens: a ceremony PR that stamps +and forgets to re-arm still passes this guard — a bare tree is allowed to +be stamped. It goes red **the moment the automatic `-dev` bump lands on +main** ([the script](actions/changelog-armed/changelog-armed.sh#L37-L42)). +The guard does not block the release; it refuses to let main *sit* +disarmed, which is the window a late PR falls into. + +### changelog-monotonic — shipped headings are append-only + +**The rule** +([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)): +the set of `## X.Y.Z` headings on your branch must be a **superset** of +the set at the merge base, and no heading may appear twice on HEAD. The +rule needs no tuning because release headings are append-only by doctrine: +the ceremony adds one and nothing ever legitimately removes one — so +superset has no exception to carve. The ceremony's own stamp passes by +construction: rewriting `## Unreleased` into `## X.Y.Z — DATE` adds a +heading and removes none (`Unreleased` is not a version heading; it is +[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business). + +**The incidents**: box#122 (caught in review of box#118) — an author +adding an entry under `## Unreleased` **replaced** the heading below it +instead of inserting above it; git merges that cleanly, and the shipped +section's body is silently absorbed into `## Unreleased`. And box#118 +itself — a bad rebase *duplicated* a shipped heading, which containment is +blind to, which is why uniqueness-on-HEAD is a separate assert +([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)). + +**Red means** a shipped section was deleted (put the heading back and +insert **above** it) or duplicated (collapse to one heading; the failure +message walks through both fixes with the diff to run). **This guard needs +history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an +unresolvable base is a hard failure, not a skip — a guard that can quietly +stop guarding is the failure shape this family of checks exists to refuse +([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)). + +### drill-recorded — a release carries its evidence + +**The rule** +([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)), +keyed on the tree's version: a `-dev` tree passes with nothing to assert +(a development tree ships nothing); a bare tree — the ceremony PR and its +merge — must carry `drills/.md` with at least one +non-whitespace character. One file per version, so `0.9.0.md` and +`0.9.0-rc1.md` are simply different files and prefix confusion is +unrepresentable (#1 constraint 7). + +**The incident**: box's CONTRIBUTING said since box#96 that the release +ritual must be run and recorded. No release ever did it — box#95, box#114 +and box#148 all shipped as a version bump plus a changelog stamp, because +the gate was a sentence in a document and the only thing standing on it +was a reviewer remembering to ask. The rule moved into CI, where it fires +whether or not anyone is paying attention. + +**Red means** the release is asserting a ritual it left no evidence of. +**The fix is to run the drill** and record it — or to waive it *in +writing* at the same path: the guard demands a **record, not a passing +result** ([below](#the-drill-doctrine)). + +## The drill doctrine + +**Evidence, not success.** The guard asserts a record exists — a failed +drill honestly written down satisfies it, and so does a maintainer waiver +that says plainly the drill was waived and why. What it refuses is +silence: a skip must cost a deliberate, reviewable file in the diff, +which is precisely what box's three silent skips never produced. CI +cannot run a consumer's drill (box's wants real hardware and the better +part of an hour); it can only refuse a release that never ran one. + +**Each repo defines what its drill *means*** — the gate only reads the +record. box asserts the **isolation contract**; rig asserts +**convergence** (a machine reaches its role, idempotently); cast asserts +**promotion** (A→B reproduces, the diff is idempotent); ceremony's own +drill is a **door rehearsal** — both doors exercised end-to-end on a +disposable repo (#11 names the six probes); incubator's is TBD in +heavy-duty/incubator. Each repo states its meaning in its own +`drills/README.md`. Three different exercises sharing a substrate is why +the records are per-repo — they are not phases of one script. + +**Drills exercise candidate refs, not released artifacts.** A ref is a +static identifier that exists as soon as the release branch does, so no +repo has to be released — or drilled — before another can be drilled: +what looks like a box↔rig recursion at runtime dissolves into two +independent tests against one fixed pair of refs. And drilling the +candidate *is* drilling the release: a ceremony PR's diff is the stamps +and nothing else, so no executable byte differs between the tree that was +drilled and the tree that ships. + +**A cross-repo release set shares one run ID.** Each repo records its own +legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling +SHAs, so the records reconcile afterwards — but the guard only ever reads +the repo it runs in. If a defect shows up only in the combination: patch, +re-drill, re-record. The set converges; it is not required to be right in +one pass. + +## Troubleshooting red main + +Every refusal the release flow can emit, verbatim, with cause and remedy. +The catalog is generated from the sources, not paraphrased — regenerate +it with: + +```sh +grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml +``` + +`$VER`-style variables appear as the run interpolates them. + +### The decision refused ([lib/decide.sh](lib/decide.sh)) + +> the version '$VER' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing. +> (If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. A first release whose version never carried -dev ships by the tag door — the known first-release edge.) + +Row 4 ([L129–L133](lib/decide.sh#L129-L133)). The message is the remedy: +drop the label, or re-do the ceremony with the bump, or take the tag door. + +> the version transitioned ('$BASE_VER' -> '$VER') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing. + +Row 5 ([L147–L149](lib/decide.sh#L147-L149)). Someone pushed or merged a +version transition without the `release` label. Label a proper ceremony PR, +or — if the tree is genuinely the release — publish by the tag door. + +> VER is empty — the caller failed to establish the version at the pushed head. Refusing to decide — creating nothing. +> BASE_VER is empty — the caller failed to establish the version at the base. Refusing to decide — creating nothing. +> RELEASED='${RELEASED}' — expected yes, no, or empty. Refusing to decide — creating nothing. +> LABELED='${LABELED}' — expected yes, no, or empty. Refusing to decide — creating nothing. +> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing. +> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing. + +The fact-gathering guards +([L92–L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135), +[L151](lib/decide.sh#L151)): a missing fact must never fall through to +"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the +workflow plumbing, not an operator mistake — read the run's `facts:` +stderr line and file what you find. + +### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh)) + +> facts: unknown VERSION_SOURCE '$VERSION_SOURCE' — expected file or package-json + +[L37](lib/facts.sh#L37): the caller's `version-source:` input is neither +`file` nor `package-json`. Fix the caller. + +> version_read: $path: no such file +> version_read: $path is empty +> version_read: $path: no version field +> version_read: node is required for version-source: package-json + +[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is +missing, empty, or unreadable. A wrong release is worse than a missing +one, so an unreadable state is never an empty print — restore the +`VERSION` file (or `package.json` version field) on main. + +### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300)) + +> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release + +[L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged +without its stamp (a state the +[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses +on the PR — red main here means it was overridden). Stamp the section on +main, then publish by the tag door. + +> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. +> release '$VER' already exists — refusing to re-release, creating nothing. + +[L207–L222](.github/workflows/release.yml#L207-L222), the nothing-exists +assert — what makes a re-run of a completed ceremony refuse instead of +clobber, and what catches a manual tag racing the merge. If the release +truly exists, there is nothing to do: this red is the system declining to +do the thing twice. If the tag exists but the release does not (a manual +tag won the race, or +[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by +the tag door: delete and re-push the tag, or `gh release create` by hand +from a fixed tree. + +> direct push refused (branch protection?) — opening the bump PR instead + +[L292–L300](.github/workflows/release.yml#L292-L300) — loud, but not a +refusal: the post-release `-dev` bump could not push directly, so the run +opened a `release`-labeled bump PR itself. Your move: merge it promptly — +until it lands, main is sitting bare, where a dev install +[impersonates the release](.github/workflows/release.yml#L291) and the +[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays +open. + +### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369)) + +> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing. +> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit. + +[L333–L337](.github/workflows/release.yml#L333-L337). The message is the +remedy. + +> CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release + +[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was +never stamped. Stamp first, then delete and re-push the tag. + +### Red main that is not the release workflow + +Consumer CI runs its guard steps on pushes to main too (this repo's +[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an +operator will actually meet on main is +**changelog-armed after a re-arm was forgotten**: the ceremony stamped +without putting `## Unreleased` back, the release's own `-dev` bump +landed, and the guard now says (first line): + +> changelog-armed: the version is '$ver' (a development tree) but the top +> section of $changelog is: … + +The fix is a one-line PR: add an empty `## Unreleased` above the stamped +section. The full message +([the script](actions/changelog-armed/changelog-armed.sh#L87-L101)) +carries the same instruction. + +## Design lineage + +The ceremony converged across box#83 → box#96, rig#32 → rig#47, and +cast#96 → cast#111; this repo is those three implementations folded into +one (the drift that motivated it is measured in +[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing +constraints — each bought with an incident, none of them safe to +"simplify" away — are listed in +[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with +their war stories, in the headers of the scripts they bind: +[release.yml](.github/workflows/release.yml#L1-L109), +[lib/decide.sh](lib/decide.sh#L1-L74), +[lib/facts.sh](lib/facts.sh#L1-L24), and the three +[guard scripts](actions/). The comments are the documentation of record; +this README is their operator-facing cut. From 79261aa79341e48a98754f2c8dbd19e94ab7da96 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 22:46:11 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20CONSUMERS.md=20=E2=80=94=20prerequi?= =?UTF-8?q?sites,=20bootstrap=20and=20convert=20checklists,=20pinning,=20c?= =?UTF-8?q?hangelog=20rule,=20team-flow=20adoption=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing release-workflow / artifact-hook / labels sections (from #27 and #39, kept intact) with the remaining adoption guide: the greenfield bootstrap path, the box/rig/cast conversion checklist, the exact-tag pinning policy, the portable changelog contributor rule, and the agent-team-flow adoption checklist with the pin-bump procedure (docs-sync documented from #19's contract). Co-Authored-By: Claude Fable 5 --- docs/CONSUMERS.md | 237 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 231 insertions(+), 6 deletions(-) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 420a3b0..53f5409 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -1,5 +1,138 @@ # 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@ + - uses: heavy-duty/ceremony/actions/changelog-monotonic@ + - uses: heavy-duty/ceremony/actions/drill-recorded@ + ``` + + `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)). +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). +- [ ] 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 @@ -65,7 +198,8 @@ 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. +tag IS the package. Worked examples land with the conversions: cast's tgz +build (#15) and incubator's GHCR image push (#16). ## Labels automation @@ -153,8 +287,99 @@ 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. -**The pin-bump procedure**: bumping the pin is one PR — the pin line change -plus the re-synced mirror (run `--fix` locally, or let the red `--check` on -the bump PR say what is stale). The guard makes a half-done bump — pin -without mirror, mirror without pin — unmergeable, which is how a process -change rolls out: deliberately, per repo, reviewed. +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.** 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). A repo that has adopted the agent team flow + bumps pin and mirror together — + [the pin-bump procedure](#the-pin-bump-procedure); a release-only repo's + bump is the one-line `uses:` change. +- **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@ + ``` + + (`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**: the pin-line +change in the workflow callers, 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. From 55d0384c2297bda1b2ac021b22ccd6cafe91003e Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Wed, 22 Jul 2026 23:07:37 +0000 Subject: [PATCH 4/4] docs: pin bumps update every ceremony reference; decide table is six rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 (PR #48): codex-bot flagged the release-only bump described as a one-line uses change while the same-tag rule two bullets up requires every reference on one tag — a release-only setup has four (release caller + three guards), so a one-line bump splits the consumer across versions. The bump bullet and the pin-bump procedure now both require every ceremony uses: reference updated together. Also the README called lib/decide.sh a 5-state table; the spec comment and rendered table have six rows. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/CONSUMERS.md | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 96aeaf4..a57b9fe 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ and double-publish — and that job is the release's only chance to publish The merge door runs on **every** push to main, and the `release` label legitimately means two things (release ceremonies, and ordinary work *on* -the release machinery), so the door's first act is a decision: the 5-state +the release machinery), so the door's first act is a decision: the six-row table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment block *is* the spec, and the table is contract-tested offline). Rendered for operators: diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 53f5409..d80bae6 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -297,13 +297,19 @@ Bumping the pin re-syncs the mirror in the same PR — 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.** Before bumping, read the ceremony's own `CHANGELOG.md` - section for the new version (the release body on its +- **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). A repo that has adopted the agent team flow - bumps pin and mirror together — - [the pin-bump procedure](#the-pin-bump-procedure); a release-only repo's - bump is the one-line `uses:` change. + 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 four (the + [release caller](#release-workflow) plus the + [three 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 @@ -376,8 +382,10 @@ this is the checklist: ### The pin-bump procedure -Bumping the ceremony pin is **one PR carrying both halves**: the pin-line -change in the workflow callers, and the re-synced `.ceremony/` mirror — +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