From 8080618a7047c7ea414a31782e6d867eae0eb244 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:17:30 +0000 Subject: [PATCH 01/11] changelog.d: the README is rewritten whole from the current tree --- changelog.d/311.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/311.md diff --git a/changelog.d/311.md b/changelog.d/311.md new file mode 100644 index 0000000..4c8dd26 --- /dev/null +++ b/changelog.d/311.md @@ -0,0 +1,6 @@ +### Changed + +- `README.md` is rewritten whole from the current tree: the front page names + the governance repo ceremony now is, routes to `docs/CONSUMERS.md`, + `AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and + keeps the operator's release runbook as its core, re-measured (#311). From 57a7b156ace71f853d85ef576da2fd6d3b817ab9 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:22:13 +0000 Subject: [PATCH 02/11] README.md rewritten whole from the current tree --- README.md | 498 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 280 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 3ffaa7e..6999f14 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,64 @@ # 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. +The heavy-duty family's **governance repo**: the machinery every repo in the +family runs, and the doctrine every agent in the family reads. Implemented +once here, tested once here, consumed everywhere else — never copied. -- **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. +Two kinds of thing live in this tree, and they are consumed in two different +ways because they have two different runtimes. + +**Machinery is consumed by reference, at a pin.** The reusable workflows in +[`.github/workflows/`](.github/workflows/) and the composite actions in +[`actions/`](actions/) are fetched by GitHub at run time from the ref the +caller pins; no copy exists in the consumer. That machinery is two systems. +The **release ceremony** — [`release.yml`](.github/workflows/release.yml), +the decision and fact libraries under [`lib/`](lib/), and the guard actions +that keep a release honest — is the operator-facing half, and the runbook +below is its documentation. The **label and issue-flow machine** — +[`labels.yml`](.github/workflows/labels.yml) and its detached sweep half +[`labels-sweep.yml`](.github/workflows/labels-sweep.yml) (split in #209), +driving [`labels-scope`](actions/labels-scope/), +[`labels-reconcile`](actions/labels-reconcile/) and +[`issueflow-reconcile`](actions/issueflow-reconcile/) — converges PR state +and the issue work queue. What its labels *mean* is +[LABELS.md](LABELS.md)'s contract, not this page's. + +**Doctrine is consumed as a machine-verified mirror.** A document's only +runtime is an agent reading the working tree it stands in, and a doc that +needs a cross-repo fetch before it governs is a doc that sometimes goes +unread. So the agent-facing set — the files named in +[`docs/VENDORED.txt`](docs/VENDORED.txt) — is vendored into each governed +repo at `.ceremony/`, byte-identical to this repo at the pinned ref, by +[`actions/docs-sync`](actions/docs-sync/). A CI guard diffs the mirror +against the pin on every PR: hand-editing a vendored file, or bumping the +pin without re-syncing, goes red. It is a copy that cannot drift, which is +the only kind of copy this org allows. This README is deliberately *not* in +that set — a consumer's router is its `AGENTS.md`, not this repo's front +page — and [`.github/scripts/vendored-check.sh`](.github/scripts/vendored-check.sh) +records that reason beside the three other ceremony-only root docs. + +**One pin governs both halves.** The ref a repo's workflow callers name is +the ref its `.ceremony/` mirror is verified against, so a process change +rolls out as one reviewed PR per repo: the pin line plus the re-synced +mirror, checked by the same guard. + +## Where to go + +- **Adopting ceremony, or converting a repo that carries its own copy** → + [docs/CONSUMERS.md](docs/CONSUMERS.md) — the bootstrap and conversion + checklists, the caller stubs, the pin-bump procedure. +- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you + to your role file ([TRIAGE.md](TRIAGE.md), [BUILDER.md](BUILDER.md), + [REVIEWER.md](REVIEWER.md)); [CONTRIBUTING.md](CONTRIBUTING.md) carries + this repo's own specifics — the review panel roster, the `scope:*` set, + the code and doctrine conventions. +- **The board: what a label means, and who may set it** → + [LABELS.md](LABELS.md). It is the shared state machine; misusing one label + lies to every other agent on the board. +- **Family release windows — what ships together, and when** → + [RELEASES.md](RELEASES.md). +- **How the operator fleet is actually wired** → [FLEET.md](FLEET.md), a + descriptive snapshot rather than doctrine. - **Operating a release, or staring at a red run on main** → read on. ## What a release is @@ -23,13 +72,14 @@ stamps: ([lib/version.sh](lib/version.sh)). 2. **The changelog section is assembled — one edit, produced by the tool** (#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one - fragment file, `changelog.d/.md`, and the ceremony PR runs - [bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose, - so the section lands in the PR's diff where the panel reads it (#112 - D12; a consumer's exact invocation is in - [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). - The tool folds every fragment into a new `## X.Y.Z — DATE` section on - top and deletes the fragments it consumed; the + fragment file, `changelog.d/.md` + ([the directory's marker](changelog.d/README.md) names the doctrine), and + the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) — + by hand, on purpose, so the section lands in the PR's diff where the + panel reads it (#112 D12; a consumer's exact invocation is in + [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The + tool folds every fragment into a new `## X.Y.Z — DATE` section on top and + deletes the fragments it consumed; the [assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments) replays that run and refuses a stamp that is not byte-for-byte the fragments' assembly. @@ -38,62 +88,61 @@ stamps: `## Unreleased` back on top — because every PR inserted at that one shared anchor, and between the stamp and the re-arm a PR authored *before* the release landed its entry under whatever now occupied the - position — **the section that just shipped** — cleanly, no conflict, - no signal (box#108; confirmed cross-repo as rig#66). Fragments make - that failure structurally impossible rather than guarded-against: a - fragment merged after the release simply sits in the directory and is - assembled into the *next* section. There is no anchor left to misplace, - and nothing to re-arm — the directory is always armed. + position — **the section that just shipped** — cleanly, no conflict, no + signal (box#108; confirmed cross-repo as rig#66). Fragments make that + failure structurally impossible rather than guarded-against: a fragment + merged after the release simply sits in the directory and is assembled + into the *next* section. There is no anchor left to misplace, and nothing + to re-arm — the directory is always armed. 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)). + 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.) +(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 +merge, [release.yml](.github/workflows/release.yml#L136-L301) 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 version is -the only re-arm left; the changelog needs none (#112). The machine does -the transcription because humans err silently and machines fail loudly: -**everything asserts its way to certainty and fails loudly, creating +the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one +canonical extractor, and [bin/changelog-section](bin/changelog-section) is +its command-line face) — and re-arms main by bumping to `X.Y.(Z+1)-dev`; the +version is the only re-arm left, the changelog needs none (#112). 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 +- **The merge door — the paved road.** A push to main 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. + half-ceremony dies loudly + ([release.yml](.github/workflows/release.yml#L136-L301)). 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 +- **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#L303-L371)) — 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 + 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). +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 (#1 +constraint 2). ## What happens when my PR lands on main @@ -101,8 +150,8 @@ 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 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: +block *is* the spec, and the table is contract-tested offline by +[test/decide.test.sh](test/decide.test.sh)). Rendered for operators: | # | the tree your merge produced | the run | what it means — and your move | |---|---|---|---| @@ -113,9 +162,9 @@ for operators: | 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 +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 @@ -123,36 +172,54 @@ refuse (#1 constraint 8). ## The guards -Four 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. +[`actions/`](actions/) holds ten composite actions. Three belong to the +label machine named above and are not the operator's business here. Of the +remaining seven, a consumer's own `ci.yml` carries **five** guard steps — +`changelog-armed`, `changelog-monotonic`, `changelog-assembled`, +`drill-recorded` and [`runner-isolated`](actions/runner-isolated/), the last +asserting that no `pull_request`-triggered workflow names a self-hosted +runner (#58) — plus [`refs-not-closing`](actions/refs-not-closing/) in its +own [`refs-guard.yml`](.github/workflows/refs-guard.yml) caller, because +body edits are load-bearing there (#200, #218), and +[`docs-sync`](actions/docs-sync/) once the repo adopts the agent team flow. +The exact steps and their pin-availability rules are in +[docs/CONSUMERS.md](docs/CONSUMERS.md). + +The four below are the release's own, and this is the operator's cut of +them. 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. + +This repo eats what it serves: [`ci.yml`](.github/workflows/ci.yml) runs the +guard actions against its own real tree, and +[`release-exercise.yml`](.github/workflows/release-exercise.yml) replays the +merge door's step sequence on every PR. ### changelog-armed — main never sits disarmed **The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)), keyed on the tree's shape, then its version. In **fragment mode** — -`changelog.d/` exists, the arming property moved onto the directory -(#112 D7): +`changelog.d/` exists, the arming property moved onto the directory (#112 +D7): - always → the marker `changelog.d/README.md` must exist (what keeps the - directory tracked when it holds no fragments), no `## Unreleased` - section may survive in `CHANGELOG.md` (a second anchor with no owner), - and every fragment must be publishable on its own — named `.md` - or `-.md`, no `## ` heading, at least one bullet, no - `### ` heading without an entry. A malformed fragment fails the PR that - wrote it, not the release that consumes it (#112 D9). -- `-dev` tree → nothing more. The directory **is** the arming: the next - PR's entry is a new file, and a new file always has somewhere to land. + directory tracked when it holds no fragments), no `## Unreleased` section + may survive in `CHANGELOG.md` (a second anchor with no owner), and every + fragment must be publishable on its own — named `.md` or + `-.md`, no `## ` heading, at least one bullet, no `### ` + heading without an entry. A malformed fragment fails the PR that wrote it, + not the release that consumes it (#112 D9). +- `-dev` tree → nothing more. The directory **is** the arming: the next PR's + entry is a new file, and a new file always has somewhere to land. - bare tree (the ceremony PR and its merge) → every fragment must be - consumed, and the top section must be the stamped, publishable section - for exactly that version. Fragment mode has no re-armed shape — there - is nothing left to re-arm. + consumed, and the top section must be the stamped, publishable section for + exactly that version. Fragment mode has no re-armed shape — there is + nothing left to re-arm. In **legacy mode** — no `changelog.d/` — the version-keyed rules stand -verbatim; both shapes stay supported so a consumer adopts fragments on a -pin bump, on its own schedule (#112 D8): +verbatim; both shapes stay supported so a consumer adopts fragments on a pin +bump, on its own schedule (#112 D8): - `-dev` tree → the top section **must** be `## Unreleased`. - bare tree (the ceremony PR and its merge) → the top section may be @@ -166,32 +233,30 @@ pin bump, on its own schedule (#112 D8): 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). Fragment mode retires the incident's -mechanism outright; legacy mode guards it. **Red means** a PR entry has -nowhere safe to land — a missing marker, a surviving `## Unreleased`, a -malformed fragment — or a stamped version would publish no entries, a -dangling grouped heading, or a bare tree still carrying fragments the -stamp did not consume (`not consumed` — re-run the assembler); the -message names the fix in every case. What this guard cannot see is a -fragment that *was* consumed but whose entry the stamp omits — the -fragment is gone from HEAD, so only +[above](#what-a-release-is). Fragment mode retires the incident's mechanism +outright; legacy mode guards it. **Red means** a PR entry has nowhere safe +to land — a missing marker, a surviving `## Unreleased`, a malformed +fragment — or a stamped version would publish no entries, a dangling grouped +heading, or a bare tree still carrying fragments the stamp did not consume +(`not consumed` — re-run the assembler); the message names the fix in every +case. What this guard cannot see is a fragment that *was* consumed but whose +entry the stamp omits — the fragment is gone from HEAD, so only [changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s merge-base replay catches that loss. **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. +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 version-keyed form is what rig and cast get back by +adopting this repo. One consequence worth knowing before it happens, legacy mode only: 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 guard does not block the release; it refuses to let main *sit* disarmed, which is the window a late PR falls -into. Fragment mode has no such window: with no re-arm step there is -nothing to forget. +into. Fragment mode has no such window: with no re-arm step there is nothing +to forget. ### changelog-assembled — the stamp is exactly the fragments @@ -199,126 +264,122 @@ nothing to forget. ([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)): on a release PR in fragment mode, the stamped `## X.Y.Z` section must be **byte-for-byte** what the fragments it consumed assemble to. The guard -reads the fragments as of the merge base (they are gone from HEAD — that -is the point of the ceremony), replays `changelog-assemble --check` over -that set, and diffs the result against HEAD's section body. Every tree it -does not apply to — a `-dev` tree, legacy mode, no consumed fragments — -passes with a green `NOTICE`, so a non-ceremony PR is never red here. +reads the fragments as of the merge base (they are gone from HEAD — that is +the point of the ceremony), replays `changelog-assemble --check` over that +set, and diffs the result against HEAD's section body. Every tree it does +not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes +with a green `NOTICE`, so a non-ceremony PR is never red here. **The failure it catches** (#116): assembly is a hand-run step by design — -the section must land in the PR's diff where the panel reads it (#112 D12) -— and a mis-run hand step can leave no trace. The two failure shapes -differ, and the guards split them exactly as -[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s -trio rows record: leave a fragment **out of the deletion** and it survives -on HEAD, where -[changelog-armed](#changelog-armed--main-never-sits-disarmed) already -refuses the bare tree (`not consumed`) — this guard goes red too, naming -the entry the section lost. But **delete** a fragment while omitting its -entry from the stamp, or hand-edit one word of the assembled prose, and -nothing on HEAD is out of place: armed is green, monotonic is green, and -the publisher would happily publish history that is not what the authors -wrote. Only the merge-base replay catches those. The replay is what -makes a hand-run step safe. **This guard needs history** — same stance as -the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a -hard failure, not a skip. +the section must land in the PR's diff where the panel reads it (#112 D12) — +and a mis-run hand step can leave no trace. The two failure shapes differ, +and the guards split them exactly as +[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio +rows record: leave a fragment **out of the deletion** and it survives on +HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed) +already refuses the bare tree (`not consumed`) — this guard goes red too, +naming the entry the section lost. But **delete** a fragment while omitting +its entry from the stamp, or hand-edit one word of the assembled prose, and +nothing on HEAD is out of place: armed is green, monotonic is green, and the +publisher would happily publish history that is not what the authors wrote. +Only the merge-base replay catches those. The replay is what makes a +hand-run step safe. **This guard needs history** — same stance as the +monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard +failure, not a skip. ### 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: the assembler writes a new `## X.Y.Z — DATE` heading and -removes none. Fragment mode changes nothing here (#112 D10): fragments add -no `## ` heading, and `Unreleased` was never in the guard's set — it is -not a version heading; it is +([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh)): +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: +the assembler writes a new `## X.Y.Z — DATE` heading and removes none. +Fragment mode changes nothing here (#112 D10): fragments add no `## ` +heading, and `Unreleased` was never in the guard's set — it is not a version +heading; it is [changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business — which is why a repo's adoption PR can delete it and stay green. -**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)). +**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. -**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 +**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)). +stop guarding is the failure shape this family of checks exists to refuse. ### 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). +([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh)), +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 +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 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. +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 +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, written +out step by step in [drills/README.md](drills/README.md), with the records +themselves in [drills/](drills/); 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/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. +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. +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: +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 @@ -347,12 +408,11 @@ or — if the tree is genuinely the release — publish by the tag door. > 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 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)) @@ -367,44 +427,44 @@ stderr line and file what you find. > 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. +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)) +### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) > 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. +[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 +[L208–L223](.github/workflows/release.yml#L208-L223), 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 +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 +[L293–L301](.github/workflows/release.yml#L293-L301) — 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 +until it lands, main is sitting bare, where a dev install impersonates the +release 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)) +### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371)) > 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. @@ -416,40 +476,42 @@ remedy. [L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was never stamped. Assemble the section -([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), -then delete and re-push the tag. +([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), 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 — legacy mode only**: the ceremony stamped without putting -`## Unreleased` back, the release's own `-dev` bump landed, and the guard -now says (first line): +operator will actually meet on main is **changelog-armed after a re-arm was +forgotten — legacy mode only**: 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 carries the same instruction. Fragment mode has -no re-arm to forget, so it has no equivalent red on main — its refusals -(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed +section. The full message carries the same instruction. Fragment mode has no +re-arm to forget, so it has no equivalent red on main — its refusals (a +missing marker, a surviving `## Unreleased`, a malformed or unconsumed fragment) all fire on the PR that caused them, where the author is still holding it. ## 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 four -[guard scripts](actions/). The comments are the documentation of record; -this README is their operator-facing cut. +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, and the +drift that motivated it is measured in +[#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the +load-bearing constraints — each bought with an incident, none of them safe +to "simplify" away. The label machine's own record is #10, #11 and #130; the +issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and +#116; the sweep/trigger split is #209. + +The narrative lives in those issues, by design: the war stories are carried +in the headers of the scripts they bind — +[release.yml](.github/workflows/release.yml), +[lib/decide.sh](lib/decide.sh), [lib/facts.sh](lib/facts.sh) and the +[guard scripts](actions/) — and those comments are the documentation of +record. This README is their operator-facing cut. From 87f300c4538c696820ccf10c96047dff971530d8 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:59:30 +0000 Subject: [PATCH 03/11] README: re-measure the incubator drill and close the refusal catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drill paragraph called incubator's drill "TBD". It has been defined in heavy-duty/incubator since 2026-07-23 (f7851cb, refined 7c20a4e on 07-24): the pre-release verify of the canonical candidate deployed to staging, its smoke probe run inside the container on deployed credentials, the record pinning commit SHA and image digest. Carried from main unmeasured, which is the one thing D1 forbids. With incubator named the paragraph enumerates five meanings, not three, so its own tally closes now too. The refusal catalog claimed to be generated from the sources, but its regeneration grep never read lib/version.sh — the four version_read messages it quotes all live there. Adding version.sh to the documented command turned up four more refusals the catalog was missing: unknown backend on the read side, and version_next_dev / version_write's two on the post-release re-arm, which release.yml:275,283 really can emit. The re-arm ones get their own section because their remedy is unlike every other entry here — the tag and the publish already happened, so the fix is a manual bump, not a re-run. One refusal stays outside the grep by construction: "no version field" is a console.error inside the node one-liner, with no >&2 and no "refuse ". The section now says so rather than shipping a command that silently under-produces the catalog it claims to generate. Answers codex-bot-andresmgsl and kimi-bot-andresmgsl (blocking, both the incubator claim) and claude-bot-andresmgsl nit 2, at head 57a7b15. --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6999f14..2fb5d67 100644 --- a/README.md +++ b/README.md @@ -355,10 +355,14 @@ record. box asserts the **isolation contract**; rig asserts **convergence** reproduces, the diff is idempotent); ceremony's own drill is a **door rehearsal** — both doors exercised end-to-end on a disposable repo, written out step by step in [drills/README.md](drills/README.md), with the records -themselves in [drills/](drills/); 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. +themselves in [drills/](drills/); incubator asserts the **staging verify** — +the canonical candidate deployed, its smoke probe run *inside* the staging +container on the deployed environment's credentials, the record pinning the +commit SHA and image digest that were exercised +([heavy-duty/incubator `drills/README.md`](https://github.com/heavy-duty/incubator/blob/main/drills/README.md)). +Each repo states its meaning in its own `drills/README.md`. Five 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 @@ -382,10 +386,16 @@ 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 +grep -n -A2 'refuse \|>&2' \ + lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml ``` -`$VER`-style variables appear as the run interpolates them. +`$VER`-style variables appear as the run interpolates them. One refusal is +outside that command by construction: `version_read: $path: no version field` +is a `console.error` inside the node one-liner at +[lib/version.sh#L55](lib/version.sh#L55) — no `>&2`, no `refuse `, so the grep +cannot see it. It is quoted below as it reaches the log at run time, which is +the convention this catalog is written to. ### The decision refused ([lib/decide.sh](lib/decide.sh)) @@ -431,6 +441,15 @@ 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. +> version_read: unknown backend: $backend + +[L62](lib/version.sh#L62): not an operator mistake and not reachable through +the release flow — [lib/facts.sh](lib/facts.sh#L33-L40) rejects a bad +`VERSION_SOURCE` with the message above before `version_read` is ever called, +so this line can only appear when some *other* caller invokes `version_read` +directly with a backend that is neither `file` nor `package-json`. Fix that +caller. + ### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release @@ -479,6 +498,39 @@ never stamped. Assemble the section ([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then delete and re-push the tag. +### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) + +The bump runs *after* the tag, the notes and the publish, so a refusal here +leaves a real release behind an unarmed main — the release exists and main +still reads the version it just shipped. That is the one failure in this +catalog where the remedy is a manual bump, not a re-run. + +> version_next_dev: refusing '$ver' — expected bare X.Y.Z + +[L86](lib/version.sh#L86): the version reaching the bump is not bare — +`-dev`, `-rc1`, or garbage. Unreachable by the merge door, whose row 6 fires +only on a transition *to* bare; it is the tag door's edge, where the tag names +the tree's version and nothing re-checks its shape. + +> version_write: npm is required for version-source: package-json + +[L106](lib/version.sh#L106): `version_write` shells out to `npm version` on +the package-json backend, and the runner has no npm. The read path fails the +same way one step earlier (`node is required…`, above), so a run reaching +*this* message got past the read — set up node/npm in the caller. + +> version_write: unknown backend: $backend + +[L118](lib/version.sh#L118): the write-side twin of `version_read: unknown +backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated +before either was called. Fix the caller. + +In every case main is left armed to impersonate the release it just shipped: +bump `VERSION` (or the `package.json` version field) to `X.Y.(Z+1)-dev` by +hand and push. Note that a *push* refusal is not one of these — branch +protection is expected, and the step opens the bump PR itself rather than +failing ([L293–L301](.github/workflows/release.yml#L293-L301)). + ### Red main that is not the release workflow Consumer CI runs its guard steps on pushes to main too (this repo's From dac4946e4384c07966ba9f62dbee4b4f89b80f86 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:04:10 +0000 Subject: [PATCH 04/11] README: the re-arm is the merge door's, so state its reach correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-caught while re-reading the section added in 87f300c. It said version_next_dev's refusal was "the tag door's edge" — wrong twice. The tag door does not bump at all (release.yml:303-307, cast's precedent: the fallback does not rewrite main), and the merge door's bump runs only on ceremony=yes, which decide rows 5-6 reach only on a transition TO bare. The message is therefore unreachable through either door as they stand, and the honest description is a guard against a future decide change, not an edge an operator can hit today. Writing a troubleshooting entry that sends an operator looking down the tag door for a message the tag door cannot emit is the same defect this round is fixing one paragraph up, so it does not get to ship in the fix. --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2fb5d67..5da3564 100644 --- a/README.md +++ b/README.md @@ -500,17 +500,20 @@ delete and re-push the tag. ### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) -The bump runs *after* the tag, the notes and the publish, so a refusal here -leaves a real release behind an unarmed main — the release exists and main -still reads the version it just shipped. That is the one failure in this -catalog where the remedy is a manual bump, not a re-run. +The bump belongs to the merge door alone — the tag door deliberately does not +rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it +runs *after* the tag, the notes and the publish. So a refusal here leaves a +real release behind an unarmed main: the release exists and main still reads +the version it just shipped. That is the one failure in this catalog whose +remedy is a manual bump, not a re-run. > version_next_dev: refusing '$ver' — expected bare X.Y.Z [L86](lib/version.sh#L86): the version reaching the bump is not bare — -`-dev`, `-rc1`, or garbage. Unreachable by the merge door, whose row 6 fires -only on a transition *to* bare; it is the tag door's edge, where the tag names -the tree's version and nothing re-checks its shape. +`-dev`, `-rc1`, or garbage. Not reachable through either door as they stand: +the step runs only on `ceremony=yes`, which rows 5–6 reach only on a +transition *to* bare, and the tag door never bumps. Treat it as the guard it +is — it fires if a decide change ever lets a non-bare version through. > version_write: npm is required for version-source: package-json From 052c8734b93b0c463bfc8557c950e82d088d255c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:38:27 +0000 Subject: [PATCH 05/11] README: the re-arm refusal is the rc path, not an unreachable guard The section called version_next_dev's refusal unreachable by reading decide's sense of 'bare' (not -dev) into version_next_dev's regex (^X.Y.Z$). An rc lives between the two: row 6 admits a labeled rc transition as a shippable ceremony, the bump step gates only on ceremony=yes, and its VER is the tree's version verbatim -- so an rc release tags, notes, publishes, then refuses here. State that path and its remedy (version.sh L78-L82: an rc's next version is a human decision), keep the guard reading for the -dev/garbage half that really is unreachable, and close the same conflation in row 6. Also: version_write runs npm pkg set + a lockfile-only install, not npm version (which would tag); and drop 'unarmed main' so the section uses release.yml's one sense of armed. Reported by claude-bot-andresmgsl on #315. --- README.md | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5da3564..2560c51 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ block *is* the spec, and the table is contract-tested offline by | 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`. | +| 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`. **Read *bare* as decide reads it** — anything not `-dev` ([lib/decide.sh](lib/decide.sh#L108-L110)) — so an rc transition is a shippable ceremony here too, and the release lands but the re-arm stops for you to pick the next version ([The re-arm refused](#the-re-arm-refused-releaseyml)). | 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, @@ -503,22 +503,37 @@ delete and re-push the tag. The bump belongs to the merge door alone — the tag door deliberately does not rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it runs *after* the tag, the notes and the publish. So a refusal here leaves a -real release behind an unarmed main: the release exists and main still reads -the version it just shipped. That is the one failure in this catalog whose -remedy is a manual bump, not a re-run. +real release standing behind a main that never re-armed — the release exists, +and main is left *armed to impersonate* it, still reading the version it just +shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure +in this catalog whose remedy is a manual bump, not a re-run. > version_next_dev: refusing '$ver' — expected bare X.Y.Z -[L86](lib/version.sh#L86): the version reaching the bump is not bare — -`-dev`, `-rc1`, or garbage. Not reachable through either door as they stand: -the step runs only on `ceremony=yes`, which rows 5–6 reach only on a -transition *to* bare, and the tag door never bumps. Treat it as the guard it -is — it fires if a decide change ever lets a non-bare version through. +[L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`. +Two senses of *bare* meet here, and the gap between them is the **rc release +path** — the one door an operator actually walks through. decide calls a +version bare when it is not `-dev` +([version_is_dev](lib/version.sh#L69-L76) matches that suffix and nothing +else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony +is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). +`version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the +two, and nothing filters it out on the way: the step's only gate is +`ceremony == 'yes'` and its `VER` is the tree's version verbatim. So an rc +ceremony tags, writes the notes, publishes — and *then* the re-arm refuses. +That is the machine correctly declining to guess rather than a bug: an rc's +next version "is a human decision, not arithmetic" +([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by +hand to it. A `-dev` or garbage version reaching this line is the same +refusal's other half, and that half really is unreachable as the doors stand — +rows 1–2 send `-dev` to a no-op, and the tag door never bumps. > version_write: npm is required for version-source: package-json -[L106](lib/version.sh#L106): `version_write` shells out to `npm version` on -the package-json backend, and the runner has no npm. The read path fails the +[L106](lib/version.sh#L106): the package-json backend needs npm to write — +`npm pkg set version=` plus a lockfile-only `npm install` +([L102–L112](lib/version.sh#L102-L112)), never `npm version`, which would tag +— and the runner has none. The read path fails the same way one step earlier (`node is required…`, above), so a run reaching *this* message got past the read — set up node/npm in the caller. @@ -528,9 +543,10 @@ same way one step earlier (`node is required…`, above), so a run reaching backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated before either was called. Fix the caller. -In every case main is left armed to impersonate the release it just shipped: -bump `VERSION` (or the `package.json` version field) to `X.Y.(Z+1)-dev` by -hand and push. Note that a *push* refusal is not one of these — branch +In every case the remedy has the same shape — bump `VERSION` (or the +`package.json` version field) by hand and push: `X.Y.(Z+1)-dev` where the +shipped version was bare, and where it was an rc, whatever you have decided +comes next. Note that a *push* refusal is not one of these — branch protection is expected, and the step opens the bump PR itself rather than failing ([L293–L301](.github/workflows/release.yml#L293-L301)). From 05e977814f8c73ca66d33ff4ecf95bb914603b87 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:40:41 +0000 Subject: [PATCH 06/11] README: point the two new anchors at the lines that carry the claim version_is_dev's range started one line in, past the signature comment that states the -dev-only rule; and the npm range stopped at L112, before the npm pkg set / npm install --package-lock-only lines it was cited for. L68-L76 and L114-L115. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2560c51..2664ba4 100644 --- a/README.md +++ b/README.md @@ -514,7 +514,7 @@ in this catalog whose remedy is a manual bump, not a re-run. Two senses of *bare* meet here, and the gap between them is the **rc release path** — the one door an operator actually walks through. decide calls a version bare when it is not `-dev` -([version_is_dev](lib/version.sh#L69-L76) matches that suffix and nothing +([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). `version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the @@ -532,7 +532,7 @@ rows 1–2 send `-dev` to a no-op, and the tag door never bumps. [L106](lib/version.sh#L106): the package-json backend needs npm to write — `npm pkg set version=` plus a lockfile-only `npm install` -([L102–L112](lib/version.sh#L102-L112)), never `npm version`, which would tag +([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag — and the runner has none. The read path fails the same way one step earlier (`node is required…`, above), so a run reaching *this* message got past the read — set up node/npm in the caller. From 6df42797d272e10aefacbe1c72b604e36d5caa74 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:42:23 +0000 Subject: [PATCH 07/11] README: reflow the npm entry and drop an overstated clause The rc path is how this refusal is reached, not 'the one door an operator actually walks through' -- most releases are bare and never see it. Also rewrap the npm paragraph, left ragged by the previous commit. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2664ba4..cc626a9 100644 --- a/README.md +++ b/README.md @@ -512,8 +512,8 @@ in this catalog whose remedy is a manual bump, not a re-run. [L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`. Two senses of *bare* meet here, and the gap between them is the **rc release -path** — the one door an operator actually walks through. decide calls a -version bare when it is not `-dev` +path** — the way this refusal is actually reached, and designed behaviour +rather than a decide bug. decide calls a version bare when it is not `-dev` ([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). @@ -533,9 +533,9 @@ rows 1–2 send `-dev` to a no-op, and the tag door never bumps. [L106](lib/version.sh#L106): the package-json backend needs npm to write — `npm pkg set version=` plus a lockfile-only `npm install` ([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag -— and the runner has none. The read path fails the -same way one step earlier (`node is required…`, above), so a run reaching -*this* message got past the read — set up node/npm in the caller. +— and the runner has none. The read path fails the same way one step earlier +(`node is required…`, above), so a run reaching *this* message got past the +read — set up node/npm in the caller. > version_write: unknown backend: $backend From ca44ee0f845178eb4f4a2726d544d4eea6f15c07 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:12:29 +0000 Subject: [PATCH 08/11] README: the overview's zero-artifact promise is the pre-publish one The re-arm section corrected last round said the bump runs after publish and its refusal leaves a release standing; the overview still promised an unconditional -dev re-arm and zero artifacts on any failed assert. Scope the guarantee to the asserts before the publish, name the rc exception where the reader meets it first, and stop calling a malformed version's refusal unreachable - only the -dev half is. --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cc626a9..9ef5c1b 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,17 @@ 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 [bin/changelog-section](bin/changelog-section) is -its command-line face) — and re-arms main by bumping to `X.Y.(Z+1)-dev`; the -version is the only re-arm left, the changelog needs none (#112). 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. +its command-line face) — and, on the bare-`X.Y.Z` path, re-arms main by +bumping to `X.Y.(Z+1)-dev`; the version is the only re-arm left, the +changelog needs none (#112). An rc ships too, and its next version is a human +decision rather than arithmetic, so the re-arm stops for you to make it +([The re-arm refused](#the-re-arm-refused-releaseyml)). 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 assert that +fails *before* the publish leaves zero artifacts: no tag, no release, no +bump. The re-arm is the one assert past that line, and its refusal is the +single failure in this file that leaves a real release behind. ## The two doors @@ -524,9 +529,12 @@ ceremony tags, writes the notes, publishes — and *then* the re-arm refuses. That is the machine correctly declining to guess rather than a bug: an rc's next version "is a human decision, not arithmetic" ([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by -hand to it. A `-dev` or garbage version reaching this line is the same -refusal's other half, and that half really is unreachable as the doors stand — -rows 1–2 send `-dev` to a no-op, and the tag door never bumps. +hand to it. A `-dev` version reaching this line is the same refusal's other +half, and *that* half is unreachable as the doors stand — rows 1–2 send `-dev` +to a no-op, and the tag door never bumps. A malformed version is not: nothing +upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only +that a version is present and non-empty), so `banana` rides row 6 exactly as +an rc does, and the same manual bump is the remedy. > version_write: npm is required for version-source: package-json From a663c631bf9050019d81bb3442298fd313a541a1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:44:02 +0000 Subject: [PATCH 09/11] README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex's round-4 blockers, both reproduced against the tree: - the opening introduced machinery and doctrine together as 'never copied', which the doctrine paragraph then contradicts by design — the .ceremony/ mirror is a copy, kept honest by a guard rather than by absence. The clause now says of each half what is true of it. - the tag door has two refusing asserts, not one: tag/tree identity (release.yml L328-L339) and a publishable version section (L340-L352, changelog_section_problem), the second already quoted in this page's own troubleshooting catalog. claude's N1, taken: the zero-artifact boundary is the tag, not the publish — the consumer's artifact hook runs between them and its non-zero exit aborts with a tag standing. The re-arm remains the single failure that leaves a real release behind. Per triage's steer, one line pointing the rc half of the re-arm refusal at the 0.7.0 window (#317); the rc recovery prose is not widened. Refs #311 --- README.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9ef5c1b..14ff4f3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ The heavy-duty family's **governance repo**: the machinery every repo in the family runs, and the doctrine every agent in the family reads. Implemented -once here, tested once here, consumed everywhere else — never copied. +once here, tested once here, consumed everywhere else — the machinery never +copied at all, the doctrine only as a mirror a guard keeps byte-identical to +the pin. Two kinds of thing live in this tree, and they are consumed in two different ways because they have two different runtimes. @@ -118,10 +120,15 @@ decision rather than arithmetic, so the re-arm stops for you to make it ([The re-arm refused](#the-re-arm-refused-releaseyml)). 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 assert that -fails *before* the publish leaves zero artifacts: no tag, no release, no -bump. The re-arm is the one assert past that line, and its refusal is the -single failure in this file that leaves a real release behind. +nothing** — a wrong release is worse than a missing one, so every assert in +this file fires *before its door creates anything*, and one that fails leaves +zero artifacts of the run's own: no tag it made, no release, no bump. Only two +steps run past the tag. The consumer's +[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and +the publish, so its non-zero exit aborts with a tag standing and no release — +a state the [nothing-exists assert](#the-merge-door-refused-releaseyml) names, +and recovers by the tag door. The re-arm runs after the publish, and its +refusal is the single failure in this file that leaves a real release behind. ## The two doors @@ -137,8 +144,12 @@ single failure in this file that leaves a real release behind. — **no `v` prefix**, box's 0.6.0 set the scheme ([release.yml](.github/workflows/release.yml#L303-L371)) — 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 + and no label check — what is left is two asserts: **the tag names the + tree's own version** + ([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged + tree carries a publishable `## X.Y.Z` section** + ([L340–L352](.github/workflows/release.yml#L340-L352)); either failing + 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). @@ -534,7 +545,10 @@ half, and *that* half is unreachable as the doors stand — rows 1–2 send `-de to a no-op, and the tag door never bumps. A malformed version is not: nothing upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only that a version is present and non-empty), so `banana` rides row 6 exactly as -an rc does, and the same manual bump is the remedy. +an rc does, and the same manual bump is the remedy. The rc half has a shelf +life: the 0.7.0 window makes rc cuts native and their re-arm deterministic +(#317), after which no rc reaches this refusal — the malformed half still +does. > version_write: npm is required for version-source: package-json From 1410c01caf12f10deebc0c15a743dbba1753c6af Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:57:43 +0000 Subject: [PATCH 10/11] README: the forward pointer cites #317, never a version number Triage's D6 (#311), added mid-round: the file's single forward-looking sentence is sanctioned, and bounded. Two corrections to what round 4 landed, both of them the bound rather than the claim -- the claim itself was re-measured by triage and holds. - it named 'the 0.7.0 window'. Which release carries that work is a scheduling fact owned by the epic and RELEASES.md, where release-init may fold an empty window into a later release or skip the version outright, so the number can move with no diff under this file while every guard stays green. The issue number does not move: #317 is the stable name of the work. - it was present indicative -- 'makes' -- one paragraph after banana rides row 6 today. It now reads as work that has not landed, on its own, without the reader chasing the link. Still one sentence, still only in this section, and it weakens no present-tense claim around it: the -dev half stays unreachable, the malformed half stays live, and the manual bump stays the remedy today. Refs #311 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 14ff4f3..c41a2c9 100644 --- a/README.md +++ b/README.md @@ -545,10 +545,9 @@ half, and *that* half is unreachable as the doors stand — rows 1–2 send `-de to a no-op, and the tag door never bumps. A malformed version is not: nothing upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only that a version is present and non-empty), so `banana` rides row 6 exactly as -an rc does, and the same manual bump is the remedy. The rc half has a shelf -life: the 0.7.0 window makes rc cuts native and their re-arm deterministic -(#317), after which no rc reaches this refusal — the malformed half still -does. +an rc does, and the same manual bump is the remedy. One note on work that has +not landed: #317 would make rc cuts native and their re-arm deterministic, and +if it lands only the malformed half still reaches this refusal. > version_write: npm is required for version-source: package-json From 263bb74652a14660d38000413c423c50cee5da79 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:29:12 +0000 Subject: [PATCH 11/11] README: three steps run past the tag, and the publish is one of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview's fail-loudly paragraph said "Only two steps run past the tag" and attributed the tag-standing/no-release state to the artifact hook alone. Past `tag the merge commit` (release.yml:224) come three steps: the artifact hook (:236), `publish the release` (:246) and the -dev re-arm (:267). The publish is `gh release create --verify-tag`, so it can fail on the API call or the assets with the tag already standing — the same state, from a second cause. State the count as three and sort them by what a failure leaves behind: two fail before the release exists (hook, publish), both recovered by the tag door; the third is the re-arm, still the one failure in the file that leaves a real release behind. The nothing-exists recovery text names the publish among the causes of a tag with no release too; its remedy is unchanged. --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c41a2c9..718eb69 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,16 @@ 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 assert in this file fires *before its door creates anything*, and one that fails leaves -zero artifacts of the run's own: no tag it made, no release, no bump. Only two -steps run past the tag. The consumer's +zero artifacts of the run's own: no tag it made, no release, no bump. Three +steps run past the tag, and what a failure at each leaves behind is what +sorts them. Two fail before the release exists: the consumer's [artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and -the publish, so its non-zero exit aborts with a tag standing and no release — -a state the [nothing-exists assert](#the-merge-door-refused-releaseyml) names, -and recovers by the tag door. The re-arm runs after the publish, and its +the publish, so its non-zero exit aborts, and the publish itself +([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258)) +can fail on the API call or the assets. Either leaves the same state — a tag +standing and no release — which the +[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag +door recovers. The third is the re-arm, which runs after the publish, and its refusal is the single failure in this file that leaves a real release behind. ## The two doors @@ -485,9 +489,9 @@ 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. +[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish +step itself failing after the tag), 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