feat: adopt ceremony callers and doctrine
This commit is contained in:
parent
c93e230396
commit
1c3d873bf2
11 changed files with 442 additions and 283 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# AGENTS.md — start here
|
||||
|
||||
You are an agent working in a repo governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||
the router: find your role below, read its file, then act. The role files
|
||||
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||
|
||||
## Your role
|
||||
|
||||
You were told your role when you were pointed at this repo ("you are a
|
||||
reviewer here"). That one word is your whole onboarding:
|
||||
|
||||
| you are the… | read | your job in one line |
|
||||
|---|---|---|
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||
|
||||
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||
are the shared state machine, and misusing one lies to every other agent on
|
||||
the board.
|
||||
|
||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||
asked to implement an issue → builder; asked to process discussions or the
|
||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||
pushing fixes, breaks the separation the pipeline depends on.
|
||||
|
||||
## The pipeline you are part of
|
||||
|
||||
```
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
Two rules bind every role:
|
||||
|
||||
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||
a merge button.
|
||||
|
||||
## Repo specifics
|
||||
|
||||
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||
label set, what a drill means, code conventions — lives in the repo's own
|
||||
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||
76
.ceremony/BUILDER.md
Normal file
76
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# BUILDER.md — the builder role
|
||||
|
||||
You turn one issue into one PR. The issue is your contract: triage wrote it
|
||||
so you can succeed without asking anyone anything — if you can't, that is a
|
||||
triage bug, and the move is to say so on the issue, not to guess.
|
||||
|
||||
## Picking
|
||||
|
||||
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
|
||||
never an `epic` (epics organize; their children are the work).
|
||||
- Respect dependency order: inside an epic, take the earliest unblocked
|
||||
unclaimed child. Between epics and strays, prefer the issue that unblocks
|
||||
the most other work.
|
||||
- **One issue at a time.** Finish or release your claim before taking
|
||||
another.
|
||||
|
||||
## Claiming
|
||||
|
||||
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||
starting. The claim is a promise of a draft PR soon — a claim with no PR
|
||||
and no activity is what the staleness sweep reclaims.
|
||||
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
|
||||
push the branch if it holds anything useful, unassign, and restore
|
||||
`ready`.
|
||||
|
||||
## Building
|
||||
|
||||
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||
body. Drafts are invisible to the reviewer panel on purpose — the draft
|
||||
phase is yours.
|
||||
- **The issue's acceptance criteria are your definition of done.** Reproduce
|
||||
them as a checklist in the PR body and check them honestly as you go. If
|
||||
one turns out to be wrong or unreachable, say so on the issue and get it
|
||||
amended by triage — do not silently ship less than the issue says.
|
||||
- Every behavior change adds one line to `CHANGELOG.md` under
|
||||
`## Unreleased` — insert **above** the heading below it, never over it
|
||||
(the monotonic guard's whole reason to exist).
|
||||
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||
not optional: the issue's test plan is the floor, not the ceiling.
|
||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||
Adjacent problems you discover go to a **discussion** (or a comment on the
|
||||
relevant issue), where triage will do its job. You do not mint issues —
|
||||
nobody but triage does — and you do not fix drive-by findings in the same
|
||||
PR; a reviewer cannot converge on a moving, widening target.
|
||||
|
||||
## The review round
|
||||
|
||||
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
|
||||
panel roster and any repo-specific flow notes live in that repo's own
|
||||
CONTRIBUTING; everything below is the shared flow.)
|
||||
|
||||
1. Mark ready-for-review; request **the whole panel** (the roster is in the
|
||||
repo's CONTRIBUTING).
|
||||
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||
covering every point, then push the fixes, then re-request exactly the
|
||||
reviewers who did not approve. Prefer verification over argument: when a
|
||||
reviewer doubts behavior, add the test that settles it.
|
||||
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||
A blocking point you disagree with is answered with evidence or escalated
|
||||
in the PR — a maintainer can be asked for a ruling; silence and
|
||||
force-forward are not options.
|
||||
|
||||
## Handoff
|
||||
|
||||
When the round passes — every panel verdict approves the **current head**,
|
||||
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
|
||||
this is a release PR) — hand it to the human, in order:
|
||||
|
||||
1. post the round summary (what changed per round, what was verified);
|
||||
2. request the human's review;
|
||||
3. set `state:needs-human` yourself.
|
||||
|
||||
The label write is optimistic — the reconciler validates it, and takes it
|
||||
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
|
||||
human's. Address what comes back (`state:addressing`) and re-hand-off the
|
||||
same way.
|
||||
98
.ceremony/LABELS.md
Normal file
98
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Labels
|
||||
|
||||
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
||||
differs per repo (each repo's `.github/labels.conf` names its actual
|
||||
surfaces); everything else below is core and identical everywhere, created by
|
||||
the labels workflow's bootstrap dispatch (issue #10).
|
||||
|
||||
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||
triage → build queue, doctrine-enforced today, machinery to follow —
|
||||
issue #18). One rule joins everything: **states are machine-owned, intent
|
||||
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
|
||||
and the reconciler recomputes it from GitHub's own facts.
|
||||
|
||||
## PR state — who is the ball with? (exactly one per open PR)
|
||||
|
||||
| Label | Color | Waiting on |
|
||||
|---|---|---|
|
||||
| `state:building` | `#FBCA04` | the builder — PR is a draft |
|
||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
|
||||
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||
|
||||
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
|
||||
*poke the reviewers*, in the second *the builder dropped the ball*. And
|
||||
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||
so it requires zero blockers and head-current approvals; anything less and
|
||||
the reconciler takes it back. The author sets it at handoff (the one
|
||||
hand-set state); the `labeled` event fires the sweep that validates the
|
||||
write within seconds.
|
||||
|
||||
## PR blockers — what is in the way? (facts, as many as apply)
|
||||
|
||||
| Label | Color | Means |
|
||||
|---|---|---|
|
||||
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
|
||||
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
|
||||
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
|
||||
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
|
||||
|
||||
States answer *whose ball*; blockers answer *what's in the way*. They are
|
||||
separate axes because the single-label version kept lying — independent facts
|
||||
projected onto one totally-ordered label meant one always won and the losers
|
||||
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
|
||||
strips it on sight).
|
||||
|
||||
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
|
||||
|
||||
| Label | Color | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||
|
||||
The invariant a board scan relies on: every open issue is either
|
||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||
`blocked`. A `claimed` issue with no open PR and no activity is what the
|
||||
staleness sweep will reclaim (issue #18); until that machinery exists,
|
||||
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
|
||||
|
||||
## Cross-cutting (PRs and issues)
|
||||
|
||||
| Label | Color | Meaning |
|
||||
|---|---|---|
|
||||
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
|
||||
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
|
||||
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
|
||||
actions/labeler, issues get theirs from triage. This repo's set:
|
||||
|
||||
| Label | Covers |
|
||||
|---|---|
|
||||
| `scope:release-flow` | the reusable release workflow, decide, the doors |
|
||||
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
|
||||
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
|
||||
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
|
||||
|
||||
## Issue types
|
||||
|
||||
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
|
||||
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
|
||||
on a PR would say the same thing twice and drift.
|
||||
|
||||
## Maintenance
|
||||
|
||||
The labels workflow (issue #10) recomputes PR state statelessly on PR events
|
||||
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
|
||||
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
|
||||
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
|
||||
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
|
||||
bootstrap — a `question` is a discussion, not an issue.
|
||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# .ceremony/ — the vendored doctrine mirror
|
||||
|
||||
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
|
||||
these files here: they are byte-identical copies of
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
|
||||
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
|
||||
goes red. They are changed in heavy-duty/ceremony, through its own flow,
|
||||
and arrive here when the pin moves.
|
||||
|
||||
The pin lives in `.github/workflows/release.yml` — the single
|
||||
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
|
||||
pin governs machinery and doctrine alike: bump it and re-sync this mirror
|
||||
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
|
||||
what is stale).
|
||||
70
.ceremony/REVIEWER.md
Normal file
70
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# REVIEWER.md — the reviewer role
|
||||
|
||||
You are one voice on a panel. The panel's job is to converge — on an
|
||||
approval the human can trust, or on a precise statement of what is wrong.
|
||||
The machine reads only your **verdict**; humans read your reasons.
|
||||
|
||||
## The verdict doctrine
|
||||
|
||||
- **Every review ends in a verdict**: approve, or request changes. A
|
||||
comment-only review is a non-verdict — it does not say whether the round
|
||||
passed, the state machine treats it as not-approved, and the PR simply
|
||||
stalls. If you have an opinion, you have a verdict; commenting without one
|
||||
only wedges the flow.
|
||||
- **The verdict carries blockingness only; the body carries the feedback.**
|
||||
Non-blocking nits ride an **approval**, and the builder addresses them at
|
||||
their discretion. Anything blocking — including a question whose answer
|
||||
gates your approval — is **request changes**, saying exactly what
|
||||
unblocks it.
|
||||
- An approval you would not defend to the human is a defect. You are not
|
||||
being asked to be agreeable; you are being asked to be right.
|
||||
|
||||
## What you review against
|
||||
|
||||
In order of authority:
|
||||
|
||||
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
|
||||
spec. Check every criterion; a PR that ships less than the issue says is
|
||||
a request-changes even if the code is beautiful.
|
||||
2. **The repo's load-bearing constraints** — the rules bought with
|
||||
incidents (in ceremony itself: issue #1's constraint list; in a governed
|
||||
repo: its own CONTRIBUTING plus ceremony's README). A change that
|
||||
"simplifies away" a constraint gets request-changes with a link to the
|
||||
incident that made the rule.
|
||||
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||
floor exist? do the failure cases actually fail?), then conventions.
|
||||
Changelog line present for behavior changes; comments carry why, not
|
||||
what.
|
||||
|
||||
**Verify over opine.** Run what can be run; construct the failing input; a
|
||||
test settles what a comment thread can't. A review that says "I ran X and
|
||||
saw Y" outranks one that says "this looks like it might".
|
||||
|
||||
## What you do not do
|
||||
|
||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||
above it, in a discussion where humans had their say. If you think the
|
||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||
discussion, while still reviewing the implementation against the spec as
|
||||
written. Spec changes go through triage, not through a review round.
|
||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||
human; only humans merge.
|
||||
- **Approve a moving target.** Your approval is of a specific head. If the
|
||||
builder pushes after your approval, GitHub stales it — that is correct,
|
||||
and the builder owes a re-request, not an assumption.
|
||||
|
||||
## The round rhythm
|
||||
|
||||
- Review the **whole PR at the current head** each round, not just the diff
|
||||
since your last comments — the fix for someone else's point can break
|
||||
yours.
|
||||
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||
reviews mid-address just churn the target).
|
||||
- Convergence = every panel verdict approves the current head, no
|
||||
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||
the panel's job is done.
|
||||
- If a round exposes a disagreement **within the panel**, argue it in the PR
|
||||
with evidence until one side concedes or the builder escalates to the
|
||||
maintainer for a ruling. Two reviewers pulling a builder in opposite
|
||||
directions without resolution is a panel failure, not a builder failure.
|
||||
95
.ceremony/TRIAGE.md
Normal file
95
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
You are the only door issues come through. Humans and agents open
|
||||
**discussions**; you decide what becomes work. The quality of every
|
||||
downstream stage — a builder succeeding without asking, a reviewer having a
|
||||
spec to review against — is set here, by you, and nowhere else.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
Discussions are allowed to be ambiguous; issues are not. An issue is a work
|
||||
order a builder must be able to execute **without asking anyone anything**.
|
||||
Keeping one accountable role between the two is what keeps the bar from
|
||||
eroding — the moment anyone can mint an issue, the backlog fills with
|
||||
"improve X" entries nobody can build, and builders start guessing. Guessing
|
||||
is the failure this whole flow exists to prevent.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open discussion** in the repo you serve.
|
||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance back into a discussion and close it, saying why. Do not shame the
|
||||
filer; do route the work correctly.
|
||||
|
||||
## For each discussion, converge on exactly one outcome
|
||||
|
||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||
already shipped or already tracked. Reply with the answer (link the code,
|
||||
the doc, the existing issue), mark answered.
|
||||
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||
answers would let you write the issue — then stop and wait. Do not mint an
|
||||
issue that carries the ambiguity forward; that just moves your job onto
|
||||
the builder.
|
||||
3. **Escalate.** The blocker is a *decision* only a human owns — scope,
|
||||
money, product direction, breaking a public contract. Say precisely what
|
||||
the decision is, list the options with your recommendation, and name the
|
||||
decider. The discussion is where humans decide; wait there.
|
||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||
outcome; a zombie discussion is not.
|
||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||
the bar.
|
||||
|
||||
## The issue contract
|
||||
|
||||
Every issue you mint carries, in this order:
|
||||
|
||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling".
|
||||
- **Context**: why this exists, with links — the discussion it came from,
|
||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||
open question, the issue is not ready to exist — go back to outcome 2 or 3.
|
||||
- **Tasks**: the steps, checkboxed, in order.
|
||||
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
|
||||
the builder's definition of done and the reviewer's review spec, verbatim.
|
||||
- **Test plan**: what proves it, including the cases that must fail.
|
||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||
epic organizes it.
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
The bar, stated once: **a competent builder who has read only this issue and
|
||||
the repo can succeed.** The release-ceremony epic and its children
|
||||
(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density
|
||||
expected.
|
||||
|
||||
## Multi-issue work
|
||||
|
||||
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||
label): the approach, the decisions, the constraint list, and a
|
||||
dependency-ordered task list of child issues. Children reference the epic;
|
||||
the epic's checklist is the progress view. Builders never pick the epic
|
||||
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||
|
||||
## Backlog hygiene (yours until #18 automates it)
|
||||
|
||||
- **Dedup before minting** — search issues *and* closed issues; extend or
|
||||
reopen before duplicating.
|
||||
- **Flip `blocked` → `ready`** when the named dependency lands.
|
||||
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
|
||||
comment, unassign, restore `ready`.
|
||||
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||
them. Every label on every open issue stays true; the board is only worth
|
||||
scanning if it does not lie.
|
||||
|
||||
## What you never do
|
||||
|
||||
- Write code, review code, or build the thing yourself.
|
||||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||
refusal into a vague issue to avoid saying no.
|
||||
- Mint an issue to "discuss" something — that is a discussion.
|
||||
8
.github/labels.conf
vendored
Normal file
8
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl
|
||||
triage-actors=dan-claude-bot
|
||||
scope:cli|C5DEF5|bin/box — the command surface
|
||||
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||
scope:templates|C5DEF5|templates/ — the box seeds
|
||||
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -58,8 +58,6 @@ jobs:
|
|||
shellcheck -x "${files[@]}"
|
||||
- name: cli tests
|
||||
run: bash test/cli.sh
|
||||
- name: labels state-machine tests
|
||||
run: bash test/labels-reconcile.sh
|
||||
- name: release-flow tests
|
||||
run: bash test/release.sh
|
||||
# The changelog is ARMED for the next entry (#108). Its own step rather
|
||||
|
|
@ -67,7 +65,7 @@ jobs:
|
|||
# tree, not about the release machinery, so when it goes red the log
|
||||
# says which check found the drift without anyone reading a suite.
|
||||
- name: changelog is armed for the next entry
|
||||
run: bash .github/scripts/changelog-armed.sh
|
||||
uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
|
||||
# ...and no SHIPPED release heading was deleted or DUPLICATED (#122, #143).
|
||||
# Its own step for the same reason as the one above — when it goes red the
|
||||
# log names the invariant that broke — but a DIFFERENT invariant: armed is
|
||||
|
|
@ -104,11 +102,13 @@ jobs:
|
|||
# every ordinary push and PR, the script no-ops green by design, so
|
||||
# running it everywhere costs nothing and closes the route.
|
||||
- name: a release tree has a drill record
|
||||
run: bash .github/scripts/drill-recorded.sh
|
||||
uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
|
||||
- name: no shipped changelog heading was deleted or duplicated
|
||||
env:
|
||||
CHANGELOG_MONOTONIC_STRICT: '1'
|
||||
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
|
||||
uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
|
||||
- name: no PR code runs on a self-hosted runner
|
||||
uses: heavy-duty/ceremony/actions/runner-isolated@0.1.0
|
||||
- name: vendored ceremony doctrine matches the pin
|
||||
uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
|
||||
|
||||
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||
|
|
|
|||
77
.github/workflows/labels.yml
vendored
77
.github/workflows/labels.yml
vendored
|
|
@ -1,78 +1,15 @@
|
|||
name: labels
|
||||
# The automation LABELS.md promises. Two halves:
|
||||
# scope — path-derived scope:* labels on PRs (actions/labeler)
|
||||
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
|
||||
#
|
||||
# pull_request_target, not pull_request: every PR here arrives from a fork,
|
||||
# where pull_request (and pull_request_review) run with a READ-ONLY token and
|
||||
# cannot label anything. _target is safe in this workflow because no PR code
|
||||
# is ever checked out or executed — labeler reads changed paths via the API,
|
||||
# and reconcile checks out the BASE branch only. Keep it that way.
|
||||
#
|
||||
# There is no pull_request_review_target, so a review landing cannot wake this
|
||||
# workflow directly — and the */15 cron is advisory: GitHub deprioritises short
|
||||
# intervals hard enough that a quiet repo goes hours between ticks. So the
|
||||
# handoff wakes the sweep itself: the author sets state:needs-human when handing
|
||||
# the PR to the maintainer (CONTRIBUTING step 6), and `labeled` fires this
|
||||
# workflow, which confirms or corrects that optimistic write within seconds. The
|
||||
# cron stays as the last resort, for the round an agent forgets to hand off.
|
||||
#
|
||||
# This cannot loop: the reconciler's own label writes use GITHUB_TOKEN, and
|
||||
# GitHub does not create workflow runs from GITHUB_TOKEN-triggered events. Agent
|
||||
# writes use a PAT and therefore do trigger — exactly the asymmetry wanted.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||
pull_request_target:
|
||||
types:
|
||||
[
|
||||
opened,
|
||||
reopened,
|
||||
ready_for_review,
|
||||
converted_to_draft,
|
||||
synchronize,
|
||||
labeled,
|
||||
unlabeled,
|
||||
]
|
||||
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
|
||||
issues:
|
||||
types: [opened, labeled, unlabeled, assigned, unassigned, closed]
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
# Not on labeled/unlabeled: those events change no paths, so labeler has
|
||||
# nothing new to derive — and label churn is precisely what they are.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action != 'labeled' &&
|
||||
github.event.action != 'unlabeled'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: labels-scope-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
# additive only — a hand-applied scope must survive the machine
|
||||
sync-labels: false
|
||||
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||
# PR-event runs must serialize or two sweeps race the same PR's labels
|
||||
# and both pass the request-the-human-once guard. GitHub keeps at most
|
||||
# one queued run per group (older queued runs are superseded), which
|
||||
# coalesces bursts instead of piling them up.
|
||||
concurrency:
|
||||
group: labels-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # base branch only — never the PR's code
|
||||
- name: reconcile state + stale
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: bash .github/scripts/labels-reconcile.sh
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
|
||||
|
|
|
|||
217
.github/workflows/release.yml
vendored
217
.github/workflows/release.yml
vendored
|
|
@ -1,213 +1,18 @@
|
|||
name: release
|
||||
# The release publisher — two doors into the same act (#83, #96):
|
||||
#
|
||||
# * The merge door (#96): merging the `release`-labeled PR into main IS the
|
||||
# release. The label is the intent, the version transition is the
|
||||
# interlock — VERSION at the merge commit must be non-`-dev` AND must have
|
||||
# changed in this PR, so a mislabeled ordinary PR fails loudly and creates
|
||||
# NOTHING. The job then tags the merge commit via the API and publishes,
|
||||
# in the SAME job on purpose: a GITHUB_TOKEN-created tag does not trigger
|
||||
# other workflows (GitHub's anti-recursion), so that tag can never re-enter
|
||||
# the tag door below and double-publish — publishing here is the only
|
||||
# chance, and the no-existing-tag/release assert covers a manual tag
|
||||
# racing the merge.
|
||||
#
|
||||
# * The tag door (#83) stays as the documented manual fallback and backfill,
|
||||
# on a bare X.Y.Z tag push (the 0.6.0 tag set the precedent — no 'v'
|
||||
# prefix). The tag must name the tree's own VERSION (a mismatch fails
|
||||
# loudly and creates NOTHING — a wrong release is worse than a missing
|
||||
# one).
|
||||
#
|
||||
# Both doors publish the release body from that version's CHANGELOG.md
|
||||
# section (.github/scripts/release-notes.sh, shared with test/release.sh) —
|
||||
# the curated prose, not the generated PR list. No assets are uploaded: for a
|
||||
# pure-bash tree, GitHub's source tarball for the tag IS the package, and
|
||||
# install.sh downloads exactly that.
|
||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||
on:
|
||||
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||
push:
|
||||
# The merge door rides pushes to MAIN, not pull_request events, for one
|
||||
# load-bearing reason the first review round caught (#97): a workflow
|
||||
# run triggered by a pull_request from a public FORK gets a READ-ONLY
|
||||
# GITHUB_TOKEN — `permissions:` cannot raise that ceiling — and every
|
||||
# ceremony PR this org has ever merged is cross-repo from the bot fork.
|
||||
# The asserts would pass and the tag create would 403, red on main,
|
||||
# every release. A push to main is an in-repo event with the full write
|
||||
# token, whoever authored the PR.
|
||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||
# never be skipped by a shape filter that didn't match
|
||||
branches: [main]
|
||||
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
|
||||
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
|
||||
# below, not be silently skipped by a pattern that didn't match.
|
||||
tags: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: write # create the tag ref + gh release create + the bump push
|
||||
# Two consumers (a declared permissions: block zeroes every unspecified
|
||||
# scope): the decide step's label read (commits/<sha>/pulls) and the bump
|
||||
# fallback's `gh pr create --label`.
|
||||
pull-requests: write
|
||||
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
|
||||
# grants the same pair for the same reason).
|
||||
issues: write
|
||||
|
||||
contents: write # tag ref create + release create + the bump push
|
||||
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||
issues: write # --label on that fallback PR rides the issues API
|
||||
jobs:
|
||||
# The merge door (#96), riding pushes to main (see the trigger comment:
|
||||
# fork PRs get a read-only token on pull_request events). The hand-set
|
||||
# `release` label (LABELS.md: automation never guesses intent) is read via
|
||||
# the API off the merge commit's PR, inside the decide step below.
|
||||
release-on-merge:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The pushed head plus its first parent (fetch-depth: 2): the
|
||||
# first parent is main the instant before the PR landed, which the
|
||||
# changed-in-this-PR assert compares against.
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 2
|
||||
# The decide step — the version asserts fused, because the `release`
|
||||
# label carries TWO legitimate meanings (LABELS.md: "release flow and
|
||||
# version/packaging work"): the ceremony PR that ships a version, and
|
||||
# ordinary work ON the release machinery — the PR that added this very
|
||||
# job included. The version tells them apart, in four states:
|
||||
# -dev, unchanged → work under the label: green NOTICE
|
||||
# no-op, not a red run per infra PR
|
||||
# -dev, changed → still a dev tree, so still work —
|
||||
# the post-release bump PR above all
|
||||
# (bare -> -dev after every release):
|
||||
# green NOTICE no-op
|
||||
# bare, unchanged, released → work merged in the post-release
|
||||
# window (ceremony landed, the -dev
|
||||
# bump has not): green NOTICE no-op
|
||||
# bare, unchanged, UNreleased→ the label says ship but this PR did
|
||||
# not mint the version: refuse to guess
|
||||
# bare, changed → the ceremony: proceed
|
||||
- name: 'decide: ceremony, or release-flow work under the label?'
|
||||
id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
base="$(git show HEAD^1:VERSION)"
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
if [ "$base" = "$ver" ]; then
|
||||
echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
|
||||
echo "ceremony=no" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "NOTICE: VERSION changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
|
||||
echo "ceremony=no" >> "$GITHUB_OUTPUT"
|
||||
exit 0 ;;
|
||||
esac
|
||||
if [ "$base" = "$ver" ]; then
|
||||
if gh release view "$ver" --json name >/dev/null 2>&1; then
|
||||
echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
|
||||
echo "ceremony=no" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "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." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The version transitioned — now the LABEL, the operator's declared
|
||||
# intent, read via the API because a push event carries no PR
|
||||
# payload (and the PR lives on a fork — the trigger comment). No
|
||||
# merged, release-labeled PR behind this commit = a transition
|
||||
# nobody declared: refuse.
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
|
||||
echo "VERSION transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#96), not a bare push — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||
- name: release notes — the version's own CHANGELOG.md section
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
# release-notes.sh fails loudly on a missing/empty section, which
|
||||
# fails the release here — before anything is created.
|
||||
run: |
|
||||
bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$ver" --silent 2>/dev/null; then
|
||||
echo "tag '$ver' already exists — a manual tag beat this run, or this is a re-run of a published release — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if gh release view "$ver" --json name >/dev/null 2>&1; then
|
||||
echo "release '$ver' already exists — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: tag the merge commit, then publish — one job, on purpose
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
# Same job as the asserts: the GITHUB_TOKEN-created tag triggers no
|
||||
# workflows (GitHub's anti-recursion), so the tag door cannot fire
|
||||
# off it — this step is the release's only chance to publish.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA"
|
||||
gh release create "$ver" --verify-tag --title "$ver" --notes-file "$RUNNER_TEMP/notes.md"
|
||||
# The post-release bump, folded into the release act (#96 followup —
|
||||
# operator decision: a mechanical one-liner deserves no PR of its
|
||||
# own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed
|
||||
# straight to main with this job's token. A GITHUB_TOKEN push fires
|
||||
# no workflows (anti-recursion), so the bump triggers neither this
|
||||
# door nor a red run; should branch protection ever refuse the direct
|
||||
# push, the step opens the bump PR itself and says so, loudly,
|
||||
# instead of leaving main armed to impersonate the release.
|
||||
- name: bump main to the next -dev — the release re-arms main itself
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
next="$(printf '%s' "$ver" | awk -F. '{ printf "%s.%s.%s-dev", $1, $2, $3 + 1 }')"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main
|
||||
git checkout -B main origin/main
|
||||
printf '%s\n' "$next" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "chore: bump main to $next — a dev install must not impersonate $ver"
|
||||
if ! git push origin main; then
|
||||
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
||||
git checkout -b "chore/bump-$next"
|
||||
git push origin "chore/bump-$next"
|
||||
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
|
||||
--title "chore: bump main to $next" \
|
||||
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One file, one line." \
|
||||
--label release
|
||||
fi
|
||||
|
||||
# The tag door (#83) — the manual fallback and backfill, unchanged. Gated
|
||||
# to the push event so a closed PR (the trigger above) never runs it
|
||||
# against a branch ref.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: the tag must name the tree's VERSION
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
|
||||
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: release notes — the version's own CHANGELOG.md section
|
||||
# release-notes.sh fails loudly on a missing/empty section, which
|
||||
# fails the release here — before anything is created.
|
||||
run: |
|
||||
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: create the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
|
||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
||||
with:
|
||||
version-source: file
|
||||
|
|
|
|||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# AGENTS.md — start at .ceremony/
|
||||
|
||||
This repository is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
||||
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||
Loading…
Reference in a new issue