README.md rewritten whole from the current tree

This commit is contained in:
cndgrr 2026-08-04 20:22:13 +00:00
parent 8080618a70
commit 57a7b156ac

498
README.md
View file

@ -1,15 +1,64 @@
# ceremony # ceremony
One release ceremony for the whole heavy-duty family — implemented once, The heavy-duty family's **governance repo**: the machinery every repo in the
tested once, documented here, consumed everywhere else by reference. The family runs, and the doctrine every agent in the family reads. Implemented
approach and its constraints live in once here, tested once here, consumed everywhere else — never copied.
[#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). Two kinds of thing live in this tree, and they are consumed in two different
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you; ways because they have two different runtimes.
[CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics.
**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. - **Operating a release, or staring at a red run on main** → read on.
## What a release is ## What a release is
@ -23,13 +72,14 @@ stamps:
([lib/version.sh](lib/version.sh)). ([lib/version.sh](lib/version.sh)).
2. **The changelog section is assembled — one edit, produced by the tool** 2. **The changelog section is assembled — one edit, produced by the tool**
(#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one (#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
fragment file, `changelog.d/<issue>.md`, and the ceremony PR runs fragment file, `changelog.d/<issue>.md`
[bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose, ([the directory's marker](changelog.d/README.md) names the doctrine), and
so the section lands in the PR's diff where the panel reads it (#112 the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) —
D12; a consumer's exact invocation is in by hand, on purpose, so the section lands in the PR's diff where the
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). panel reads it (#112 D12; a consumer's exact invocation is in
The tool folds every fragment into a new `## X.Y.Z — DATE` section on [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The
top and deletes the fragments it consumed; 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) [assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments)
replays that run and refuses a stamp that is not byte-for-byte the replays that run and refuses a stamp that is not byte-for-byte the
fragments' assembly. fragments' assembly.
@ -38,62 +88,61 @@ stamps:
`## Unreleased` back on top — because every PR inserted at that one `## Unreleased` back on top — because every PR inserted at that one
shared anchor, and between the stamp and the re-arm a PR authored shared anchor, and between the stamp and the re-arm a PR authored
*before* the release landed its entry under whatever now occupied the *before* the release landed its entry under whatever now occupied the
position — **the section that just shipped** — cleanly, no conflict, position — **the section that just shipped** — cleanly, no conflict, no
no signal (box#108; confirmed cross-repo as rig#66). Fragments make signal (box#108; confirmed cross-repo as rig#66). Fragments make that
that failure structurally impossible rather than guarded-against: a failure structurally impossible rather than guarded-against: a fragment
fragment merged after the release simply sits in the directory and is merged after the release simply sits in the directory and is assembled
assembled into the *next* section. There is no anchor left to misplace, into the *next* section. There is no anchor left to misplace, and nothing
and nothing to re-arm — the directory is always armed. to re-arm — the directory is always armed.
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the 3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
evidence the release rests on evidence the release rests on ([the drill doctrine](#the-drill-doctrine)).
([the drill doctrine](#the-drill-doctrine)).
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the (This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the ref
ref consumers' runs fetch this repo at — moves to the version being consumers' runs fetch this repo at — moves to the version being released, in
released, in [release.yml](.github/workflows/release.yml#L123-L132) and [release.yml](.github/workflows/release.yml#L123-L132) and every other
every other workflow that carries it. workflow that carries it.
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not [self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not a
a consumer's release, when it is stale.) consumer's release, when it is stale.)
**The merge is the ship decision; the tag is transcription.** After the **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 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 version's own changelog section as the body — the curated prose, never
the generated PR list the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) — canonical extractor, and [bin/changelog-section](bin/changelog-section) is
and re-arms main by bumping to `X.Y.(Z+1)-dev` its command-line face) — and re-arms main by bumping to `X.Y.(Z+1)-dev`; the
([release.yml](.github/workflows/release.yml#L266-L300)) — the version is version is the only re-arm left, the changelog needs none (#112). The
the only re-arm left; the changelog needs none (#112). The machine does machine does the transcription because humans err silently and machines fail
the transcription because humans err silently and machines fail loudly: loudly: **everything asserts its way to certainty and fails loudly, creating
**everything asserts its way to certainty and fails loudly, creating
nothing** — a wrong release is worse than a missing one, so every failed nothing** — a wrong release is worse than a missing one, so every failed
assert leaves zero artifacts: no tag, no release, no bump. assert leaves zero artifacts: no tag, no release, no bump.
## The two doors ## The two doors
- **The merge door — the paved road.** A push to main - **The merge door — the paved road.** A push to main runs the
([release.yml](.github/workflows/release.yml#L140)) runs the
[decide table](#what-happens-when-my-pr-lands-on-main); a merged, [decide table](#what-happens-when-my-pr-lands-on-main); a merged,
`release`-labeled PR whose version transitioned to bare is the ceremony, `release`-labeled PR whose version transitioned to bare is the ceremony,
everything legitimate that isn't one is a green no-op, and every 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 - **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
push **no `v` prefix**, box's 0.6.0 set the scheme **no `v` prefix**, box's 0.6.0 set the scheme
([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the ([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 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 and no label check; the one assert is that **the tag names the tree's own
own version**, and a mismatch refuses, creating nothing. No `-dev` bump version**, and a mismatch refuses, creating nothing. No `-dev` bump either
either — the fallback does not rewrite main (cast's precedent). Use it — the fallback does not rewrite main (cast's precedent). Use it when the
when the merge path is red, for backfills, and for the merge path is red, for backfills, and for the
[first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4). [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, Tag + publish (+ the consumer's artifact hook) happen **in the same job, on
on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's 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 anti-recursion), so the merge door's tag can never re-enter the tag door and
and double-publish — and that job is the release's only chance to publish double-publish — and that job is the release's only chance to publish (#1
([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2). constraint 2).
## What happens when my PR lands on main ## 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* 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 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 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 block *is* the spec, and the table is contract-tested offline by
for operators: [test/decide.test.sh](test/decide.test.sh)). Rendered for operators:
| # | the tree your merge produced | the run | what it means — and your move | | # | 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. | | 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`. |
The green rows are the point as much as the red ones: the machinery must The green rows are the point as much as the red ones: the machinery must be
be safe to work on, so every legitimate non-ceremony is a green `NOTICE` safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op,
no-op — never a red run on main per infra PR never a red run on main per infra PR
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and ([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 automation never guesses; the version transition is the interlock, and
label-without-transition (row 4) and transition-without-label (row 5) both label-without-transition (row 4) and transition-without-label (row 5) both
@ -123,36 +172,54 @@ refuse (#1 constraint 8).
## The guards ## The guards
Four composite actions run in every consumer's CI (and in this repo's [`actions/`](actions/) holds ten composite actions. Three belong to the
own). Shared shape: version-keyed where the tree's state matters, loud label machine named above and are not the operator's business here. Of the
where it fails, and **a file of its own so a test can drive it**. The full remaining seven, a consumer's own `ci.yml` carries **five** guard steps —
war stories are in the scripts' header comments — authoritative and longer `changelog-armed`, `changelog-monotonic`, `changelog-assembled`,
than this; what follows is the operator's cut. `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 ### changelog-armed — main never sits disarmed
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)), **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** keyed on the tree's shape, then its version. In **fragment mode**
`changelog.d/` exists, the arming property moved onto the directory `changelog.d/` exists, the arming property moved onto the directory (#112
(#112 D7): D7):
- always → the marker `changelog.d/README.md` must exist (what keeps the - always → the marker `changelog.d/README.md` must exist (what keeps the
directory tracked when it holds no fragments), no `## Unreleased` directory tracked when it holds no fragments), no `## Unreleased` section
section may survive in `CHANGELOG.md` (a second anchor with no owner), may survive in `CHANGELOG.md` (a second anchor with no owner), and every
and every fragment must be publishable on its own — named `<issue>.md` fragment must be publishable on its own — named `<issue>.md` or
or `<repo>-<issue>.md`, no `## ` heading, at least one bullet, no `<repo>-<issue>.md`, no `## ` heading, at least one bullet, no `### `
`### ` heading without an entry. A malformed fragment fails the PR that heading without an entry. A malformed fragment fails the PR that wrote it,
wrote it, not the release that consumes it (#112 D9). not the release that consumes it (#112 D9).
- `-dev` tree → nothing more. The directory **is** the arming: the next - `-dev` tree → nothing more. The directory **is** the arming: the next PR's
PR's entry is a new file, and a new file always has somewhere to land. 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 - bare tree (the ceremony PR and its merge) → every fragment must be
consumed, and the top section must be the stamped, publishable section consumed, and the top section must be the stamped, publishable section for
for exactly that version. Fragment mode has no re-armed shape — there exactly that version. Fragment mode has no re-armed shape — there is
is nothing left to re-arm. nothing left to re-arm.
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
verbatim; both shapes stay supported so a consumer adopts fragments on a verbatim; both shapes stay supported so a consumer adopts fragments on a pin
pin bump, on its own schedule (#112 D8): bump, on its own schedule (#112 D8):
- `-dev` tree → the top section **must** be `## Unreleased`. - `-dev` tree → the top section **must** be `## Unreleased`.
- bare tree (the ceremony PR and its merge) → the top section may be - 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). uses, so the two cannot disagree about what a section is).
**The incident**: box#108 / rig#66 — the silent mislanding described **The incident**: box#108 / rig#66 — the silent mislanding described
[above](#what-a-release-is). Fragment mode retires the incident's [above](#what-a-release-is). Fragment mode retires the incident's mechanism
mechanism outright; legacy mode guards it. **Red means** a PR entry has outright; legacy mode guards it. **Red means** a PR entry has nowhere safe
nowhere safe to land — a missing marker, a surviving `## Unreleased`, a to land — a missing marker, a surviving `## Unreleased`, a malformed
malformed fragment — or a stamped version would publish no entries, a fragment — or a stamped version would publish no entries, a dangling grouped
dangling grouped heading, or a bare tree still carrying fragments the heading, or a bare tree still carrying fragments the stamp did not consume
stamp did not consume (`not consumed` — re-run the assembler); the (`not consumed` — re-run the assembler); the message names the fix in every
message names the fix in every case. What this guard cannot see is a case. What this guard cannot see is a fragment that *was* consumed but whose
fragment that *was* consumed but whose entry the stamp omits — the entry the stamp omits — the fragment is gone from HEAD, so only
fragment is gone from HEAD, so only
[changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s [changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s
merge-base replay catches that loss. merge-base replay catches that loss.
**Do not "simplify" this to "always require `## Unreleased`".** The **Do not "simplify" this to "always require `## Unreleased`".** The
unconditional form is false by construction on the ceremony PR's own tree 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 it makes every release unshippable — and rig#44 and cast#108 both had to
to revert exactly that revert exactly that. The version-keyed form is what rig and cast get back by
([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)). adopting this repo.
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 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 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 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 `-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 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 into. Fragment mode has no such window: with no re-arm step there is nothing
nothing to forget. to forget.
### changelog-assembled — the stamp is exactly the fragments ### 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)): ([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 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 **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 reads the fragments as of the merge base (they are gone from HEAD — that is
is the point of the ceremony), replays `changelog-assemble --check` over the point of the ceremony), replays `changelog-assemble --check` over that
that set, and diffs the result against HEAD's section body. Every tree it set, and diffs the result against HEAD's section body. Every tree it does
does not apply to — a `-dev` tree, legacy mode, no consumed fragments — not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes
passes with a green `NOTICE`, so a non-ceremony PR is never red here. 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 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) 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 and a mis-run hand step can leave no trace. The two failure shapes differ,
differ, and the guards split them exactly as and the guards split them exactly as
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s [test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio
trio rows record: leave a fragment **out of the deletion** and it survives rows record: leave a fragment **out of the deletion** and it survives on
on HEAD, where HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed)
[changelog-armed](#changelog-armed--main-never-sits-disarmed) already already refuses the bare tree (`not consumed`) — this guard goes red too,
refuses the bare tree (`not consumed`) — this guard goes red too, naming naming the entry the section lost. But **delete** a fragment while omitting
the entry the section lost. But **delete** a fragment while omitting its its entry from the stamp, or hand-edit one word of the assembled prose, and
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
nothing on HEAD is out of place: armed is green, monotonic is green, and publisher would happily publish history that is not what the authors wrote.
the publisher would happily publish history that is not what the authors Only the merge-base replay catches those. The replay is what makes a
wrote. Only the merge-base replay catches those. The replay is what hand-run step safe. **This guard needs history** — same stance as the
makes a hand-run step safe. **This guard needs history** — same stance as monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard
the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a failure, not a skip.
hard failure, not a skip.
### changelog-monotonic — shipped headings are append-only ### changelog-monotonic — shipped headings are append-only
**The rule** **The rule**
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)): ([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 of `## X.Y.Z` headings on your branch must be a **superset** of the
the set at the merge base, and no heading may appear twice on HEAD. The set at the merge base, and no heading may appear twice on HEAD. The rule
rule needs no tuning because release headings are append-only by doctrine: needs no tuning because release headings are append-only by doctrine: the
the ceremony adds one and nothing ever legitimately removes one — so ceremony adds one and nothing ever legitimately removes one — so superset
superset has no exception to carve. The ceremony's own stamp passes by has no exception to carve. The ceremony's own stamp passes by construction:
construction: the assembler writes a new `## X.Y.Z — DATE` heading and the assembler writes a new `## X.Y.Z — DATE` heading and removes none.
removes none. Fragment mode changes nothing here (#112 D10): fragments add Fragment mode changes nothing here (#112 D10): fragments add no `## `
no `## ` heading, and `Unreleased` was never in the guard's set — it is heading, and `Unreleased` was never in the guard's set — it is not a version
not a version heading; it is heading; it is
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business — [changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business —
which is why a repo's adoption PR can delete it and stay green. 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 **The incidents**: box#122 (caught in review of box#118) — an author adding
adding an entry under `## Unreleased` **replaced** the heading below it an entry under `## Unreleased` **replaced** the heading below it instead of
instead of inserting above it; git merges that cleanly, and the shipped inserting above it; git merges that cleanly, and the shipped section's body
section's body is silently absorbed into `## Unreleased`. And box#118 is silently absorbed into `## Unreleased`. And box#118 itself — a bad rebase
itself — a bad rebase *duplicated* a shipped heading, which containment is *duplicated* a shipped heading, which containment is blind to, which is why
blind to, which is why uniqueness-on-HEAD is a separate assert 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 **Red means** a shipped section was deleted (put the heading back and insert
insert **above** it) or duplicated (collapse to one heading; the failure **above** it) or duplicated (collapse to one heading; the failure message
message walks through both fixes with the diff to run). **This guard needs 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 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 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 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 ### drill-recorded — a release carries its evidence
**The rule** **The rule**
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)), ([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 keyed on the tree's version: a `-dev` tree passes with nothing to assert (a
(a development tree ships nothing); a bare tree — the ceremony PR and its development tree ships nothing); a bare tree — the ceremony PR and its merge
merge — must carry `drills/<version>.md` with at least one — must carry `drills/<version>.md` with at least one non-whitespace
non-whitespace character. One file per version, so `0.9.0.md` and character. One file per version, so `0.9.0.md` and `0.9.0-rc1.md` are simply
`0.9.0-rc1.md` are simply different files and prefix confusion is different files and prefix confusion is unrepresentable (#1 constraint 7).
unrepresentable (#1 constraint 7).
**The incident**: box's CONTRIBUTING said since box#96 that the release **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 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 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 the gate was a sentence in a document and the only thing standing on it was
was a reviewer remembering to ask. The rule moved into CI, where it fires a reviewer remembering to ask. The rule moved into CI, where it fires
whether or not anyone is paying attention. whether or not anyone is paying attention.
**Red means** the release is asserting a ritual it left no evidence of. **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 **The fix is to run the drill** and record it — or to waive it *in writing*
writing* at the same path: the guard demands a **record, not a passing at the same path: the guard demands a **record, not a passing result**
result** ([below](#the-drill-doctrine)). ([below](#the-drill-doctrine)).
## The drill doctrine ## The drill doctrine
**Evidence, not success.** The guard asserts a record exists — a failed **Evidence, not success.** The guard asserts a record exists — a failed
drill honestly written down satisfies it, and so does a maintainer waiver 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 that says plainly the drill was waived and why. What it refuses is silence:
silence: a skip must cost a deliberate, reviewable file in the diff, a skip must cost a deliberate, reviewable file in the diff, which is
which is precisely what box's three silent skips never produced. CI precisely what box's three silent skips never produced. CI cannot run a
cannot run a consumer's drill (box's wants real hardware and the better consumer's drill (box's wants real hardware and the better part of an hour);
part of an hour); it can only refuse a release that never ran one. it can only refuse a release that never ran one.
**Each repo defines what its drill *means*** — the gate only reads the **Each repo defines what its drill *means*** — the gate only reads the
record. box asserts the **isolation contract**; rig asserts record. box asserts the **isolation contract**; rig asserts **convergence**
**convergence** (a machine reaches its role, idempotently); cast asserts (a machine reaches its role, idempotently); cast asserts **promotion** (A→B
**promotion** (A→B reproduces, the diff is idempotent); ceremony's own reproduces, the diff is idempotent); ceremony's own drill is a **door
drill is a **door rehearsal** — both doors exercised end-to-end on a rehearsal** — both doors exercised end-to-end on a disposable repo, written
disposable repo (#11 names the six probes); incubator's is TBD in 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 heavy-duty/incubator. Each repo states its meaning in its own
`drills/README.md`. Three different exercises sharing a substrate is why `drills/README.md`. Three different exercises sharing a substrate is why the
the records are per-repo — they are not phases of one script. records are per-repo — they are not phases of one script.
**Drills exercise candidate refs, not released artifacts.** A ref is a **Drills exercise candidate refs, not released artifacts.** A ref is a
static identifier that exists as soon as the release branch does, so no static identifier that exists as soon as the release branch does, so no repo
repo has to be released — or drilled — before another can be drilled: has to be released — or drilled — before another can be drilled: what looks
what looks like a box↔rig recursion at runtime dissolves into two like a box↔rig recursion at runtime dissolves into two independent tests
independent tests against one fixed pair of refs. And drilling the against one fixed pair of refs. And drilling the candidate *is* drilling the
candidate *is* drilling the release: a ceremony PR's diff is the stamps release: a ceremony PR's diff is the stamps and nothing else, so no
and nothing else, so no executable byte differs between the tree that was executable byte differs between the tree that was drilled and the tree that
drilled and the tree that ships. ships.
**A cross-repo release set shares one run ID.** Each repo records its own **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 legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling SHAs,
SHAs, so the records reconcile afterwards — but the guard only ever reads so the records reconcile afterwards — but the guard only ever reads the repo
the repo it runs in. If a defect shows up only in the combination: patch, it runs in. If a defect shows up only in the combination: patch, re-drill,
re-drill, re-record. The set converges; it is not required to be right in re-record. The set converges; it is not required to be right in one pass.
one pass.
## Troubleshooting red main ## Troubleshooting red main
Every refusal the release flow can emit, verbatim, with cause and remedy. Every refusal the release flow can emit, verbatim, with cause and remedy.
The catalog is generated from the sources, not paraphrased — regenerate The catalog is generated from the sources, not paraphrased — regenerate it
it with: with:
```sh ```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 .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 '$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 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 The fact-gathering guards ([L92L105](lib/decide.sh#L92-L105),
([L92L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135), [L135](lib/decide.sh#L135), [L151](lib/decide.sh#L151)): a missing fact must
[L151](lib/decide.sh#L151)): a missing fact must never fall through to never fall through to "no". These indicate a bug upstream in
"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the [lib/facts.sh](lib/facts.sh) or the workflow plumbing, not an operator
workflow plumbing, not an operator mistake — read the run's `facts:` mistake — read the run's `facts:` stderr line and file what you find.
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)) ### 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 > version_read: node is required for version-source: package-json
[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is [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 missing, empty, or unreadable. A wrong release is worse than a missing one,
one, so an unreadable state is never an empty print — restore the so an unreadable state is never an empty print — restore the `VERSION` file
`VERSION` file (or `package.json` version field) on main. (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 > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
[L202L205](.github/workflows/release.yml#L202-L205): the ceremony merged [L202L205](.github/workflows/release.yml#L202-L205): the ceremony merged
without its stamp (a state the without its stamp (a state the
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses [armed guard](#changelog-armed--main-never-sits-disarmed) already refuses on
on the PR — red main here means it was overridden). Stamp the section on the PR — red main here means it was overridden). Stamp the section on main,
main, then publish by the tag door. 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. > 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. > release '$VER' already exists — refusing to re-release, creating nothing.
[L207L222](.github/workflows/release.yml#L207-L222), the nothing-exists [L208L223](.github/workflows/release.yml#L208-L223), the nothing-exists
assert — what makes a re-run of a completed ceremony refuse instead of 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 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 truly exists, there is nothing to do: this red is the system declining to do
do the thing twice. If the tag exists but the release does not (a manual the thing twice. If the tag exists but the release does not (a manual tag
tag won the race, or won the race, or
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by [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 the tag door: delete and re-push the tag, or `gh release create` by hand
from a fixed tree. from a fixed tree.
> direct push refused (branch protection?) — opening the bump PR instead > direct push refused (branch protection?) — opening the bump PR instead
[L292L300](.github/workflows/release.yml#L292-L300) — loud, but not a [L293L301](.github/workflows/release.yml#L293-L301) — loud, but not a
refusal: the post-release `-dev` bump could not push directly, so the run 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 — opened a `release`-labeled bump PR itself. Your move: merge it promptly —
until it lands, main is sitting bare, where a dev install until it lands, main is sitting bare, where a dev install impersonates the
[impersonates the release](.github/workflows/release.yml#L291) and the release and the
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays [armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
open. 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. > 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. > 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.
[L346L349](.github/workflows/release.yml#L346-L349). The tagged tree was [L346L349](.github/workflows/release.yml#L346-L349). The tagged tree was
never stamped. Assemble the section never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), ([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
then delete and re-push the tag. delete and re-push the tag.
### Red main that is not the release workflow ### Red main that is not the release workflow
Consumer CI runs its guard steps on pushes to main too (this repo's 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 [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 operator will actually meet on main is **changelog-armed after a re-arm was
was forgotten — legacy mode only**: the ceremony stamped without putting forgotten — legacy mode only**: the ceremony stamped without putting
`## Unreleased` back, the release's own `-dev` bump landed, and the guard `## Unreleased` back, the release's own `-dev` bump landed, and the guard now
now says (first line): says (first line):
> changelog-armed: the version is '$ver' (a development tree) but the top > changelog-armed: the version is '$ver' (a development tree) but the top
> section of $changelog is: … > section of $changelog is: …
The fix is a one-line PR: add an empty `## Unreleased` above the stamped 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 section. The full message carries the same instruction. Fragment mode has no
no re-arm to forget, so it has no equivalent red on main — its refusals re-arm to forget, so it has no equivalent red on main — its refusals (a
(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed missing marker, a surviving `## Unreleased`, a malformed or unconsumed
fragment) all fire on the PR that caused them, where the author is still fragment) all fire on the PR that caused them, where the author is still
holding it. holding it.
## Design lineage ## Design lineage
The ceremony converged across box#83 → box#96, rig#32 → rig#47, and The ceremony converged across box#83 → box#96, rig#32 → rig#47 and cast#96 →
cast#96 → cast#111; this repo is those three implementations folded into cast#111; this repo is those three implementations folded into one, and the
one (the drift that motivated it is measured in drift that motivated it is measured in
[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing [#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the
constraints — each bought with an incident, none of them safe to load-bearing constraints — each bought with an incident, none of them safe
"simplify" away — are listed in to "simplify" away. The label machine's own record is #10, #11 and #130; the
[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and
their war stories, in the headers of the scripts they bind: #116; the sweep/trigger split is #209.
[release.yml](.github/workflows/release.yml#L1-L109),
[lib/decide.sh](lib/decide.sh#L1-L74), The narrative lives in those issues, by design: the war stories are carried
[lib/facts.sh](lib/facts.sh#L1-L24), and the four in the headers of the scripts they bind —
[guard scripts](actions/). The comments are the documentation of record; [release.yml](.github/workflows/release.yml),
this README is their operator-facing cut. [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.