forked from heavy-duty/ceremony
Merge pull request #48 from claude-bot-andresmgsl/build/12-docs-doctrine
docs: README doctrine + CONSUMERS.md adoption guide
This commit is contained in:
commit
682d80eddb
3 changed files with 624 additions and 7 deletions
1
README
1
README
|
|
@ -1 +0,0 @@
|
||||||
## ceremony
|
|
||||||
385
README.md
Normal file
385
README.md
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
# ceremony
|
||||||
|
|
||||||
|
One release ceremony for the whole heavy-duty family — implemented once,
|
||||||
|
tested once, documented here, consumed everywhere else by reference. The
|
||||||
|
approach and its constraints live in
|
||||||
|
[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the
|
||||||
|
operator-facing doctrine that used to live, three times over, in the
|
||||||
|
consumers' CONTRIBUTINGs.
|
||||||
|
|
||||||
|
- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md).
|
||||||
|
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you;
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics.
|
||||||
|
- **Operating a release, or staring at a red run on main** → read on.
|
||||||
|
|
||||||
|
## What a release is
|
||||||
|
|
||||||
|
**A release is a PR, and merging it ships it** (box#96, building on box#83;
|
||||||
|
rig#47, cast#111 converged on the same doctrine). The ceremony PR —
|
||||||
|
`release: X.Y.Z`, carrying the hand-set `release` label — makes three
|
||||||
|
stamps:
|
||||||
|
|
||||||
|
1. **The version goes bare**: `X.Y.Z-dev` → `X.Y.Z`
|
||||||
|
([lib/version.sh](lib/version.sh)).
|
||||||
|
2. **The changelog is stamped *and re-armed* — two edits, not one**
|
||||||
|
(box#108). `## Unreleased` becomes `## X.Y.Z — DATE`, and an **empty
|
||||||
|
`## Unreleased` goes back on top**, immediately above it:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.7.1 — 2026-07-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The second edit is not cosmetic and not deferrable. Between the stamp
|
||||||
|
and the next re-creation of that heading, main has no `## Unreleased`. A
|
||||||
|
PR authored *before* the release wrote its entry under that heading;
|
||||||
|
with the heading gone, git lands the entry under whatever now occupies
|
||||||
|
the position — **the section that just shipped** — and it merges
|
||||||
|
cleanly, no conflict, no signal. The changelog then credits a released
|
||||||
|
version with a change it does not contain, and nothing but a human
|
||||||
|
reading the file will ever say so (box#108; confirmed cross-repo as
|
||||||
|
rig#66). The [armed guard](#changelog-armed--main-never-sits-disarmed)
|
||||||
|
exists because of exactly this edit.
|
||||||
|
|
||||||
|
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
|
||||||
|
evidence the release rests on
|
||||||
|
([the drill doctrine](#the-drill-doctrine)).
|
||||||
|
|
||||||
|
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the
|
||||||
|
ref consumers' runs fetch this repo at — moves to the version being
|
||||||
|
released, in [release.yml](.github/workflows/release.yml#L123-L132) and
|
||||||
|
every other workflow that carries it.
|
||||||
|
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not
|
||||||
|
a consumer's release, when it is stale.)
|
||||||
|
|
||||||
|
**The merge is the ship decision; the tag is transcription.** After the
|
||||||
|
merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its
|
||||||
|
way to certainty, tags the merge commit, publishes the GitHub release with
|
||||||
|
the version's own changelog section as the body — the curated prose, never
|
||||||
|
the generated PR list
|
||||||
|
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) —
|
||||||
|
and re-arms main by bumping to `X.Y.(Z+1)-dev`
|
||||||
|
([release.yml](.github/workflows/release.yml#L266-L300)). The machine does
|
||||||
|
the transcription because humans err silently and machines fail loudly:
|
||||||
|
**everything asserts its way to certainty and fails loudly, creating
|
||||||
|
nothing** — a wrong release is worse than a missing one, so every failed
|
||||||
|
assert leaves zero artifacts: no tag, no release, no bump.
|
||||||
|
|
||||||
|
## The two doors
|
||||||
|
|
||||||
|
- **The merge door — the paved road.** A push to main
|
||||||
|
([release.yml](.github/workflows/release.yml#L140)) runs the
|
||||||
|
[decide table](#what-happens-when-my-pr-lands-on-main); a merged,
|
||||||
|
`release`-labeled PR whose version transitioned to bare is the ceremony,
|
||||||
|
everything legitimate that isn't one is a green no-op, and every
|
||||||
|
half-ceremony dies loudly. Use it for every normal release.
|
||||||
|
|
||||||
|
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag
|
||||||
|
push — **no `v` prefix**, box's 0.6.0 set the scheme
|
||||||
|
([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the
|
||||||
|
same way. The tag is the operator's explicit act, so there is no decide
|
||||||
|
and no label check; the one assert is that **the tag names the tree's
|
||||||
|
own version**, and a mismatch refuses, creating nothing. No `-dev` bump
|
||||||
|
either — the fallback does not rewrite main (cast's precedent). Use it
|
||||||
|
when the merge path is red, for backfills, and for the
|
||||||
|
[first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4).
|
||||||
|
|
||||||
|
Tag + publish (+ the consumer's artifact hook) happen **in the same job,
|
||||||
|
on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's
|
||||||
|
anti-recursion), so the merge door's tag can never re-enter the tag door
|
||||||
|
and double-publish — and that job is the release's only chance to publish
|
||||||
|
([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2).
|
||||||
|
|
||||||
|
## What happens when my PR lands on main
|
||||||
|
|
||||||
|
The merge door runs on **every** push to main, and the `release` label
|
||||||
|
legitimately means two things (release ceremonies, and ordinary work *on*
|
||||||
|
the release machinery), so the door's first act is a decision: the six-row
|
||||||
|
table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment
|
||||||
|
block *is* the spec, and the table is contract-tested offline). Rendered
|
||||||
|
for operators:
|
||||||
|
|
||||||
|
| # | the tree your merge produced | the run | what it means — and your move |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | version `-dev`, unchanged | green `NOTICE`, no-op | Almost every PR — including release-flow work under the `release` label. Nothing to publish, nothing to do. |
|
||||||
|
| 2 | version changed, still `-dev` | green `NOTICE`, no-op | The post-release bump, or a renumber. "A dev tree is by definition not a release." Nothing to do. |
|
||||||
|
| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
|
||||||
|
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). |
|
||||||
|
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
|
||||||
|
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. |
|
||||||
|
|
||||||
|
The green rows are the point as much as the red ones: the machinery must
|
||||||
|
be safe to work on, so every legitimate non-ceremony is a green `NOTICE`
|
||||||
|
no-op — never a red run on main per infra PR
|
||||||
|
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
|
||||||
|
automation never guesses; the version transition is the interlock, and
|
||||||
|
label-without-transition (row 4) and transition-without-label (row 5) both
|
||||||
|
refuse (#1 constraint 8).
|
||||||
|
|
||||||
|
## The guards
|
||||||
|
|
||||||
|
Three composite actions run in every consumer's CI (and in this repo's
|
||||||
|
own). Shared shape: version-keyed where the tree's state matters, loud
|
||||||
|
where it fails, and **a file of its own so a test can drive it**. The full
|
||||||
|
war stories are in the scripts' header comments — authoritative and longer
|
||||||
|
than this; what follows is the operator's cut.
|
||||||
|
|
||||||
|
### changelog-armed — main never sits disarmed
|
||||||
|
|
||||||
|
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh#L27-L36)),
|
||||||
|
keyed on the tree's version:
|
||||||
|
|
||||||
|
- `-dev` tree → the top section **must** be `## Unreleased`.
|
||||||
|
- bare tree (the ceremony PR and its merge) → the top section may be
|
||||||
|
`## Unreleased` (re-armed) *or* the stamped section for exactly that
|
||||||
|
version — **and** that version's section must exist and carry prose,
|
||||||
|
because it is the one about to ship (the half-ceremony refusal, rig#67:
|
||||||
|
version bumped, stamp missing — asserted through the very extractor the
|
||||||
|
publisher uses, so the two cannot disagree about what a section is).
|
||||||
|
|
||||||
|
**The incident**: box#108 / rig#66 — the silent mislanding described
|
||||||
|
[above](#what-a-release-is). **Red means** a PR entry has nowhere safe to
|
||||||
|
land; **the fix** is to re-arm: add an empty `## Unreleased` above the top
|
||||||
|
stamped section.
|
||||||
|
|
||||||
|
**Do not "simplify" this to "always require `## Unreleased`".** The
|
||||||
|
unconditional form is false by construction on the ceremony PR's own tree
|
||||||
|
— it makes every release unshippable — and rig#44 and cast#108 both had
|
||||||
|
to revert exactly that
|
||||||
|
([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)).
|
||||||
|
The version-keyed form is what rig and cast get back by adopting this repo.
|
||||||
|
|
||||||
|
One consequence worth knowing before it happens: a ceremony PR that stamps
|
||||||
|
and forgets to re-arm still passes this guard — a bare tree is allowed to
|
||||||
|
be stamped. It goes red **the moment the automatic `-dev` bump lands on
|
||||||
|
main** ([the script](actions/changelog-armed/changelog-armed.sh#L37-L42)).
|
||||||
|
The guard does not block the release; it refuses to let main *sit*
|
||||||
|
disarmed, which is the window a late PR falls into.
|
||||||
|
|
||||||
|
### changelog-monotonic — shipped headings are append-only
|
||||||
|
|
||||||
|
**The rule**
|
||||||
|
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)):
|
||||||
|
the set of `## X.Y.Z` headings on your branch must be a **superset** of
|
||||||
|
the set at the merge base, and no heading may appear twice on HEAD. The
|
||||||
|
rule needs no tuning because release headings are append-only by doctrine:
|
||||||
|
the ceremony adds one and nothing ever legitimately removes one — so
|
||||||
|
superset has no exception to carve. The ceremony's own stamp passes by
|
||||||
|
construction: rewriting `## Unreleased` into `## X.Y.Z — DATE` adds a
|
||||||
|
heading and removes none (`Unreleased` is not a version heading; it is
|
||||||
|
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business).
|
||||||
|
|
||||||
|
**The incidents**: box#122 (caught in review of box#118) — an author
|
||||||
|
adding an entry under `## Unreleased` **replaced** the heading below it
|
||||||
|
instead of inserting above it; git merges that cleanly, and the shipped
|
||||||
|
section's body is silently absorbed into `## Unreleased`. And box#118
|
||||||
|
itself — a bad rebase *duplicated* a shipped heading, which containment is
|
||||||
|
blind to, which is why uniqueness-on-HEAD is a separate assert
|
||||||
|
([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)).
|
||||||
|
|
||||||
|
**Red means** a shipped section was deleted (put the heading back and
|
||||||
|
insert **above** it) or duplicated (collapse to one heading; the failure
|
||||||
|
message walks through both fixes with the diff to run). **This guard needs
|
||||||
|
history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an
|
||||||
|
unresolvable base is a hard failure, not a skip — a guard that can quietly
|
||||||
|
stop guarding is the failure shape this family of checks exists to refuse
|
||||||
|
([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)).
|
||||||
|
|
||||||
|
### drill-recorded — a release carries its evidence
|
||||||
|
|
||||||
|
**The rule**
|
||||||
|
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)),
|
||||||
|
keyed on the tree's version: a `-dev` tree passes with nothing to assert
|
||||||
|
(a development tree ships nothing); a bare tree — the ceremony PR and its
|
||||||
|
merge — must carry `drills/<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).
|
||||||
|
|
||||||
|
**The incident**: box's CONTRIBUTING said since box#96 that the release
|
||||||
|
ritual must be run and recorded. No release ever did it — box#95, box#114
|
||||||
|
and box#148 all shipped as a version bump plus a changelog stamp, because
|
||||||
|
the gate was a sentence in a document and the only thing standing on it
|
||||||
|
was a reviewer remembering to ask. The rule moved into CI, where it fires
|
||||||
|
whether or not anyone is paying attention.
|
||||||
|
|
||||||
|
**Red means** the release is asserting a ritual it left no evidence of.
|
||||||
|
**The fix is to run the drill** and record it — or to waive it *in
|
||||||
|
writing* at the same path: the guard demands a **record, not a passing
|
||||||
|
result** ([below](#the-drill-doctrine)).
|
||||||
|
|
||||||
|
## The drill doctrine
|
||||||
|
|
||||||
|
**Evidence, not success.** The guard asserts a record exists — a failed
|
||||||
|
drill honestly written down satisfies it, and so does a maintainer waiver
|
||||||
|
that says plainly the drill was waived and why. What it refuses is
|
||||||
|
silence: a skip must cost a deliberate, reviewable file in the diff,
|
||||||
|
which is precisely what box's three silent skips never produced. CI
|
||||||
|
cannot run a consumer's drill (box's wants real hardware and the better
|
||||||
|
part of an hour); it can only refuse a release that never ran one.
|
||||||
|
|
||||||
|
**Each repo defines what its drill *means*** — the gate only reads the
|
||||||
|
record. box asserts the **isolation contract**; rig asserts
|
||||||
|
**convergence** (a machine reaches its role, idempotently); cast asserts
|
||||||
|
**promotion** (A→B reproduces, the diff is idempotent); ceremony's own
|
||||||
|
drill is a **door rehearsal** — both doors exercised end-to-end on a
|
||||||
|
disposable repo (#11 names the six probes); incubator's is TBD in
|
||||||
|
heavy-duty/incubator. Each repo states its meaning in its own
|
||||||
|
`drills/README.md`. Three different exercises sharing a substrate is why
|
||||||
|
the records are per-repo — they are not phases of one script.
|
||||||
|
|
||||||
|
**Drills exercise candidate refs, not released artifacts.** A ref is a
|
||||||
|
static identifier that exists as soon as the release branch does, so no
|
||||||
|
repo has to be released — or drilled — before another can be drilled:
|
||||||
|
what looks like a box↔rig recursion at runtime dissolves into two
|
||||||
|
independent tests against one fixed pair of refs. And drilling the
|
||||||
|
candidate *is* drilling the release: a ceremony PR's diff is the stamps
|
||||||
|
and nothing else, so no executable byte differs between the tree that was
|
||||||
|
drilled and the tree that ships.
|
||||||
|
|
||||||
|
**A cross-repo release set shares one run ID.** Each repo records its own
|
||||||
|
legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling
|
||||||
|
SHAs, so the records reconcile afterwards — but the guard only ever reads
|
||||||
|
the repo it runs in. If a defect shows up only in the combination: patch,
|
||||||
|
re-drill, re-record. The set converges; it is not required to be right in
|
||||||
|
one pass.
|
||||||
|
|
||||||
|
## Troubleshooting red main
|
||||||
|
|
||||||
|
Every refusal the release flow can emit, verbatim, with cause and remedy.
|
||||||
|
The catalog is generated from the sources, not paraphrased — regenerate
|
||||||
|
it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
`$VER`-style variables appear as the run interpolates them.
|
||||||
|
|
||||||
|
### The decision refused ([lib/decide.sh](lib/decide.sh))
|
||||||
|
|
||||||
|
> the version '$VER' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing.
|
||||||
|
> (If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. A first release whose version never carried -dev ships by the tag door — the known first-release edge.)
|
||||||
|
|
||||||
|
Row 4 ([L129–L133](lib/decide.sh#L129-L133)). The message is the remedy:
|
||||||
|
drop the label, or re-do the ceremony with the bump, or take the tag door.
|
||||||
|
|
||||||
|
> the version transitioned ('$BASE_VER' -> '$VER') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
|
||||||
|
|
||||||
|
Row 5 ([L147–L149](lib/decide.sh#L147-L149)). Someone pushed or merged a
|
||||||
|
version transition without the `release` label. Label a proper ceremony PR,
|
||||||
|
or — if the tree is genuinely the release — publish by the tag door.
|
||||||
|
|
||||||
|
> VER is empty — the caller failed to establish the version at the pushed head. Refusing to decide — creating nothing.
|
||||||
|
> BASE_VER is empty — the caller failed to establish the version at the base. Refusing to decide — creating nothing.
|
||||||
|
> RELEASED='${RELEASED}' — expected yes, no, or empty. Refusing to decide — creating nothing.
|
||||||
|
> LABELED='${LABELED}' — expected yes, no, or empty. Refusing to decide — creating nothing.
|
||||||
|
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
||||||
|
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
||||||
|
|
||||||
|
The fact-gathering guards
|
||||||
|
([L92–L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135),
|
||||||
|
[L151](lib/decide.sh#L151)): a missing fact must never fall through to
|
||||||
|
"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the
|
||||||
|
workflow plumbing, not an operator mistake — read the run's `facts:`
|
||||||
|
stderr line and file what you find.
|
||||||
|
|
||||||
|
### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh))
|
||||||
|
|
||||||
|
> facts: unknown VERSION_SOURCE '$VERSION_SOURCE' — expected file or package-json
|
||||||
|
|
||||||
|
[L37](lib/facts.sh#L37): the caller's `version-source:` input is neither
|
||||||
|
`file` nor `package-json`. Fix the caller.
|
||||||
|
|
||||||
|
> version_read: $path: no such file
|
||||||
|
> version_read: $path is empty
|
||||||
|
> version_read: $path: no version field
|
||||||
|
> version_read: node is required for version-source: package-json
|
||||||
|
|
||||||
|
[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is
|
||||||
|
missing, empty, or unreadable. A wrong release is worse than a missing
|
||||||
|
one, so an unreadable state is never an empty print — restore the
|
||||||
|
`VERSION` file (or `package.json` version field) on main.
|
||||||
|
|
||||||
|
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300))
|
||||||
|
|
||||||
|
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
|
||||||
|
|
||||||
|
[L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged
|
||||||
|
without its stamp (a state the
|
||||||
|
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses
|
||||||
|
on the PR — red main here means it was overridden). Stamp the section on
|
||||||
|
main, then publish by the tag door.
|
||||||
|
|
||||||
|
> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
|
||||||
|
> release '$VER' already exists — refusing to re-release, creating nothing.
|
||||||
|
|
||||||
|
[L207–L222](.github/workflows/release.yml#L207-L222), the nothing-exists
|
||||||
|
assert — what makes a re-run of a completed ceremony refuse instead of
|
||||||
|
clobber, and what catches a manual tag racing the merge. If the release
|
||||||
|
truly exists, there is nothing to do: this red is the system declining to
|
||||||
|
do the thing twice. If the tag exists but the release does not (a manual
|
||||||
|
tag won the race, or
|
||||||
|
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by
|
||||||
|
the tag door: delete and re-push the tag, or `gh release create` by hand
|
||||||
|
from a fixed tree.
|
||||||
|
|
||||||
|
> direct push refused (branch protection?) — opening the bump PR instead
|
||||||
|
|
||||||
|
[L292–L300](.github/workflows/release.yml#L292-L300) — loud, but not a
|
||||||
|
refusal: the post-release `-dev` bump could not push directly, so the run
|
||||||
|
opened a `release`-labeled bump PR itself. Your move: merge it promptly —
|
||||||
|
until it lands, main is sitting bare, where a dev install
|
||||||
|
[impersonates the release](.github/workflows/release.yml#L291) and the
|
||||||
|
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
|
||||||
|
open.
|
||||||
|
|
||||||
|
### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369))
|
||||||
|
|
||||||
|
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
|
||||||
|
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
|
||||||
|
|
||||||
|
[L333–L337](.github/workflows/release.yml#L333-L337). The message is the
|
||||||
|
remedy.
|
||||||
|
|
||||||
|
> CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release
|
||||||
|
|
||||||
|
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
|
||||||
|
never stamped. Stamp first, then delete and re-push the tag.
|
||||||
|
|
||||||
|
### Red main that is not the release workflow
|
||||||
|
|
||||||
|
Consumer CI runs its guard steps on pushes to main too (this repo's
|
||||||
|
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
|
||||||
|
operator will actually meet on main is
|
||||||
|
**changelog-armed after a re-arm was forgotten**: the ceremony stamped
|
||||||
|
without putting `## Unreleased` back, the release's own `-dev` bump
|
||||||
|
landed, and the guard now says (first line):
|
||||||
|
|
||||||
|
> changelog-armed: the version is '$ver' (a development tree) but the top
|
||||||
|
> section of $changelog is: …
|
||||||
|
|
||||||
|
The fix is a one-line PR: add an empty `## Unreleased` above the stamped
|
||||||
|
section. The full message
|
||||||
|
([the script](actions/changelog-armed/changelog-armed.sh#L87-L101))
|
||||||
|
carries the same instruction.
|
||||||
|
|
||||||
|
## Design lineage
|
||||||
|
|
||||||
|
The ceremony converged across box#83 → box#96, rig#32 → rig#47, and
|
||||||
|
cast#96 → cast#111; this repo is those three implementations folded into
|
||||||
|
one (the drift that motivated it is measured in
|
||||||
|
[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing
|
||||||
|
constraints — each bought with an incident, none of them safe to
|
||||||
|
"simplify" away — are listed in
|
||||||
|
[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with
|
||||||
|
their war stories, in the headers of the scripts they bind:
|
||||||
|
[release.yml](.github/workflows/release.yml#L1-L109),
|
||||||
|
[lib/decide.sh](lib/decide.sh#L1-L74),
|
||||||
|
[lib/facts.sh](lib/facts.sh#L1-L24), and the three
|
||||||
|
[guard scripts](actions/). The comments are the documentation of record;
|
||||||
|
this README is their operator-facing cut.
|
||||||
|
|
@ -1,5 +1,138 @@
|
||||||
# Consumer setup
|
# Consumer setup
|
||||||
|
|
||||||
|
How a repo adopts the ceremony — bootstrap for a greenfield repo, a
|
||||||
|
conversion checklist for a repo carrying its own copy of the machinery,
|
||||||
|
and the policies that keep either honest afterwards. The doctrine (what a
|
||||||
|
release *is*, the doors, the guards, the drill) lives in
|
||||||
|
[../README.md](../README.md); this guide is the how-to. It is meant to be
|
||||||
|
sufficient on its own: a conversion executed from this guide should need
|
||||||
|
zero out-of-band knowledge, and gaps found while converting are filed as
|
||||||
|
edits to this guide (#12).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Repo shape**: work lands on a `main` default branch by PR; fork PRs
|
||||||
|
are fine — the merge door rides `push` to `main`, never `pull_request`
|
||||||
|
([release.yml](../.github/workflows/release.yml#L70-L74), box#97), and
|
||||||
|
the label read goes through the API
|
||||||
|
([lib/facts.sh](../lib/facts.sh#L88-L101)), so the ceremony never needs
|
||||||
|
the PR's own context. No PAT, no secrets: every permission the flow uses
|
||||||
|
is the caller-declared `GITHUB_TOKEN` grant.
|
||||||
|
- **Pick the version backend**: `file` (a `VERSION` file — box, rig,
|
||||||
|
incubator) or `package-json` (the `version` field, lockfile kept in sync
|
||||||
|
on the post-release bump — cast). This is the workflow's one input; the
|
||||||
|
full configuration surface of the ceremony is enumerated in
|
||||||
|
[#1](https://github.com/heavy-duty/ceremony/issues/1) ("The
|
||||||
|
configuration axes").
|
||||||
|
- **The `release` label must exist** before the first ceremony PR — it is
|
||||||
|
the merge door's declared-intent read
|
||||||
|
([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels
|
||||||
|
workflow's `workflow_dispatch`
|
||||||
|
([Labels automation](#labels-automation)), or create it by hand,
|
||||||
|
matching the core table
|
||||||
|
([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh label create release --color 0E8A16 \
|
||||||
|
--description "Release flow and version/packaging work"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrap a new repo
|
||||||
|
|
||||||
|
The greenfield path (incubator's, #16) — the repo never owns a copy of
|
||||||
|
the machinery at all:
|
||||||
|
|
||||||
|
1. **`VERSION` at `X.Y.Z-dev` — never bare.** A first version that never
|
||||||
|
carried `-dev` hits the decide table's refuse row and has to ship by
|
||||||
|
the tag door (the known first-release edge, cast#111;
|
||||||
|
[lib/decide.sh](../lib/decide.sh#L70-L74)). Bootstrapping at `-dev`
|
||||||
|
keeps the repo clear of it entirely. (`package-json` backend: the
|
||||||
|
`version` field, same rule.)
|
||||||
|
2. **An armed `CHANGELOG.md`**: a preamble plus an empty `## Unreleased`
|
||||||
|
section for the first entries to land under.
|
||||||
|
3. **`drills/README.md`** defining what a drill *means* in this repo —
|
||||||
|
each repo names its own
|
||||||
|
([the drill doctrine](../README.md#the-drill-doctrine)). Plain
|
||||||
|
`drills`, not a dot-directory
|
||||||
|
([drill-recorded.sh](../actions/drill-recorded/drill-recorded.sh#L49-L52)).
|
||||||
|
4. **`.github/workflows/release.yml`** — the caller, verbatim from
|
||||||
|
[Release workflow](#release-workflow) below.
|
||||||
|
5. **CI guard steps** in the repo's `ci.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# changelog-monotonic compares HEAD against the merge base; a
|
||||||
|
# checkout that cannot resolve it is a hard failure in CI, not
|
||||||
|
# a skip (a guard that can quietly stop guarding is the failure
|
||||||
|
# shape these checks exist to refuse).
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
|
||||||
|
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
|
||||||
|
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
`changelog-armed` and `drill-recorded` take
|
||||||
|
`version-source: package-json` where that is the backend; every guard's
|
||||||
|
inputs and defaults are in its `action.yml`
|
||||||
|
([actions/](../actions/)). Adopting the agent team flow adds the
|
||||||
|
`docs-sync` step ([below](#adopting-the-agent-team-flow)).
|
||||||
|
6. **Labels automation** (optional but recommended): the caller from
|
||||||
|
[Labels automation](#labels-automation), plus `.github/labels.conf`
|
||||||
|
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
|
||||||
|
path→scope globs). Run `workflow_dispatch` once — **this bootstraps
|
||||||
|
the taxonomy, `release` label included**.
|
||||||
|
7. **The artifact hook** (optional): `.github/actions/release-artifact/`
|
||||||
|
per [The artifact hook](#the-artifact-hook). No hook → the source
|
||||||
|
tarball is the package.
|
||||||
|
|
||||||
|
From there the flow is the doctrine: ordinary PRs add their changelog
|
||||||
|
line, the ceremony PR makes
|
||||||
|
[the three stamps](../README.md#what-a-release-is), a human merges, the
|
||||||
|
machine transcribes.
|
||||||
|
|
||||||
|
## Convert an existing repo
|
||||||
|
|
||||||
|
The box/rig/cast path — the repo carries its own copy of the machinery
|
||||||
|
and hands it over. The conversion PR is release-flow work: label it
|
||||||
|
`release` if the repo's conventions ask for that, and either way it lands
|
||||||
|
as a green `NOTICE` no-op on main — the decide table's green rows exist
|
||||||
|
precisely so the machinery is safe to work on
|
||||||
|
([lib/decide.sh](../lib/decide.sh#L6-L12)).
|
||||||
|
|
||||||
|
- [ ] Replace `.github/workflows/release.yml` with the caller from
|
||||||
|
[Release workflow](#release-workflow) — **whole file**, keeping its
|
||||||
|
load-bearing comments. Check the result has **one** `push:` key
|
||||||
|
carrying both filters: YAML maps are last-key-wins, and a second
|
||||||
|
sibling `push:` silently kills a door (rig's review catch).
|
||||||
|
- [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in
|
||||||
|
the bootstrap list above (with `fetch-depth: 0` on the checkout).
|
||||||
|
- [ ] Replace `labels.yml` with the caller from
|
||||||
|
[Labels automation](#labels-automation); extract
|
||||||
|
`.github/labels.conf` from the old reconciler's embedded config —
|
||||||
|
the `panel=` roster line and the repo's `scope:*` rows
|
||||||
|
([the format](#labels-automation)). `.github/labeler.yml` stays as
|
||||||
|
it is (path globs are inherently repo-specific).
|
||||||
|
- [ ] Delete the now-shadowed copies — zero shared scripts remain:
|
||||||
|
`.github/scripts/release-notes.sh` (box, cast) or
|
||||||
|
`release-lib.sh` (rig), `changelog-armed.sh` (box),
|
||||||
|
`changelog-monotonic.sh`, `drill-recorded.sh`,
|
||||||
|
`labels-reconcile.sh`.
|
||||||
|
- [ ] Trim the repo's test suite to repo-specific tests: the machinery
|
||||||
|
tests go — they live in this repo's `test/` now, run by its CI —
|
||||||
|
while the repo's own surfaces stay (box/rig's install-channel halves
|
||||||
|
of `test/release.sh`, cast's `install-sh` tests).
|
||||||
|
- [ ] Shrink CONTRIBUTING's release section to a pointer at
|
||||||
|
[this repo's README](../README.md) plus what is genuinely per-repo:
|
||||||
|
the drill meaning (`drills/README.md`), artifact notes, the
|
||||||
|
changelog house style if it differs from
|
||||||
|
[the portable rule](#the-changelog-rule).
|
||||||
|
- [ ] What stays, per repo, forever: `VERSION` (or the `package.json`
|
||||||
|
version), `CHANGELOG.md`, `drills/`, `.github/labeler.yml`,
|
||||||
|
`.github/labels.conf`, the optional
|
||||||
|
`.github/actions/release-artifact/` — the full kept-vs-moved table
|
||||||
|
is in [#1](https://github.com/heavy-duty/ceremony/issues/1).
|
||||||
|
|
||||||
## Release workflow
|
## Release workflow
|
||||||
|
|
||||||
The reusable release workflow implements both doors of the ceremony — the
|
The reusable release workflow implements both doors of the ceremony — the
|
||||||
|
|
@ -65,7 +198,8 @@ a fixed tree. The merge door's nothing-exists assert will refuse a re-run of
|
||||||
the completed merge, by design.
|
the completed merge, by design.
|
||||||
|
|
||||||
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
|
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
|
||||||
tag IS the package.
|
tag IS the package. Worked examples land with the conversions: cast's tgz
|
||||||
|
build (#15) and incubator's GHCR image push (#16).
|
||||||
|
|
||||||
## Labels automation
|
## Labels automation
|
||||||
|
|
||||||
|
|
@ -153,8 +287,107 @@ that routes agents to `.ceremony/AGENTS.md` — created once, never
|
||||||
overwritten; it is per-repo content the moment you edit it, so `--check`
|
overwritten; it is per-repo content the moment you edit it, so `--check`
|
||||||
asserts only that it exists.
|
asserts only that it exists.
|
||||||
|
|
||||||
**The pin-bump procedure**: bumping the pin is one PR — the pin line change
|
Bumping the pin re-syncs the mirror in the same PR —
|
||||||
plus the re-synced mirror (run `--fix` locally, or let the red `--check` on
|
[the pin-bump procedure](#the-pin-bump-procedure).
|
||||||
the bump PR say what is stale). The guard makes a half-done bump — pin
|
|
||||||
without mirror, mirror without pin — unmergeable, which is how a process
|
## Version pinning
|
||||||
change rolls out: deliberately, per repo, reviewed.
|
|
||||||
|
- **Pin an exact ceremony release tag** — `@0.1.0`, never a branch and
|
||||||
|
never a moving major pointer: the family pins things and reviews
|
||||||
|
updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)).
|
||||||
|
Every `uses:` of this repo in the consumer — the two workflow callers
|
||||||
|
and the guard steps — names the same tag.
|
||||||
|
- **Bump by PR, every reference together.** Before bumping, read the
|
||||||
|
ceremony's own `CHANGELOG.md` section for the new version (the release
|
||||||
|
body on its
|
||||||
|
[releases page](https://github.com/heavy-duty/ceremony/releases) is
|
||||||
|
that section, verbatim). One bump PR updates **every** ceremony `uses:`
|
||||||
|
reference in the repo to the new tag — the workflow callers *and* each
|
||||||
|
guard step; a release-only setup already has four (the
|
||||||
|
[release caller](#release-workflow) plus the
|
||||||
|
[three CI guards](#bootstrap-a-new-repo)), and changing only one line
|
||||||
|
leaves the consumer split across ceremony versions, which the same-tag
|
||||||
|
rule above forbids. A repo that has adopted the agent team flow
|
||||||
|
additionally bumps the mirror in the same PR —
|
||||||
|
[the pin-bump procedure](#the-pin-bump-procedure).
|
||||||
|
- **One pin governs machinery and doctrine.** The ref in the consumer's
|
||||||
|
`release.yml` `uses:` line is the single pin: `docs-sync` reads it from
|
||||||
|
exactly there and verifies the `.ceremony/` mirror against it — there
|
||||||
|
is no second pin to fall out of sync (#19).
|
||||||
|
|
||||||
|
## The changelog rule
|
||||||
|
|
||||||
|
The portable version of the family's contributor rule — the repo's own
|
||||||
|
CONTRIBUTING may sharpen it, but this is the floor the guards assume:
|
||||||
|
|
||||||
|
- **Every PR that changes behavior adds one line** under `## Unreleased`.
|
||||||
|
- **Insert above the heading below — never type over it.** Replacing a
|
||||||
|
shipped `## X.Y.Z` heading with your entry deletes that release's
|
||||||
|
section, silently; this exact edit is why the
|
||||||
|
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only)
|
||||||
|
exists (box#122).
|
||||||
|
- **One line: say what changed, and stop.** Lead with the surface, not
|
||||||
|
the mechanism — "`state:needs-human` is set at handoff" beats "the
|
||||||
|
labels workflow now also wakes on `labeled`". The why and the how
|
||||||
|
belong in the PR body, where anyone chasing the reasoning already goes.
|
||||||
|
- **Cite the issue or PR** — `(#141)`.
|
||||||
|
- **Mark a breaking change** with a leading `BREAKING:`.
|
||||||
|
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
|
||||||
|
|
||||||
|
## Adopting the agent team flow
|
||||||
|
|
||||||
|
The team flow (discussion → triage → issue → build → review → human
|
||||||
|
merge) is **optional per repo and separable from the release ceremony**:
|
||||||
|
a repo can adopt release-only and take the team flow later — incubator's
|
||||||
|
initial posture (#16). The model is this repo's own
|
||||||
|
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
|
||||||
|
this is the checklist:
|
||||||
|
|
||||||
|
- [ ] **Enable Discussions** — the triage door exists or the pipeline
|
||||||
|
has no intake.
|
||||||
|
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
|
||||||
|
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md` —
|
||||||
|
byte-identical to this repo at the pinned ref — plus the generated
|
||||||
|
`.ceremony/README.md` (machine-managed marker) and, if the repo has
|
||||||
|
none, the thin root `AGENTS.md` stub ("governed by
|
||||||
|
heavy-duty/ceremony; read `.ceremony/AGENTS.md` first; repo
|
||||||
|
specifics in CONTRIBUTING"). The stub is scaffolded once and never
|
||||||
|
overwritten; the mirror is machine-written and never hand-edited.
|
||||||
|
Commit `.ceremony/` together with the workflow callers.
|
||||||
|
- [ ] **Guard the mirror in CI**: add the `docs-sync` check step
|
||||||
|
alongside the other guards —
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: heavy-duty/ceremony/actions/docs-sync@<pinned-tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
(`mode: check` is the default.) Hand-editing a vendored file, or
|
||||||
|
bumping the pin without re-syncing, goes red (#19).
|
||||||
|
- [ ] **Reduce tool-specific files** (`CLAUDE.md`, …) to one pointer line
|
||||||
|
at the root `AGENTS.md`, so every harness converges on the same
|
||||||
|
router.
|
||||||
|
- [ ] **Point CONTRIBUTING at the mirror**: a short header telling agents
|
||||||
|
to read `.ceremony/` first — agents never leave the working tree to
|
||||||
|
read the rules — followed by only what is genuinely per-repo: the
|
||||||
|
review panel roster, the `scope:*` set, the drill meaning, the
|
||||||
|
repo's code conventions.
|
||||||
|
- [ ] **Name the review panel**: the roster table in CONTRIBUTING and the
|
||||||
|
`panel=` line in `.github/labels.conf` — the required verdicts for
|
||||||
|
any PR are the panel minus its author (#10).
|
||||||
|
- [ ] **Bootstrap the issue-flow labels**: the labels
|
||||||
|
`workflow_dispatch` once ([above](#labels-automation)), or the hand
|
||||||
|
commands in [LABELS.md](../LABELS.md).
|
||||||
|
- [ ] **State the single-writer rule** in the repo's own docs: only
|
||||||
|
triage mints issues; everyone else opens discussions.
|
||||||
|
|
||||||
|
### The pin-bump procedure
|
||||||
|
|
||||||
|
Bumping the ceremony pin is **one PR carrying both halves**: every
|
||||||
|
ceremony `uses:` reference — the workflow callers *and* each guard step,
|
||||||
|
[all to the same new tag](#version-pinning) — and the re-synced
|
||||||
|
`.ceremony/` mirror —
|
||||||
|
run `docs-sync --fix` locally, or let the red `--check` on the bump PR
|
||||||
|
tell you what is stale. The CI guard is what makes a half-done bump —
|
||||||
|
pin without mirror, or mirror without pin — unmergeable (#19). This is
|
||||||
|
how a process change rolls out to a governed repo: deliberately, per
|
||||||
|
repo, reviewed.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue