2026-07-22 22:43:59 +00:00
# ceremony
2026-08-04 20:22:13 +00:00
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
README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
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
2026-08-04 22:44:02 +00:00
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.
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
- **Operating a release, or staring at a red run on main** → read on.
## What a release is
**A release is a PR, and merging it ships it** (box#96, building on box#83;
rig#47, cast#111 converged on the same doctrine). The ceremony PR —
`release: X.Y.Z` , carrying the hand-set `release` label — makes three
stamps:
1. **The version goes bare** : `X.Y.Z-dev` → `X.Y.Z`
([lib/version.sh](lib/version.sh)).
2026-07-24 10:22:12 +00:00
2. **The changelog section is assembled — one edit, produced by the tool**
(#112). Entries never accumulate in `CHANGELOG.md` : each PR wrote one
2026-08-04 20:22:13 +00:00
fragment file, `changelog.d/<issue>.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
2026-07-24 10:22:12 +00:00
[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.
There is no second edit: the old stamp *re-armed* — put an empty
`## 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
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
3. **The drill record is present** : `drills/X.Y.Z.md` , non-blank — the
2026-08-04 20:22:13 +00:00
evidence the release rests on ([the drill doctrine](#the-drill-doctrine)).
2026-07-22 22:43:59 +00:00
2026-08-04 20:22:13 +00:00
(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.)
2026-07-22 22:43:59 +00:00
**The merge is the ship decision; the tag is transcription.** After the
2026-08-31 15:55:21 +00:00
merge, [release.yml ](.github/workflows/release.yml#L136-L310 ) asserts its
2026-08-30 09:27:56 +00:00
way to certainty, tags the merge commit, publishes the forge release with
2026-07-22 22:43:59 +00:00
the version's own changelog section as the body — the curated prose, never
2026-08-04 20:22:13 +00:00
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
canonical extractor, and [bin/changelog-section ](bin/changelog-section ) is
2026-08-04 22:12:29 +00:00
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
README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
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
2026-08-04 22:44:02 +00:00
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
2026-08-04 23:29:12 +00:00
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
README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
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
2026-08-04 22:44:02 +00:00
[artifact hook ](docs/CONSUMERS.md#the-artifact-hook ) sits between the tag and
2026-08-04 23:29:12 +00:00
the publish, so its non-zero exit aborts, and the publish itself
2026-08-31 11:21:09 +00:00
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
2026-08-04 23:29:12 +00:00
can fail on the API call or the assets. Either leaves the same state — a tag
2026-08-31 11:21:09 +00:00
standing and no release — which the merge-door preflight recognizes and a
re-run resumes. The tag door remains the fallback when the original run is no
longer reachable or the release must come from a fixed tree. 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.
2026-07-22 22:43:59 +00:00
## The two doors
2026-08-04 20:22:13 +00:00
- **The merge door — the paved road.** A push to main runs the
2026-07-22 22:43:59 +00:00
[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
2026-08-04 20:22:13 +00:00
half-ceremony dies loudly
2026-08-31 15:55:21 +00:00
([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
2026-08-04 20:22:13 +00:00
normal release.
2026-07-22 22:43:59 +00:00
2026-08-04 20:22:13 +00:00
- **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
2026-08-31 15:55:21 +00:00
([release.yml](.github/workflows/release.yml#L325-L410)) — publishes the
2026-07-22 22:43:59 +00:00
same way. The tag is the operator's explicit act, so there is no decide
2026-08-30 09:33:12 +00:00
and no label check — what is left is three asserts: **the tag names the
README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
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
2026-08-04 22:44:02 +00:00
tree's own version**
2026-08-31 15:55:21 +00:00
([L350– L361](.github/workflows/release.yml#L350-L361)), **the tagged
README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
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
2026-08-04 22:44:02 +00:00
tree carries a publishable `## X.Y.Z` section**
2026-08-31 15:55:21 +00:00
([L362– L374](.github/workflows/release.yml#L362-L374)), and **no published
2026-08-30 09:33:12 +00:00
release already exists for the tag**
2026-08-31 15:55:21 +00:00
([L375– L390](.github/workflows/release.yml#L375-L390)); any failure
README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
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
2026-08-04 22:44:02 +00:00
refuses, creating nothing. No `-dev` bump either
2026-08-04 20:22:13 +00:00
— the fallback does not rewrite main (cast's precedent). Use it when the
merge path is red, for backfills, and for the
2026-07-22 22:43:59 +00:00
[first-release edge ](#what-happens-when-my-pr-lands-on-main ) (row 4).
2026-08-04 20:22:13 +00:00
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).
2026-07-22 22:43:59 +00:00
## What happens when my PR lands on main
The merge door runs on **every** push to main, and the `release` label
legitimately means two things (release ceremonies, and ordinary work *on*
2026-07-22 23:07:37 +00:00
the release machinery), so the door's first act is a decision: the six-row
2026-07-22 22:43:59 +00:00
table in [lib/decide.sh ](lib/decide.sh#L29-L61 ) (issue #8 — the comment
2026-08-04 20:22:13 +00:00
block *is* the spec, and the table is contract-tested offline by
[test/decide.test.sh ](test/decide.test.sh )). Rendered for operators:
2026-07-22 22:43:59 +00:00
| # | the tree your merge produced | the run | what it means — and your move |
|---|---|---|---|
| 1 | version `-dev` , unchanged | green `NOTICE` , no-op | Almost every PR — including release-flow work under the `release` label. Nothing to publish, nothing to do. |
| 2 | version changed, still `-dev` | green `NOTICE` , no-op | The post-release bump, or a renumber. "A dev tree is by definition not a release." Nothing to do. |
| 3 | version bare, unchanged, already released | green `NOTICE` , no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh ](lib/decide.sh#L70-L74 )). |
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
2026-08-04 21:38:27 +00:00
| 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)). |
2026-07-22 22:43:59 +00:00
2026-08-04 20:22:13 +00:00
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
2026-07-22 22:43:59 +00:00
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
automation never guesses; the version transition is the interlock, and
label-without-transition (row 4) and transition-without-label (row 5) both
refuse (#1 constraint 8).
## The guards
2026-08-04 20:22:13 +00:00
[`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.
2026-07-22 22:43:59 +00:00
### changelog-armed — main never sits disarmed
2026-07-24 10:22:12 +00:00
**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** —
2026-08-04 20:22:13 +00:00
`changelog.d/` exists, the arming property moved onto the directory (#112
D7):
2026-07-24 10:22:12 +00:00
- always → the marker `changelog.d/README.md` must exist (what keeps the
2026-08-04 20:22:13 +00:00
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 `<issue>.md` or
`<repo>-<issue>.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.
2026-07-24 10:22:12 +00:00
- bare tree (the ceremony PR and its merge) → every fragment must be
2026-08-04 20:22:13 +00:00
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.
2026-07-24 10:22:12 +00:00
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
2026-08-04 20:22:13 +00:00
verbatim; both shapes stay supported so a consumer adopts fragments on a pin
bump, on its own schedule (#112 D8):
2026-07-22 22:43:59 +00:00
- `-dev` tree → the top section **must** be `## Unreleased` .
- bare tree (the ceremony PR and its merge) → the top section may be
`## Unreleased` (re-armed) *or* the stamped section for exactly that
2026-07-23 23:44:21 +00:00
version — **and** that version's section must exist, carry at least one
`-` or `*` entry, and have no `### ` heading without an entry before the
next heading or section end. A heading is not an entry. These publication
rules do not apply to `Unreleased` : the empty three-heading template is
deliberately valid there (the half-ceremony refusal, rig#67: version
bumped, stamp missing — asserted through the very extractor the publisher
uses, so the two cannot disagree about what a section is).
2026-07-22 22:43:59 +00:00
**The incident**: box#108 / rig#66 — the silent mislanding described
2026-08-04 20:22:13 +00:00
[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
2026-07-24 10:46:12 +00:00
[changelog-assembled ](#changelog-assembled--the-stamp-is-exactly-the-fragments )'s
merge-base replay catches that loss.
2026-07-22 22:43:59 +00:00
**Do not "simplify" this to "always require `## Unreleased` ".** The
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
2026-07-24 10:22:12 +00:00
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
2026-08-04 20:22:13 +00:00
into. Fragment mode has no such window: with no re-arm step there is nothing
to forget.
2026-07-24 10:22:12 +00:00
### changelog-assembled — the stamp is exactly the fragments
**The rule**
([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
2026-08-04 20:22:13 +00:00
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.
2026-07-24 10:22:12 +00:00
**The failure it catches** (#116): assembly is a hand-run step by design —
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
### changelog-monotonic — shipped headings are append-only
**The rule**
2026-08-04 20:22:13 +00:00
([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
2026-07-24 10:22:12 +00:00
[changelog-armed ](#changelog-armed--main-never-sits-disarmed )'s business —
which is why a repo's adoption PR can delete it and stay green.
2026-07-22 22:43:59 +00:00
2026-08-04 20:22:13 +00:00
**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
2026-07-22 22:43:59 +00:00
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
2026-08-04 20:22:13 +00:00
stop guarding is the failure shape this family of checks exists to refuse.
2026-07-22 22:43:59 +00:00
### drill-recorded — a release carries its evidence
**The rule**
2026-08-04 20:22:13 +00:00
([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/<version>.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).
2026-07-22 22:43:59 +00:00
**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
2026-08-04 20:22:13 +00:00
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
2026-07-22 22:43:59 +00:00
whether or not anyone is paying attention.
**Red means** the release is asserting a ritual it left no evidence of.
2026-08-04 20:22:13 +00:00
**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)).
2026-07-22 22:43:59 +00:00
## 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
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
**Each repo defines what its drill *means* ** — the gate only reads the
2026-08-04 20:22:13 +00:00
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
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
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.
2026-07-22 22:43:59 +00:00
**Drills exercise candidate refs, not released artifacts.** A ref is a
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
**A cross-repo release set shares one run ID.** Each repo records its own
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
## Troubleshooting red main
Every refusal the release flow can emit, verbatim, with cause and remedy.
2026-08-04 20:22:13 +00:00
The catalog is generated from the sources, not paraphrased — regenerate it
with:
2026-07-22 22:43:59 +00:00
```sh
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
grep -n -A2 'refuse \|>& 2' \
lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml
2026-07-22 22:43:59 +00:00
```
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
`$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.
2026-07-22 22:43:59 +00:00
### The decision refused ([lib/decide.sh](lib/decide.sh))
> the version '$VER' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing.
> (If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. A first release whose version never carried -dev ships by the tag door — the known first-release edge.)
Row 4 ([L129– L133](lib/decide.sh#L129-L133)). The message is the remedy:
drop the label, or re-do the ceremony with the bump, or take the tag door.
> the version transitioned ('$BASE_VER' -> '$VER') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
Row 5 ([L147– L149](lib/decide.sh#L147-L149)). Someone pushed or merged a
version transition without the `release` label. Label a proper ceremony PR,
or — if the tree is genuinely the release — publish by the tag door.
> VER is empty — the caller failed to establish the version at the pushed head. Refusing to decide — creating nothing.
> BASE_VER is empty — the caller failed to establish the version at the base. Refusing to decide — creating nothing.
> RELEASED='${RELEASED}' — expected yes, no, or empty. Refusing to decide — creating nothing.
> LABELED='${LABELED}' — expected yes, no, or empty. Refusing to decide — creating nothing.
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh))
> facts: unknown VERSION_SOURCE '$VERSION_SOURCE' — expected file or package-json
[L37 ](lib/facts.sh#L37 ): the caller's `version-source:` input is neither
`file` nor `package-json` . Fix the caller.
> version_read: $path: no such file
> version_read: $path is empty
> version_read: $path: no version field
> version_read: node is required for version-source: package-json
[lib/version.sh ](lib/version.sh#L16-L66 ): the tree's version source is
2026-08-04 20:22:13 +00:00
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.
2026-07-22 22:43:59 +00:00
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
> 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.
2026-08-31 15:55:21 +00:00
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310))
2026-07-22 22:43:59 +00:00
> 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
2026-08-04 20:22:13 +00:00
[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.
2026-07-22 22:43:59 +00:00
2026-08-31 11:21:09 +00:00
> release '$VER' already exists — this release already happened; refusing to re-release, creating nothing.
> tag '$VER' already exists at <tag sha> but this run would tag <MERGE_SHA> — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit.
> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run.
[L208– L239 ](.github/workflows/release.yml#L208-L239 ), the merge-door
preflight — the published-release refusal prevents clobbering, the
different-commit refusal diagnoses a racing or manual tag with both SHAs, and
the notice resumes this door after its tag succeeded but the artifact hook or
publish failed. Re-run the merge-door job first. If that run is no longer
reachable or the tree itself needs repair, use the tag-door fallback: delete
and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
2026-07-22 22:43:59 +00:00
> direct push refused (branch protection?) — opening the bump PR instead
2026-08-31 15:55:21 +00:00
[L302– L310 ](.github/workflows/release.yml#L302-L310 ) — loud, but not a
2026-07-22 22:43:59 +00:00
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 —
2026-08-04 20:22:13 +00:00
until it lands, main is sitting bare, where a dev install impersonates the
release and the
2026-07-22 22:43:59 +00:00
[armed guard's window ](#changelog-armed--main-never-sits-disarmed ) stays
open.
2026-08-31 15:55:21 +00:00
### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
2026-07-22 22:43:59 +00:00
> 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.
2026-08-31 15:55:21 +00:00
[L356– L359 ](.github/workflows/release.yml#L356-L359 ). The message is the
2026-07-22 22:43:59 +00:00
remedy.
2026-07-24 10:22:12 +00:00
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
2026-07-22 22:43:59 +00:00
2026-08-31 15:55:21 +00:00
[L368– L374 ](.github/workflows/release.yml#L368-L374 ). The tagged tree was
2026-07-24 10:22:12 +00:00
never stamped. Assemble the section
2026-08-04 20:22:13 +00:00
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
delete and re-push the tag.
2026-07-22 22:43:59 +00:00
2026-08-30 09:33:12 +00:00
> release '$VER' already exists — refusing to re-release, creating nothing.
2026-08-31 15:55:21 +00:00
[L375– L390 ](.github/workflows/release.yml#L375-L390 ). A published release is
2026-08-30 09:33:12 +00:00
never replaced by the fallback. If it is correct, there is nothing to do; if
it is wrong, correct that published artifact deliberately before retrying.
2026-08-31 15:55:21 +00:00
### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310))
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
2026-08-04 21:04:10 +00:00
The bump belongs to the merge door alone — the tag door deliberately does not
2026-08-31 15:55:21 +00:00
rewrite main ([L325– L329](.github/workflows/release.yml#L325-L329)) — and it
2026-08-04 21:04:10 +00:00
runs *after* the tag, the notes and the publish. So a refusal here leaves a
2026-08-04 21:38:27 +00:00
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
2026-08-31 15:55:21 +00:00
shipped ([L275](.github/workflows/release.yml#L275)). That is the one failure
2026-08-04 21:38:27 +00:00
in this catalog whose remedy is a manual bump, not a re-run.
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
> version_next_dev: refusing '$ver' — expected bare X.Y.Z
2026-08-04 21:38:27 +00:00
[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
2026-08-04 21:42:23 +00:00
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`
2026-08-04 21:40:41 +00:00
([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing
2026-08-04 21:38:27 +00:00
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
2026-08-04 22:12:29 +00:00
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
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) <noreply@anthropic.com>
2026-08-04 22:57:43 +00:00
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.
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
> version_write: npm is required for version-source: package-json
2026-08-04 21:38:27 +00:00
[L106 ](lib/version.sh#L106 ): the package-json backend needs npm to write —
`npm pkg set version=` plus a lockfile-only `npm install`
2026-08-04 21:40:41 +00:00
([L114– L115](lib/version.sh#L114-L115)), never `npm version` , which would tag
2026-08-04 21:42:23 +00:00
— 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.
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
> 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.
2026-08-04 21:38:27 +00:00
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
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
protection is expected, and the step opens the bump PR itself rather than
2026-08-31 15:55:21 +00:00
failing ([L302– L310](.github/workflows/release.yml#L302-L310)).
README: re-measure the incubator drill and close the refusal catalog
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.
2026-08-04 20:59:30 +00:00
2026-07-22 22:43:59 +00:00
### 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
2026-08-04 20:22:13 +00:00
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):
2026-07-22 22:43:59 +00:00
> 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
2026-08-04 20:22:13 +00:00
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
2026-07-24 10:22:12 +00:00
fragment) all fire on the PR that caused them, where the author is still
holding it.
2026-07-22 22:43:59 +00:00
## Design lineage
2026-08-04 20:22:13 +00:00
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.