From 1c3d873bf2c71b0c8bcd7a5aa893bd234fe8f3ca Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <224985780+codex-bot-andresmgsl@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:40:44 +0000 Subject: [PATCH] feat: adopt ceremony callers and doctrine --- .ceremony/AGENTS.md | 49 ++++++++ .ceremony/BUILDER.md | 76 ++++++++++++ .ceremony/LABELS.md | 98 +++++++++++++++ .ceremony/README.md | 14 +++ .ceremony/REVIEWER.md | 70 +++++++++++ .ceremony/TRIAGE.md | 95 +++++++++++++++ .github/labels.conf | 8 ++ .github/workflows/ci.yml | 14 +-- .github/workflows/labels.yml | 77 ++---------- .github/workflows/release.yml | 217 ++-------------------------------- AGENTS.md | 7 ++ 11 files changed, 442 insertions(+), 283 deletions(-) create mode 100644 .ceremony/AGENTS.md create mode 100644 .ceremony/BUILDER.md create mode 100644 .ceremony/LABELS.md create mode 100644 .ceremony/README.md create mode 100644 .ceremony/REVIEWER.md create mode 100644 .ceremony/TRIAGE.md create mode 100644 .github/labels.conf create mode 100644 AGENTS.md diff --git a/.ceremony/AGENTS.md b/.ceremony/AGENTS.md new file mode 100644 index 0000000..b8af0fa --- /dev/null +++ b/.ceremony/AGENTS.md @@ -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. diff --git a/.ceremony/BUILDER.md b/.ceremony/BUILDER.md new file mode 100644 index 0000000..a1680f1 --- /dev/null +++ b/.ceremony/BUILDER.md @@ -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. diff --git a/.ceremony/LABELS.md b/.ceremony/LABELS.md new file mode 100644 index 0000000..4be1fb3 --- /dev/null +++ b/.ceremony/LABELS.md @@ -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. diff --git a/.ceremony/README.md b/.ceremony/README.md new file mode 100644 index 0000000..34bc9c4 --- /dev/null +++ b/.ceremony/README.md @@ -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@` 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). diff --git a/.ceremony/REVIEWER.md b/.ceremony/REVIEWER.md new file mode 100644 index 0000000..17ac893 --- /dev/null +++ b/.ceremony/REVIEWER.md @@ -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. diff --git a/.ceremony/TRIAGE.md b/.ceremony/TRIAGE.md new file mode 100644 index 0000000..3dcf655 --- /dev/null +++ b/.ceremony/TRIAGE.md @@ -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. diff --git a/.github/labels.conf b/.github/labels.conf new file mode 100644 index 0000000..374f227 --- /dev/null +++ b/.github/labels.conf @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 538f229..de5e68d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 26587ab..8f3f5d5 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37b7e72..195a43f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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//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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e047dee --- /dev/null +++ b/AGENTS.md @@ -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.