Compare commits

..

7 commits
0.2.0 ... main

Author SHA1 Message Date
Daniel Marin
e4a581169a
Merge pull request #143 from claude-bot-andresmgsl/build/15-convert-cast
Some checks failed
ci / build (push) Has been cancelled
ci / install (push) Has been cancelled
release / release (push) Has been cancelled
feat: convert cast to the shared ceremony — package-json backend and the artifact hook debut
2026-07-23 17:05:39 +01:00
claude-bot-andresmgsl
26129672ea fix: the panel roster is the CURRENT bench — kimi was dropped by porting a stale list
The conversion preserved cast's pre-ceremony CONTRIBUTING panel verbatim,
but that list predates kimi-bot joining the bench — and the omission then
self-licensed: the handoff read the roster this very PR had authored,
counted codex+grok as the full panel, and handed off one reviewer short.
A conversion PR must write the roster from the current family bench, not
inherit whatever the repo last knew.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:14:42 +00:00
claude-bot-andresmgsl
53ef175662 test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
claude-bot-andresmgsl
f223aa699e docs: CONTRIBUTING points at the mirror; docs swept for deleted-path pointers
CONTRIBUTING keeps only what is genuinely cast's — the panel roster, the
local checks, the changelog house style, the prebuilt-asset contract and
the promotion drill meaning; the review-round doctrine and label taxonomy
now live in .ceremony/. drills/README.md and labeler.yml repoint at the
pinned guard and the vendored LABELS.md (the sweep rule from ceremony#12,
learned in rig#112).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:59:24 +00:00
claude-bot-andresmgsl
becf41940d docs: vendor the ceremony doctrine at 0.1.0 — .ceremony/ mirror + AGENTS.md stub
docs-sync --fix materializes .ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,
LABELS}.md byte-identical to heavy-duty/ceremony@0.1.0 (the pin docs-sync
reads from release.yml — one pin governs machinery and doctrine), plus
the machine-managed README marker and the root AGENTS.md stub. Root
LABELS.md retires: a hand-maintained copy beside a machine-verified
mirror is the drift the mirror exists to end (rig's precedent, rig#112).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:58:17 +00:00
claude-bot-andresmgsl
76926785f5 feat(release): consume the shared ceremony at 0.1.0 — callers, artifact hook, labels.conf
The 250-line release.yml becomes the caller stub (version-source:
package-json — the backend's first exercise); the prebuilt-asset build
moves into .github/actions/release-artifact/ per ceremony#9's hook
contract (setup-node moves into the hook — the shared workflow is
node-free). labels.yml becomes its caller; the panel and cast's six
scope rows extract into .github/labels.conf (data only — the parser
refuses comment lines). ci.yml swaps the guard script steps for the
pinned actions and adds changelog-armed (cast regains the guard
reverted in cast#108) and docs-sync. The four shared scripts die;
test/labels-reconcile.sh dies with the reconciler it sourced
(machinery test files go whole — rig #13's rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:57:10 +00:00
github-actions[bot]
2aa7018db4 chore: bump main to 0.2.1-dev — a dev install must not impersonate 0.2.0 2026-07-21 18:06:54 +00:00
25 changed files with 659 additions and 3102 deletions

49
.ceremony/AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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 23 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.

View file

@ -0,0 +1,39 @@
name: release-artifact
description: >-
Build cast's prebuilt release asset — the ceremony's artifact hook
(ceremony#9's contract; ceremony#15 is this conversion). Where cast
differs from its siblings: box and rig are pure bash, so GitHub's source
tarball for the tag IS their package; cast's source tarball is not
runnable — it needs npm ci and tsc first. So the build happens ONCE,
here, and the asset is the runnable tree — bin/, dist/, production
node_modules/, package.json — staged as cast-<version>/ inside
cast-<version>.tgz. That name and layout are the install contract: the
installer's release channels download this exact asset and never run npm
or tsc (test/install-sh.test.ts pins it). The hook owns its own
toolchain (the shared workflow is node-free).
inputs:
version:
description: The release version the asset is named for
required: true
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: build once, stage the runnable tree, drop the tgz
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
# Deliberately no tests/checks here: ci.yml already gated the merge
# commit this release names, and cast's suite needs `age`, which
# the release runner does not install. The staged tree is exactly
# what an install needs to run.
npm ci
npm run build
npm prune --omit=dev
mkdir -p "$RUNNER_TEMP/stage/cast-$VERSION"
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$VERSION/"
tar -C "$RUNNER_TEMP/stage" -czf "$RELEASE_ASSETS_DIR/cast-$VERSION.tgz" "cast-$VERSION"

4
.github/labeler.yml vendored
View file

@ -1,5 +1,5 @@
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope # path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive # scope story (issues are hand-scoped at triage; paths only exist on PRs). Additive
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives. # only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
"scope:capture": "scope:capture":
- changed-files: - changed-files:

7
.github/labels.conf vendored Normal file
View file

@ -0,0 +1,7 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
scope:capture|C5DEF5|draft/capture — reading the live world into a manifest
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
scope:fleet|C5DEF5|fleet/inventory/server — placement
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client

View file

@ -1,250 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
# on HEAD must be a SUPERSET of the set at the merge base.
#
# Ported from box (heavy-duty/box#122, caught in review of box#118) for #133,
# because cast's release-notes.sh carries the exact awk shape that made box#118
# dangerous. The failure it exists to catch leaves no trace either. An author
# adding an entry under '## Unreleased' REPLACES the line below it instead of
# inserting above it:
#
# -## 0.1.1 — 2026-07-19
# +## Unreleased
# +
# +### Fixed
# +
# +- **An entry**
#
# git merges that cleanly — it is a one-line edit inside a file nobody has
# touched concurrently — and the shipped section's whole body is silently
# absorbed into '## Unreleased'. 0.1.1 no longer HAS a section; the notes
# anchor release-notes.sh extracts by is gone, and the next release cut from
# that state republishes 0.1.1's prose as if it were new.
#
# The ARMING rule (test/release.test.ts, "the changelog is armed for the next
# entry (rig#66)") is green on exactly that tree, correctly: it asks only
# whether the TOP section agrees with package.json's version, and deleting
# '## 0.1.1' leaves '## Unreleased' on top. It is not wrong, it is narrow — it
# guards ONE heading, the one a PR is about to write under. This guards the
# REST of the file, the part no single tree can be asked about at all, because
# "a heading disappeared" is not a property of a tree — it is a property of a
# DIFF.
#
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
# ceremony (#111) adds one and never removes one; nothing else in the
# documented flow (CONTRIBUTING.md, "Releasing") touches them. So SUPERSET is
# exact — it has no legitimate violation to carve an exception for. The stamp
# is covered for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS
# X.Y.Z and removes no X.Y.Z heading, because 'Unreleased' is not one.
# '## Unreleased' is deliberately NOT in the set this guards — the arming rule
# owns that heading, keyed on package.json's version, and the ceremony
# legitimately consumes it.
#
# A file of its own, NOT a clause inside the arming assertions, for three
# reasons. Its input is different (a git history, not two files). Its
# degradation is different (no base ref is a SKIP, not a failure). And the
# arming assertions run against constructed in-memory changelog strings that
# are not git repos at all — folding a git-dependent assert into them would
# make every one of those cases either skip or lie. Same discipline as
# release-notes.sh: its own file so test/release.test.ts can drive it.
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
changelog="${2:-CHANGELOG.md}"
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
# degradation becomes a red run there instead. A guard that can silently
# stop guarding is the failure shape this whole family of checks exists to
# refuse, so the skip path is loud and CI refuses to take it at all.
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
skip() {
if [ "$strict" = "1" ]; then
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
exit 1
fi
echo "changelog-monotonic: containment SKIPPED — $*"
echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading"
echo " half needs the history. In CI this same condition is a hard failure.)"
exit 0
}
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
# version. Field $2, the same split the arming rule and release-notes.sh use,
# so the three cannot disagree about what a section header is. 'Unreleased'
# fails the shape and is excluded by construction.
headings_raw() {
awk '
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
'
}
headings() { headings_raw | sort -u; }
# --- uniqueness on HEAD (the box#118 class) ----------------------------------
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
# extras on the head side — with or without `sort -u`, base {0.1.1} minus head
# {0.1.1, 0.1.1} is empty. Multiset comparison does not close it either, for
# the same reason. The assert that does is uniqueness of version headings ON
# HEAD, kept alongside containment rather than replacing it.
#
# cast is the MORE exposed of the two repos here (#133). release-notes.sh
# extracts with:
#
# /^## / { grab = ($2 == ver); next }
# grab { print }
#
# There is no `exit`. `grab` re-arms on every matching '## ' line, so two
# '## 0.1.1' headings make the published body ABSORB whatever sits between the
# copies — and an entry stranded there is dropped from the NEXT release's notes
# as well. (rig's extractor has `if (found) exit`, so it truncates instead of
# absorbing — same class, milder symptom. cast has the absorbing one.)
#
# This is the shape box#118's bad rebase actually produced: two
# '## 0.8.0 — 2026-07-19' headings with the incoming entry between them. Every
# other guard stayed green — the arming rule happy (the top section was still
# right), tests and `bash -n` clean — while release-notes.sh re-armed its grab
# on the second heading and folded post-cut prose into the shipped release
# body. Note the arming rule's "double re-arm" case counts duplicate
# '## Unreleased' headings only; duplicate VERSION headings, the ones that
# reach release-notes.sh, are this script's.
#
# Nothing legitimate repeats a version heading: the ceremony stamps a NEW
# version, and 'Unreleased' fails the version shape and never reaches here.
dupes="$(headings_raw < "$changelog" | sort | uniq -d)"
if [ -n "$dupes" ]; then
{
echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):"
echo
printf '%s\n' "$dupes" | sed 's/^/ ## /'
echo
cat <<EOF
Each version heading must appear exactly once. A repeat splits one release
into two same-named sections, and release-notes.sh re-arms its extraction on
every matching '## ' line — so the published body for that version absorbs
whatever sits between the copies, and an entry stranded there is dropped from
the NEXT release's notes as well.
This is the box#118 shape: an entry meant for '## Unreleased' was inserted
after a shipped heading, and the heading re-added below it. The fix is one
heading, with the entry above it under '## Unreleased':
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
Quick check on any changelog-touching rebase:
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
EOF
} >&2
exit 1
fi
# --- everything below needs the HISTORY --------------------------------------
# Uniqueness is settled. What follows is containment, which compares HEAD
# against the merge base and therefore genuinely depends on the base ref, the
# merge base, and the base blob. Each of those can be unavailable for reasons
# that are not the author's fault (a shallow clone, a fork checkout without the
# upstream remote, the commit that first adds the changelog), so each degrades
# rather than failing — which is exactly why the uniqueness half must NOT live
# down here (#133, box#143). It asks nothing of the history, and gating it
# behind these conditions let a duplicate exit 0 on a message about deletion.
#
# That ordering mattered MORE here than anywhere. cast's release-notes.sh has
# no `exit`, so `grab` re-arms on every matching '## ' line and a duplicate
# makes the published body ABSORB whatever sits between the copies — the live
# extraction bug this guard exists for. The half with that bug behind it was
# the half with the most ways to silently not run.
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| skip "not inside a git work tree, so there is no history to compare against"
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
[ -n "$merge_base" ] \
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
# The changelog may not exist at the merge base at all (the commit that adds
# it). Nothing to have deleted, so nothing to assert.
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
[ -n "$base_file" ] || {
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)."
exit 0
}
base_headings="$(printf '%s\n' "$base_file" | headings)"
head_headings="$(headings < "$changelog")"
# comm -23: lines in the base set that are NOT in the head set — exactly the
# headings this branch removed.
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
if [ -n "$missing" ]; then
{
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
echo
printf '%s\n' "$missing" | sed 's/^/ ## /'
echo
cat <<EOF
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
Release headings are APPEND-ONLY. The ceremony adds one (#111); nothing ever
legitimately removes one. So this is not a judgement call — it is a defect,
and almost always the same one (#133, box#122): an entry written under
'## Unreleased' REPLACED the heading below it instead of being inserted
ABOVE it. The shipped section's body is now sitting under '## Unreleased',
and the version it belonged to has no section at all.
Nothing else will say so. git merges that edit cleanly — no conflict, no
signal — and the arming rule stays green, because the TOP section is still
the right one for this VERSION. The damage surfaces at the NEXT release,
when release-notes.sh cannot find the section it extracts by heading, or
worse, republishes the absorbed prose as if it were new.
The fix is to put the heading back and INSERT above it, never over it:
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
If you are genuinely renaming a released version, that is a rewrite of
history this guard is meant to stop; say so in the PR and change the guard
deliberately, in its own commit.
EOF
} >&2
exit 1
fi
count="$(printf '%s\n' "$base_headings" | grep -c . || true)"
head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)"
# The success line has two honest forms, because this step now runs on two
# shapes of event. On a push to main the merge base IS HEAD: containment
# compared the file against itself and asserted nothing, and deletion is
# undetectable on that event by construction. Reporting "all N still present"
# there would be the same dishonesty the skip messages were fixed for (#133) —
# a log claiming a check that did no work. Uniqueness is the half that actually
# ran, so that is the half the line names.
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)."
else
echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog"
fi

View file

@ -1,152 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# drill-recorded.sh [<drills-dir>] [<version-file>] — assert that the version
# this tree claims to ship has a DRILL RECORD at <drills-dir>/<version>.md.
#
# CONTRIBUTING says a release carries the full real-hardware drill. Nothing
# checked that, so no release in this family ever carried one: the step lived
# only in a reviewer's memory, and a step that lives in a reviewer's memory is
# performed exactly as often as the reviewer remembers it. A bot finally
# blocked on it, which is the first time the omission was visible at all. So
# the gate moves into CI, where it is asserted on every release PR rather than
# recalled.
#
# ONE FILE PER VERSION — WHY THE PARSER IS GONE
#
# The first cut kept every record in one drill/RUNS.md and asked awk which
# section belonged to this version. That bought a heading grammar: em-dash
# field matching, an optional ' — DATE' tail, a whole-version comparison so
# 0.2.0-rc1 could not satisfy 0.2.0, a '(NF == 5 || $6 == dash)' tail
# constraint to stay in step with box's twin, and a non-blank body rule.
#
# All of it existed ONLY because records shared a file — and in review this
# repo shipped two defects out of that complexity: a `sed '/./,$!d'`
# extraction where `.` matches a space, so a heading followed by one tab
# satisfied the gate; and heading-grammar drift from box's stricter form.
# Two defects, on the one check whose entire job is to demand evidence.
#
# One file per version makes nearly all of it UNREPRESENTABLE. `0.2.0.md` and
# `0.2.0-rc1.md` are simply different files — the whole-version rule is the
# filesystem's, not a comparison anyone can get wrong. There is no heading to
# parse, so there is no grammar to drift from box's. What is left is a
# question a shell can ask directly: does the file exist, and does it say
# anything.
#
# The directory is plain `drills/`, NOT `.drills/`. Dot-prefixed directories
# are invisible to globs without `dotglob`, which is the exact blind spot that
# produced #118, #121 here and box#116 — a sweep that looks green because it
# never descended into the directory holding the thing it was meant to check.
#
# WHAT IT ASSERTS, AND WHAT IT DELIBERATELY DOES NOT
#
# It asserts a RECORD EXISTS — not that the drill passed. That is the whole
# design. A maintainer may ship on a failed or partial drill; what they may not
# do is ship on silence. Requiring a record makes a waiver a deliberate,
# reviewable commit (a file saying who waived it and what is untested) instead
# of the default outcome of forgetting. A guard that demanded a PASS would be
# argued with and eventually bypassed; one that demands EVIDENCE has nothing to
# argue about.
#
# PER-REPO, ON PURPOSE
#
# This reads cast's OWN drills/. It does not reach into box or rig to ask
# whether the family drilled. A cross-repo lookup has a failure mode this repo
# keeps refusing: when the fetch fails — no network, moved file, renamed repo,
# a token without read on the other repo — the honest answers are "unknown" and
# "blocked", but the shape such code actually takes degrades to "pass". Same
# class as the unreadable check rollup that read as "nothing is failing".
#
# There is also nothing to look up. The three repos' drills are INDEPENDENT
# (CONTRIBUTING.md, "Releasing") — run in any order, on any schedule, in
# separate sittings. What makes that safe is that every drill pins the SAME
# FIXED SET OF CANDIDATE REFS (RIG_REPO/RIG_REF at mint time), so each one
# exercises the combination that will ship rather than whatever main happens
# to be that afternoon.
#
# That pinning, not sequencing, is what dissolves the box<->rig recursion. box
# and rig ARE mutually recursive — rig builds the host that runs box, box's
# seed calls rig back to converge the guest — but candidate refs are static
# identifiers that exist as soon as the release branches do, long before any
# drill runs. A cycle at runtime becomes independent tests against one fixed
# pair, and no repo must ship before another can be drilled. The three
# releases are NOT published in a fixed sequence.
#
# Each repo also drills a DIFFERENT thing: box asserts the isolation contract,
# rig asserts convergence, cast asserts promotion. Three different exercises
# over a shared substrate — which is exactly why the records are per-repo.
# Each cites the shared run ID naming the pinned set, plus the other repos'
# SHAs, so three records still reassemble into one picture without any repo
# reading another's file.
#
# A file of its own, not a clause inlined in ci.yml, for the same reason as
# release-notes.sh and changelog-monotonic.sh: test/release.test.ts drives the
# REAL script against fixtures, so what the tests prove is what CI runs.
drills="${1:-drills}"
version_file="${2:-package.json}"
[ "$#" -le 2 ] || { echo "usage: drill-recorded.sh [<drills-dir>] [<version-file>]" >&2; exit 2; }
[ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2; exit 1; }
# cast's version lives in package.json (there is no VERSION file), so this
# reads JSON — with sed, not node. release-notes.sh takes the version as an
# ARGUMENT and so never had to; this one is invoked by CI with no arguments and
# has to find it itself. sed keeps the script runnable by `bash -n`, shellcheck
# and a bare shell alike, with no dependency on a toolchain being installed
# before the guard can speak. The first "version" key in package.json is the
# package's own by npm's schema; dependency entries are "<name>": "<range>"
# pairs and carry no "version" key to be confused with it.
ver="$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file" | head -1)"
[ -n "$ver" ] || { echo "drill-recorded: no \"version\" key in $version_file" >&2; exit 1; }
# A -dev tree is main between releases. Nothing ships from it, so there is no
# claim to evidence — and demanding a record here would make every ordinary
# feature PR red until somebody drilled for a version that will never be cut.
# The gate is about the SHIP CLAIM, and `-dev` is the absence of one.
case "$ver" in
*-dev)
echo "drill-recorded: version $ver is a development tree — nothing ships from it, so there is nothing to assert."
exit 0
;;
esac
# A bare version is a release ceremony tree: this is the tree whose merge IS
# the release, so this is where the evidence has to exist.
#
# The record is <drills-dir>/<version>.md, and it must contain at least one
# NON-WHITESPACE character. That second clause is the one surviving piece of
# the whitespace defect found in review (#138): a file of only spaces, tabs
# and newlines is a file, and `[ -f ]` is happy with it, but it is not a
# record — an evidence-free release for the price of an invisible character.
# `grep -q '[^[:space:]]'` asks the question the old `sed '/./,$!d'` only
# claimed to: `.` matches a space, a POSIX class does not.
record="$drills/$ver.md"
if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then
{
echo "drill-recorded: version $ver is a release, but there is no drill record at $record."
echo
echo " A release PR's version must have a NON-EMPTY file named for it:"
echo
echo " $drills/$ver.md"
echo
echo " (A file that exists but holds only whitespace counts as no record."
echo " One file per version, so '$ver-rc1.md' is a different record and"
echo " does not satisfy '$ver', or the other way round.)"
echo
echo " To unblock, either:"
echo " * run the drill and record it — the legs (team, apply, idempotent"
echo " diff, smoke, inventory, emit-draft, fleet, destroy, read-only"
echo " guard), the numbers, and what failed; or"
echo " * record an explicit maintainer WAIVER for this version in that"
echo " file, saying who waived it and what is untested."
echo
echo " The waiver is allowed on purpose: this gate requires a RECORD, not a"
echo " passing result, so shipping without a drill stays possible — and"
echo " stays a deliberate, reviewable commit instead of an oversight."
} >&2
exit 1
fi
echo "drill-recorded: $record carries a drill record for $ver ($(grep -c '' "$record") line(s))"

View file

@ -1,486 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
# request-changes. Some live bots are comment-only and post agreement as a
# COMMENTED review — a non-verdict this machine refuses to guess about (body
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
# by requesting the human's review — an explicit request is a fact, and it is
# the one this machine trusts (see decide_state's top precedence). The
# machine auto-requests the human only in the no-judgment-needed case: three
# formal head-current approvals. Any approval that counts must be bound to
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
# fixture tests in test/labels-reconcile.sh.
HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested)
# Labels this machine used to own and no longer does. Cleared on sight so a
# retirement heals the board instead of stranding a label nothing recomputes.
RETIRED=(state:needs-rebase)
STALE_AFTER=$((48 * 3600))
log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
# ---------------------------------------------------------------------------
# The state machine. Pure functions over four globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
# ---------------------------------------------------------------------------
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly
# means this PR has no checks. Collapsing the two let an API hiccup present
# as "nothing is failing", i.e. as mergeable-by-a-human: the same
# unknown-certified-as-green shape as the bug this machine exists to stop.
# The caller skips the PR entirely rather than labelling on facts it did not
# read; blocking on it instead would flap the whole board on one bad call.
# The rollup mixes two node types with two different closed enums: CheckRun
# carries `conclusion` (CheckConclusionState), StatusContext carries `state`
# (StatusState). Rather than list the outcomes that block — the version that
# shipped in this PR's first round listed four, and ERROR, CANCELLED and
# STALE fell through its `else` into SUCCESS — this lists the outcomes that
# DON'T, and treats everything else as blocking.
#
# That direction is the point. An outcome we do not recognise is one we
# cannot certify as mergeable, and certifying the unrecognised as green is
# the exact shape of #136. The cost of being wrong is symmetric in form and
# not in consequence: a false FAILURE parks the PR on the agent, who looks;
# a false SUCCESS invites a human to merge a tree that will not merge.
jq -r '
if (has("statusCheckRollup") | not) then "UNREADABLE" else
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
# is not a failed one, and path-filtered jobs skip constantly here.
["SUCCESS", "NEUTRAL", "SKIPPED"] as $passing
# "" covers a StatusContext still reported with no state at all.
| ["", "PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", "EXPECTED"] as $waiting
# A re-run does not evict the run it superseded — the rollup keeps both.
# This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39)
# beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow.
# Once CANCELLED blocks, judging every entry would strand this very PR in
# needs-rebase forever, so collapse each context to its newest entry first.
# Key on workflow + name because a bare job name is only unique within its
# workflow.
#
# Dating a run is the subtle part, and getting it wrong restores the bug.
# A run still in flight has no completion, but `gh` does not omit the
# field: its Go struct marshals the zero time as "0001-01-01T00:00:00Z",
# which is a string, so `//` will not fall through it. Ordering on
# completion therefore sorted the LIVE re-run to the bottom and let `last`
# pick the very run it superseded — reporting the old SUCCESS while a
# replacement was still running, which is #136 again.
#
# So: date a run by when it BEGAN, discarding both spellings of absent
# (null, and the zero sentinel) and falling back only if it never recorded
# a beginning. NOT by the newest stamp of any kind: `max` compares the
# completion of a finished run against the start of a live one, which are
# different quantities and not an ordering on runs. A run cancelled by the
# concurrency group does not stop the instant its replacement starts — the
# runner has to wind down — so predecessor.completedAt > successor.startedAt
# is the ordinary case, and `max` dated the dead predecessor newer than the
# live run that replaced it, narrowing both failures above without closing
# them. The list is already in preference order, so `first` IS that rule.
#
# An entry that carries no usable timestamp at all sorts LAST rather than
# first — something we cannot date is most likely the thing just created,
# and treating it as newest keeps an undateable in-flight run from being
# discarded in favour of a stale success. Every ambiguity resolves toward
# "not settled".
| [ (.statusCheckRollup // [])[]
| { ctx: [.workflowName // "", .name // .context // ""],
at: ([.startedAt, .createdAt, .completedAt]
| map(select(type == "string" and . != ""
and (startswith("0001-01-01") | not)))
| first // ""),
outcome: ((.conclusion // .state // "") | ascii_upcase) } ]
| group_by(.ctx)
| map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest
| if ($latest | length) == 0 then "NONE"
elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE"
elif (($latest - $passing) | length) > 0 then "PENDING"
else "SUCCESS" end
end'
}
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit
review="$(jq -c --arg u "$1" \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
<<<"$REVIEWS_JSON")"
if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")"
case "$state" in
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ;;
APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
*)
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo FEEDBACK ;;
esac
}
human_request_needed() { # 0 when needs-human requires a FRESH human request
# already requested → the handoff is live; head-current human approval →
# nothing left to ask. Anything else (never reviewed, an old comment, an
# approval of an older head) stalls the handoff unless we request —
# guarding on "has the human ever reviewed" wedged exactly that way.
if requested "$HUMAN"; then return 1; fi
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
return 0
}
blockers() { # → the blocker:* labels this PR should carry, one per line
# The second axis. These are FACTS ABOUT THE BRANCH, and they are mutually
# independent — a PR can be conflicted and red and unasked at once — so they
# are a set, not an ordering. That is the whole point of splitting them out
# of state:*: every precedence bug this machine has had (needs-human
# surviving a conflict, MISSING swallowing STALE) came from projecting
# independent facts onto one totally-ordered label. A set has no precedence
# to get wrong.
#
# UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for
# about a minute after every merge while it recomputes, and flapping every
# open PR on each merge would be worse than the bug. Same for a failed read
# of either fact — both default to the "do not know" value, which blocks
# nothing. An unset global (an older fixture, a failed fetch) must never
# invent a verdict it did not read.
case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac
case "${CHECKS:-NONE}" in FAILURE) echo blocker:ci-red ;; esac
# Nobody is on the hook for a verdict somebody still owes. Distinct from
# bots-reviewing, which says a request is live and an answer is coming:
# here the round is stalled because no one was ever asked, and the board
# said "waiting on the bots" for the 48h it took `stale` to notice.
# A draft is exempt (the bots ignore drafts by design), and so is an
# explicit human request — a maintainer claiming a PR early is deliberate,
# not a dropped ball.
if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then
local b v owed=false any_requested=false
for b in "${BOTS[@]}"; do
requested "$b" && any_requested=true
# MISSING and STALE are both verdicts this head does not have: nobody
# reviewed it, or everybody reviewed something else. The agent owes an
# ask either way — the stale round is if anything the worse of the two,
# since it has approvals on the page that no longer describe the tree.
v="$(bot_verdict "$b")"
case "$v" in MISSING | STALE) owed=true ;; esac
done
if [ "$owed" = true ] && [ "$any_requested" = false ]; then
echo blocker:unrequested
fi
fi
}
decide_state() { # → the one state:* label this PR should carry
if [ "$DRAFT" = true ]; then echo state:building; return; fi
local s
s="$(round_state)"
# The one rule joining the two axes: state:needs-human means a human could
# merge this RIGHT NOW, so it requires a clear branch. Any blocker at all
# means the work is the agent's — whatever the review round says — and the
# blocker label says which work it is. Nothing else in this function reads
# the branch, which is what keeps the ordering below purely about reviews.
if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then
echo state:addressing; return
fi
echo "$s"
}
round_state() { # → the state the REVIEW ROUND alone implies; knows no branch facts
local b verdicts=""
for b in "${BOTS[@]}"; do
if requested "$b"; then echo state:bots-reviewing; return; fi
done
# Collect the WHOLE round before applying any precedence. Deciding inside
# the loop let BOTS order pick the winner: a MISSING returned immediately,
# so a STALE belonging to a later bot was never even read, and the mixed
# round (one approval staled by a push, another bot yet to review) came out
# needs-human — the #136 headline shape, with zero reviews bound to the head.
for b in "${BOTS[@]}"; do
verdicts="$verdicts $(bot_verdict "$b")"
done
case "$verdicts" in
# STALE = a verdict for an older head. Unlike MISSING, this outranks the
# human request: every approval it covers was invalidated by a push, so
# NOBODY has reviewed this tree. Handing that to the human is the #136 case
# where everything reads green — mergeable, CI passing, "waiting on the
# human" — over code no reviewer has seen. The agent owes a re-request.
# Checked before MISSING because "unfinished" must not swallow "and also
# stale": a round that is both is a push that outran the re-requests, not
# a maintainer deliberately claiming the PR early.
*STALE*) echo state:addressing; return ;;
esac
case "$verdicts" in
# No verdict at all from some bot, and nothing staled. An explicit human
# request still outranks an unfinished round — a maintainer pulling a PR
# to themselves early is a deliberate act, and the original precedence.
#
# Otherwise it is the AGENT's ball, not the bots'. The loop above already
# returned for every live bot request, so reaching here with a MISSING
# means somebody owes a verdict and nobody was asked for one — the round
# is not running. Calling that bots-reviewing was the lie that let a
# forgotten PR read "waiting on the reviewers" for the 48h it took the
# stale sweep to notice. blocker:unrequested says why.
*MISSING*)
if requested "$HUMAN"; then echo state:needs-human; return; fi
echo state:addressing; return ;;
esac
# an explicit human request outranks the remaining bot outcomes — it is the
# final gate, and a maintainer pulling a PR to themselves early counts too
if requested "$HUMAN"; then echo state:needs-human; return; fi
case "$verdicts" in
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
*BLOCK* | *FEEDBACK*) echo state:addressing; return ;;
esac
# the bots all approve — but if the human's standing word is
# changes-requested (and nobody re-requested them yet), the agent owes
# fixes, not the human a nag
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
echo state:addressing
else
echo state:needs-human
fi
}
# ---------------------------------------------------------------------------
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
# others — each PR reconciles in a subshell and a failure just logs.
# ---------------------------------------------------------------------------
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer
blocker:conflict|B60205|Does not merge — the branch conflicts and the agent owes a rebase
blocker:ci-red|B60205|A check is failing — the agent owes a fix (not a rebase)
blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked for one
merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here)
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first
release|0E8A16|Release flow and version/packaging work
scope:capture|C5DEF5|draft/capture — reading the live world into a manifest
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
scope:fleet|C5DEF5|fleet/inventory/server — placement
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client
EOF
}
has_label() { grep -qxF "$1" <<<"$LABELS"; }
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age
desired="$(decide_state)"
# encode the runbook's last step for the no-judgment case: three formal
# head-current approvals → the human is asked, once. The guard asks whether
# a FRESH human review is needed for THIS head — never "has the human ever
# reviewed", which wedged the handoff after any earlier human comment.
# Idempotent (a live request suppresses it); race-free via the shared
# concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (round passed)"
fi
# ---- converge both axes ----
# state:* is exclusive (everything but $desired comes off); blocker:* is a
# set (each one on or off on its own); RETIRED always comes off. One edit
# call for all of it, so a PR never flickers through a half-applied board.
local want_blockers add=""
want_blockers="$(blockers)"
remove=""
for s in "${STATES[@]}"; do
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
done
for s in "${RETIRED[@]}"; do
if has_label "$s"; then remove="$remove,$s"; fi
done
for s in "${BLOCKERS[@]}"; do
if grep -qxF "$s" <<<"$want_blockers"; then
has_label "$s" || add="$add,$s"
else
has_label "$s" && remove="$remove,$s"
fi
done
add="${add#,}"
remove="${remove#,}"
# Never NAME a label the repo does not have. `gh issue edit --add-label`
# rejects the WHOLE call on one unknown name — nothing is applied — so a
# single missing blocker would take the state convergence down with it, on
# exactly the PRs this change exists to fix, surfacing only as a log line.
# Batching state and blockers into one edit for anti-flicker is what widened
# that blast radius; filtering the add side is what closes it again.
# Removals need no filter: they are built from has_label, so the label
# provably exists. REPO_LABELS unreadable means no filtering rather than
# filtering everything out — a failed read must not silently strip the board.
local skip_edit=false
if [ -n "${REPO_LABELS:-}" ]; then
local kept="" missing="" want
for want in ${add//,/ }; do
if grep -qxF "$want" <<<"$REPO_LABELS"; then kept="$kept,$want"
else missing="$missing $want"; fi
done
add="${kept#,}"
# A missing STATE label skips only the EDIT — never the rest of this
# function. Everything below is independent of the state:* taxonomy, and
# returning here stranded it: `merge-next` kept claiming "merge this one
# next" on a PR the board had moved to the agent, and the stale sweep
# stopped running. That is the original false-invitation bug, reintroduced
# in the very fix meant to survive a cold-start repo — and a regression
# against the old behaviour, which failed the edit and fell through.
if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then
log "#$n: WARNING: state label '$desired' does not exist — skipping the label edit; dispatch the workflow to bootstrap"
skip_edit=true
elif [ -n "$missing" ]; then
log "#$n: WARNING: missing label(s)$missing — state still converged; dispatch the workflow to bootstrap"
fi
fi
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
args=(--add-label "$desired${add:+,$add}")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
fi
fi
# ---- merge-next: cleared, never set ----------------------------------
# Queue order is INTENT — which PR should land first is a judgement about
# conflicts and dependencies that GitHub knows nothing about, so the
# reconciler must not guess it (LABELS.md's rule for `blocked`/`release`).
# What it CAN do is stop the label going stale the way needs-human did:
# the moment the PR is no longer the thing a human should merge next, the
# claim is removed. Setting it stays with whoever owns the queue.
if has_label merge-next && [ "$desired" != state:needs-human ]; then
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
last_activity="$(
{
jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
age=$((NOW - $(date -d "$last_activity" +%s)))
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
}
main() {
REPO="${REPO:?set REPO to owner/name}"
NOW="$(date +%s)"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
# add against it, because one unknown name fails the whole edit call.
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")"
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
local n
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
(
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
# mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
# this returns the same MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
# Failure to read them is NOT fatal and NOT treated as broken — an API
# hiccup must never flap every PR into needs-rebase, so both degrade to
# the "do not know" value that triggers nothing.
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')"
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
CHECKS="$(checks_state <<<"$GH_VIEW")"
# Read failed: leave this PR exactly as it is. Recomputing on facts we
# did not read is how an API hiccup turns into a false "merge me" —
# and the next tick is 15 minutes away, not 15 hours.
if [ "$CHECKS" = UNREADABLE ]; then
log "#$n: could not read mergeability/checks — left alone this pass"
exit 0
fi
reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs"
done
log "reconciled."
}
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi

View file

@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
# section of the changelog: every line between its '## <version> — <date>'
# header and the next '## '. This is what release.yml hands to
# 'gh release create', so the release notes are the curated prose we wrote,
# not the PR list GitHub would generate (#96; box#83's extraction). Fails
# loudly when the section is missing or empty — a tag without its changelog
# section is a release ritual skipped, and an empty release body would paper
# over it.
#
# A file of its own (not inlined in release.yml) so test/release.test.ts
# drives the same extraction against fixtures and the real CHANGELOG.md.
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
# $2 of a section header ('## 0.1.0 — 2026-07-18') is the bare version —
# compared WHOLE, so 0.1.0 can never match a 0.1.0-rc1 section (or vice
# versa), and no regex-escaping of dots. sed drops the blank padding under
# the header; the command substitution eats the trailing blanks.
notes="$(awk -v ver="$ver" '
/^## / { grab = ($2 == ver); next }
grab { print }
' "$changelog" | sed '/./,$!d')"
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#96)" >&2; exit 1; }
printf '%s\n' "$notes"

View file

@ -11,16 +11,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# fetch-depth: 0, for the changelog-monotonic step below and only # changelog-monotonic compares HEAD against the merge base; a
# for it. That check is about a DIFF — which release headings the # checkout that cannot resolve it is a hard failure in CI, not
# merge base had — so it needs the base branch's history present, # a skip (a guard that can quietly stop guarding is the failure
# and the default depth-1 checkout has none of it. An explicit # shape this repo keeps refusing).
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# tree this size costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@ -40,55 +34,35 @@ jobs:
# tracked *.sh, so a new script cannot go unlinted quietly (#118). # tracked *.sh, so a new script cannot go unlinted quietly (#118).
- name: shellcheck — every tracked shell script - name: shellcheck — every tracked shell script
run: bash .github/scripts/shellcheck-all.sh run: bash .github/scripts/shellcheck-all.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
# ...and no SHIPPED release heading was deleted or DUPLICATED (#133; # The release guards, doctrine in heavy-duty/ceremony's README
# box#122's guard, box#143's ordering fix). Its own step so that when it # (ceremony#15 is this conversion). Each guard's war story — why it
# goes red the log names the invariant that broke — and a DIFFERENT # exists, what it refuses — lives with its implementation upstream;
# invariant from the arming rule npm test carries: arming is a fact # the four pins below and the two workflow callers must always name
# about this tree, monotonicity is a fact about this tree versus its # the same ceremony tag.
# merge base. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
# #
# NOT pull-request-only, and that is the #133 fix at the workflow level. # changelog-armed: the version-keyed arming rule (rig#66 is the
# The two halves have different vacuity: DELETION is vacuous on a push # incident; the unconditional form cast#108 reverted — this is its
# to main (the merge base IS HEAD), but DUPLICATION is vacuous on no # correct return).
# tree at all, so gating the whole script on `pull_request` left a - uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
# duplicate that reached main by any other route unasserted forever. with:
# version-source: package-json
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a # changelog-monotonic: no shipped heading deleted or duplicated
# push event `github.base_ref` is EMPTY, so the argument would collapse # (#133; box#122's guard, box#143's ordering fix). Strict by default:
# to a bare `origin/`, which does not resolve — and STRICT=1 correctly # an unresolvable base ref is red, never a quiet skip — hence the
# promotes that to a hard failure, turning every push to main red. With # fetch-depth: 0 above.
# the fallback it resolves to the pushed branch, whose merge base with - uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
# HEAD is HEAD or its parent: containment passes vacuously, exactly as # drill-recorded: a release version carries drills/<version>.md
# the old `if` intended, while uniqueness now runs on every push. # (cast's drill meaning: drills/README.md). Vacuous on -dev trees; it
- name: no shipped changelog heading was deleted or duplicated # requires a RECORD, not a pass — a maintainer waiver is legal, and
env: # is itself the content of the file.
CHANGELOG_MONOTONIC_STRICT: "1" - uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}" with:
version-source: package-json
# ...and a release carries its DRILL RECORD. CONTRIBUTING has always # docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
# asked for the real-hardware drill; nothing asserted it, so it was # pin read from release.yml (ceremony#19) — a hand edit or a
# performed exactly as often as a reviewer remembered to ask — which is # half-done pin bump goes red here.
# never, across every release in the family, until a bot blocked on it. - uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
# Here it is a fact about the tree instead of a fact about somebody's
# memory.
#
# No `if:` guard on the event or the label. The script keys off
# package.json itself: a `-dev` tree has no ship claim and passes
# trivially, a bare version is a release ceremony tree and must have a
# record. Gating this step on the `release` label instead would put the
# assert behind a hand-applied label — the guard would be absent from
# exactly the PR that mislabels itself, and unasserted PRs are how the
# drill went missing in the first place.
#
# It requires a RECORD, not a PASS: a maintainer waiver is legal, and is
# itself the content of drills/<version>.md. Skipping stays possible and
# stays visible.
- name: a release version has a drill record
run: bash .github/scripts/drill-recorded.sh
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at # The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
# this checkout, so CI proves the installer under review (the versioned # this checkout, so CI proves the installer under review (the versioned

View file

@ -1,78 +1,19 @@
name: labels name: labels
# The automation LABELS.md promises. Two halves: # The automation LABELS.md promises, now implemented upstream
# scope — path-derived scope:* labels on PRs (actions/labeler) # (heavy-duty/ceremony — ceremony#15 is this conversion): scope labeling and
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh) # the state reconciler live in the reusable workflow this caller pins. Cast
# # keeps the triggers and permissions (a called workflow cannot define them),
# pull_request_target, not pull_request: every PR here arrives from a fork, # its path map in .github/labeler.yml, and its panel + scope taxonomy in
# where pull_request (and pull_request_review) run with a READ-ONLY token and # .github/labels.conf.
# 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: on:
schedule: schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
- cron: "*/15 * * * *" workflow_dispatch: # bootstraps missing labels on a fresh repo
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
pull_request_target: pull_request_target:
types: types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
[
opened,
reopened,
ready_for_review,
converted_to_draft,
synchronize,
labeled,
unlabeled,
]
permissions: permissions:
contents: read contents: read
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
scope: labels:
# Not on labeled/unlabeled: those events change no paths, so labeler has uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
# 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

View file

@ -1,250 +1,24 @@
name: release name: release
# The release publisher (#96; box#83's design) — two ways in, one act (#111; # The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
# box#96's design): # README; ceremony#15 is this conversion). Both doors — merge and tag push —
# # live in the reusable workflow this caller pins; cast keeps only what a
# - Merging a `release`-labeled PR into main IS the release. The ceremony # called workflow cannot define (triggers and permissions) plus its one
# PR carries the bumped version and the stamped changelog; the # genuinely local piece: the prebuilt-asset build, now the artifact hook at
# maintainer's merge is the ship decision, and tagging after it is # .github/actions/release-artifact/, which both doors invoke.
# transcription — exactly where humans err silently and machines fail # Triggers and permissions MUST live here (a called workflow cannot define them):
# loudly. This path asserts four facts (each fail-loud, creating
# nothing), then tags the merge commit and publishes.
# - A bare X.Y.Z tag push (no 'v' prefix — box's and rig's tag scheme)
# stays as the documented manual fallback and backfill.
#
# Both paths converge on the SAME steps below — one notes extraction, one
# build, one asset name, one create — so they cannot drift.
#
# Where cast differs from its siblings: the release carries a PREBUILT
# asset. box and rig are pure bash, so GitHub's source tarball for the tag
# IS their package; cast's source tarball is not runnable — it needs npm ci
# and tsc first. So the build happens ONCE, here, and the asset is the
# runnable tree: bin/, dist/, production node_modules/, package.json.
on: on:
# ONE push key, both filters — YAML maps are last-key-wins, so a second # ONE push key, both filters — YAML maps are last-key-wins; a second sibling
# sibling `push:` would silently REPLACE the first and kill a door # `push:` silently replaces the first and kills a door (rig's review catch).
# (grok's round-2 catch: the tag fallback had stopped triggering).
push: push:
# Every tag, not a shape filter (box's and rig's precedent): a tag that tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# mismatches package.json — a habitual v0.1.0, a typo — must fail the # never be skipped by a shape filter that didn't match
# assert LOUDLY below, not be silently skipped by a pattern that didn't
# match.
tags: ["**"]
# The merge-is-the-release path (#111) rides pushes to MAIN, not
# pull_request events: a pull_request run from a public FORK gets a
# READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling —
# and every ceremony PR this org merges is cross-repo from the bot
# fork; the tag create would 403 after green asserts. A push to main
# is an in-repo event with the full write token, whoever authored the
# PR. The steps split on the pushed ref.
branches: [main] branches: [main]
permissions: permissions:
contents: write # tag create via the API + gh release create + the bump push contents: write # tag ref create + release create + the bump push
# Two consumers (labels.yml precedent — a declared permissions: block pull-requests: write # decide's label read; the bump-fallback `gh pr create`
# zeroes every unspecified scope): the decide step's label read issues: write # --label on that fallback PR rides the issues API
# (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
jobs: jobs:
release: release:
# Tag pushes and main pushes both enter (the asserts below are the uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
# filter); the steps split on the ref. The hand-set `release` label
# (LABELS.md: `release` is the operator's — automation never guesses
# intent) is read via the API off the merge commit's PR, inside the
# decide step — a push event carries no PR payload, and the PR itself
# lives on a fork (the trigger comment).
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: with:
# Either door: the pushed ref — a tag, or main's new head (the version-source: package-json
# merge commit the maintainer shipped, which the tag created
# below will name).
ref: ${{ github.sha }}
# Depth 2: the pushed head's first parent must be resolvable for
# the decide step's all-zeros fallback (event.before on a
# branch-creation push).
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: "tag push: the tag must name package.json's version"
if: startsWith(github.ref, 'refs/tags/')
run: |
ver="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
# 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
# trigger 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 — and cast's ENTIRE
# pre-0.1.1 era, since 0.1.0 never
# carried -dev): green NOTICE no-op
# bare, unchanged, UNreleased→ the label says ship but this PR did
# not mint the version: refuse to
# guess. This is also the known
# first-release edge (#111): the 0.1.0
# ceremony (#110) ships by manual tag,
# the fallback path; the automation
# applies from 0.1.1 on.
# bare, changed → the ceremony: proceed
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
if: github.ref == 'refs/heads/main'
env:
BASE_SHA: ${{ github.event.before }}
GH_TOKEN: ${{ github.token }}
run: |
# Versions read via node, never regex (the pkg_version discipline).
ver="$(node -p 'require("./package.json").version')"
# event.before is all-zeros on a branch-create push; the pushed
# head's first parent is main the instant before, either way.
case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac
git fetch --depth=1 origin "$BASE_SHA" || true
git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json"
base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").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" > /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
echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&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 (#111), not a bare push — creating nothing." >&2
exit 1
fi
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
- name: release notes — the version's own CHANGELOG.md section
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
# Assert 3 on the merge path, the same fact on the tag path:
# 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 "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: "merged release PR: nothing exists yet, then tag the merge commit"
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.sha }}
run: |
# Assert 4 — no tag and no release exist for this version. Re-runs
# of a completed ceremony REFUSE LOUDLY (red, creating nothing —
# the correct direction), and a manual race (an operator who
# tagged by hand between merge and here) fails the same way
# instead of double-publishing.
if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then
echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2
exit 1
fi
if gh release view "$RELEASE_VERSION" > /dev/null 2>&1; then
echo "release '$RELEASE_VERSION' already exists — creating nothing." >&2
exit 1
fi
# The act begins: tag the merge commit via the API. A tag created
# with GITHUB_TOKEN does not trigger other workflows, so the
# tag-push trigger above CANNOT fire on this tag and
# double-publish — which is also why the publish must happen in
# THIS job.
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA"
- name: build the prebuilt dist asset
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
# Build ONCE, in CI — the whole point of the asset (#96): the
# installer's release channels never run npm or tsc. Deliberately no
# check/tests here: ci.yml already gated the merge commit this
# release names, and the test suite needs `age`, which this runner
# does not install. The staged tree is exactly what an install needs
# to run.
run: |
npm ci
npm run build
npm prune --omit=dev
mkdir -p "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION"
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/"
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION"
- name: create the release
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$RELEASE_VERSION" --verify-tag \
--title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \
"$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz"
# The post-release bump, folded into the release act (#111 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 the
# merge path nor a red run; should branch protection ever refuse the
# direct push, the step opens the bump PR itself and says so, loudly.
# Merge-door only (the decide gate): the manual tag path stays a
# fallback and does not rewrite main.
- name: bump main to the next -dev — the release re-arms main itself
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
# next is computed from the RELEASE tree (the checkout), then
# applied to whatever main is by the time of the push — if main
# moved in the window, release+1 still lands on the newer head,
# which is the intended arithmetic either way.
next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')"
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
npm pkg set version="$next"
npm install --package-lock-only --ignore-scripts
git add package.json package-lock.json
git commit -m "chore: bump main to $next — a dev install must not impersonate $RELEASE_VERSION"
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." \
--label release
fi

7
AGENTS.md Normal file
View 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.

View file

@ -7,6 +7,12 @@ actually cutting it, and this file starts there.
## Unreleased ## Unreleased
### Changed
- The release flow and labels automation now run heavy-duty/ceremony's shared
machinery at 0.1.0; the prebuilt-asset build moves to the release-artifact
hook (heavy-duty/ceremony#15)
## 0.2.0 — 2026-07-21 ## 0.2.0 — 2026-07-21
### Added ### Added

View file

@ -1,59 +1,33 @@
# Contributing # Contributing
How change lands in this repo. The short version: PRs are born as drafts, This repo is governed by
three reviewer bots take the first rounds, a human takes the last word — and [heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
labels tell you where everything is without opening anything. read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to
your role file (builder, reviewer, triage), vendored beside it,
byte-identical to ceremony at the pin named in
[`.github/workflows/release.yml`](.github/workflows/release.yml) and
guarded by the `docs-sync` step in CI. The review-round doctrine — drafts,
whole-round replies, verdicts, the handoff — lives there and in
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is
genuinely cast's.
## The PR loop ## The PR loop, cast specifics
1. **Fork and branch.** Contributors work from forks; upstream branches are 1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`). for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
2. **Open as a draft** while you build. Drafts are invisible to the reviewer 2. **The review panel** (`.github/labels.conf`'s `panel=` line):
bots on purpose. `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`,
3. **When it's ready**: mark ready-for-review and request all three bots — `kimi-bot-andresmgsl`
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. the required verdicts for a PR are the panel minus its author. The
They poll roughly every 15 minutes. maintainer (`danmt`) takes the last word and merges.
4. **Rounds are answered whole.** Wait until all three have reviewed, then 3. **Checks must be green**: `npm run check`, `npm run build`, and
answer the entire round in a **single reply**, push the fixes, and `npm test` locally mirror what CI runs, plus the shellcheck sweep
re-request the bots that didn't approve. Prefer verification over (`npm run check:shell`). The release guards (`changelog-armed`,
argument: a test settles what a comment thread can't. `changelog-monotonic`, `drill-recorded`, `docs-sync`) run as ceremony's
5. **Reviews end in a verdict.** A reviewer — bot or human — either pinned actions.
**approves** or **requests changes**, never a bare comment. A 4. **Feature PRs land their changelog entry as part of the PR**: add it
comment-only review is a non-verdict: it doesn't say whether the round under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes
passed, and the state machine (and anyone scanning the board) has to the release notes verbatim when a release is cut.
guess. The verdict carries *blockingness only*, the body carries the
feedback: non-blocking nits ride an **approval** and the author addresses
them at their discretion; anything blocking — including a question that
gates the verdict — is **request changes**, saying what unblocks it. The
reconciler treats a comment-only review as not-approved, so commenting
without a verdict only stalls the PR. The machine never reads review
bodies: when a comment-only reviewer's line is really an agreement, that
judgment belongs to the **author** — escalate by requesting the
maintainer's review (step 6), and the reconciler flips the label on that
request, because an explicit request is a fact it can trust.
6. **When the round passes, the author hands the PR to the maintainer** in
three acts, in this order: post the tagged round summary, request the
maintainer's review, then set `state:needs-human` yourself — removing the
state label it replaces. The review request is what *earns* the label,
provided the PR carries **no `blocker:*` label**. A blocker means the work
is still yours whatever the round said, so on a conflicted or red PR
neither the request nor your own label write will stick — the sweep takes
it straight back off. With three formal head-current approvals the labels
workflow requests the maintainer automatically; when part of the panel is
comment-only, reading their agreement is the author's judgment, so the
author makes the request.
Writing the label by hand is an **optimistic write, not a transfer of
ownership**. The machine stays the authority — but because the workflow
wakes on `labeled`, the author's own write fires the sweep that validates
it, and a handoff that had not earned the label is corrected seconds later.
Forgetting the write is not a failure either; it only means the label waits
for the cron, which is the lag this replaced.
7. **Checks must be green**: `npm run check`, `npm run build`, and
`npm test` locally mirror what CI runs.
8. **Feature PRs land their changelog entry as part of the PR** (box's
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
that section becomes the release notes verbatim when a release is cut.
## Changelog entries ## Changelog entries
@ -89,144 +63,46 @@ Not an entry — that is a PR body:
## Releasing ## Releasing
A release is a PR, and merging it IS the release A release is a PR, and merging it is the release. The ceremony — the two
([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design, doors, the decide table, the stamps, the post-release re-arm — is
on box#83's shape): heavy-duty/ceremony's machinery, consumed by reference:
[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md)
is the doctrine, `.github/workflows/release.yml` here is the ≤20-line
caller pinning it (`version-source: package-json` — the version lives in
`package.json`, and the post-release bump keeps `package-lock.json` in
step), and the guards run in `ci.yml` from the same pin. Bare `X.Y.Z`
tags, no `v`.
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s What stays cast's beyond that pin:
`version` (and `package-lock.json`; `npm install --package-lock-only`
keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as
`## X.Y.Z — YYYY-MM-DD`. **Then re-arm: add a fresh, empty
`## Unreleased` immediately above the section you just stamped.** The
same PR, the same diff — stamping without re-arming leaves main with no
`## Unreleased`, and the next PR that was authored before the release
and merged after has its entry land *inside the shipped section*, which
git does cleanly, with no conflict to warn anyone
(heavy-duty/rig#66 — it happened there). `test/release.test.ts` keys
this to the version, and checks both halves of the stamp:
- while `package.json` is bare, the top section may be the stamp or the
re-armed `## Unreleased`, but a `## X.Y.Z` section for the version you
are shipping **must exist and extract non-empty** — a bump without a
stamp is red here rather than after the merge, in release.yml;
- the moment step 4's `-dev` bump lands, the top section must be
`## Unreleased` or CI is red.
The empty `## Unreleased` this step adds is deliberately tolerated: what - **The prebuilt asset**
must extract non-empty is the section that SHIPS, not the top one. CI [`.github/actions/release-artifact/`](.github/actions/release-artifact/action.yml),
green on it, same loop as any PR. the artifact hook both doors invoke: the build happens ONCE, in CI, and
2. **Drill, and record it.** Before the PR can be handed over, run the full `cast-X.Y.Z.tgz` is the runnable tree (`bin/`, `dist/`, production
real-hardware drill — two live Coolify instances, the whole A→B promotion: `node_modules/`, `package.json`). That asset is what the installer's
team, apply, an idempotent diff, smoke, inventory, emit-draft, fleet, release channels download — never a source tarball, never an
destroy, and the read-only guard — and record it in a file named for the operator-machine build.
version, one record per version: - **The drill** — the real-hardware gate before the handoff of a release
PR. Cast's drill asserts **promotion**: two live Coolify instances, the
drills/X.Y.Z.md full A→B run (team, apply, an idempotent diff, smoke, inventory,
emit-draft, fleet, destroy, the read-only guard) — A→B reproduces, and
The name matches `package.json`'s `version` exactly, and the file must hold the diff is idempotent. The full meaning — the fixed candidate-ref
at least one non-whitespace character. See pinning that dissolves the box↔rig recursion, the per-version record
[drills/README.md](drills/README.md) for what a record contains. files, the waiver rule — is [`drills/README.md`](drills/README.md); the
`drill-recorded` guard enforces the record on every release tree.
[.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh)
enforces this on every release PR (a `-dev` tree has no ship claim and
passes trivially). It is **not a thing a reviewer has to remember** — that
is precisely how every release in this family shipped without one until a
bot blocked on it.
So the release flow is: **draft → ready → bot round → drill →
`state:needs-human` → maintainer merge (which IS the release).**
**The three repos' drills are independent.** Run them in any order, on any
schedule, in separate sittings. They are not three phases of one script.
What makes that safe is that every drill **pins the same fixed set of
candidate refs**, so each one exercises exactly the combination that will
ship rather than whatever `main` happens to be that afternoon. The run
drills **candidate refs, not released artifacts**: `RIG_REPO` and `RIG_REF`
are mint-time environment variables (default `heavy-duty/rig@main`), so a
run pins the exact commits under test.
That pinning — **not sequencing** — is what dissolves the box↔rig
recursion. box and rig *are* mutually recursive: rig builds the host that
runs box, and box's seed calls rig back to converge the guest. But
candidate refs are static identifiers that exist as soon as the release
branches do, long before any drill runs, so a cycle at runtime becomes
independent tests against one fixed pair. No repo has to be released
before another can be drilled, and there is **no fixed order in which the
three releases must be published.**
Each repo also drills a **different thing**: box asserts the isolation
contract (the VM trust boundary), rig asserts convergence (a machine
reaches its role, idempotently), cast asserts promotion (A→B reproduces,
and the diff is idempotent). Three different exercises sharing a
substrate — which is exactly why the records are per-repo.
cast's legs are the **least coupled** of the three: two Coolify instances
can be stood up by hand, as the July drill did for instance B via a
parameterised compose file. Within a single drill you of course bring the
substrate up before probing it — a host before a guest before Coolify —
but that is how you run *a* drill, not an ordering rule *between repos*.
Drilling the candidate **is** drilling the release. A release PR's diff is
the version file and `CHANGELOG.md` — nothing executable differs between
the tree that was drilled and the tree that ships, so the evidence carries
across the ceremony commit.
Each repo records ITS OWN legs in its own `drills/X.Y.Z.md`, citing the
shared **run ID** that names the pinned set and the other two repos' commit
SHAs — which is what lets separate records be reassembled into one picture.
The guard still reads only this repo's files: cast never queries box's or
rig's drill records to decide whether cast may ship, because a cross-repo
lookup degrades to "pass" the moment it fails to resolve — the
unreadable-rollup bug wearing a different hat.
If a defect shows up only in the combination: patch, re-drill, re-record.
The three releases converge on a set that holds together; they are not
required to be right in one pass.
A maintainer **waiver** is possible — but it must be RECORDED in
`drills/X.Y.Z.md` for that version, saying who waived it and what is
untested. The guard requires a *record*, not a passing result, so skipping
the drill stays possible and stays visible and deliberate.
3. **Merge. That's the ship decision — nothing else to do.**
[release.yml](.github/workflows/release.yml) fires on the merged,
`release`-labeled PR and asserts, in order, each fail-loud and creating
nothing: the merged version is non-`-dev`; the version *changed in this
PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR
fails here); that version's changelog section extracts non-empty
([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh));
and no tag or release exists for it yet. Then, in the same job, it tags
the merge commit bare `X.Y.Z` (no `v` prefix — box's tag scheme), builds
the package once (`npm ci && npm run build && npm prune --omit=dev`), and
publishes the release with the runnable tree — `bin/`, `dist/`,
production `node_modules/`, `package.json` — attached as
`cast-X.Y.Z.tgz`. That asset is what the installer's release channels
download: the build happens once, in CI, never on an operator's machine.
*Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge
commit yourself — the same workflow runs the same asserts, build, and
publish from the tag.
4. **The release re-arms main itself**: the same workflow run bumps
`package.json` (and `package-lock.json`) to `X.Y.(Z+1)-dev` and pushes
the commit straight to main — no follow-up PR (it opens one only if
branch protection refuses the direct push, and says so loudly).
Installs are versioned by the tree's `package.json` version, so a
`CAST_REF=main` install between releases must land as
`versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must
not impersonate the release it merely descends from. On the *manual*
tag path the bump stays yours: open the one-line PR after publishing.
This step re-arms the **version** only — the `## Unreleased` heading is
step 1's, in the ceremony PR's own diff, because no workflow ever writes
`CHANGELOG.md`. The two halves meet in `test/release.test.ts`: once this
bump makes the version `-dev`, a missing `## Unreleased` is CI-red.
## Labels — who sets what ## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is The taxonomy and state machine are
who sets each kind — most of it is machinery, and hand-moving a [`.ceremony/LABELS.md`](.ceremony/LABELS.md); cast's `scope:*` rows live in
machine-owned label just gets corrected on the next pass: `.github/labels.conf` (reconciled by the labels caller) and their path map
in `.github/labeler.yml`. What matters day to day is who sets each kind —
most of it is machinery, and hand-moving a machine-owned label just gets
corrected on the next pass:
| Labels | Set by | | Labels | Set by |
|---|---| |---|---|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff (step 6) and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* | | `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. | | `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. |
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. | | `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. | | `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |

182
LABELS.md
View file

@ -1,182 +0,0 @@
# Labels
How this repo uses GitHub labels. The taxonomy is shared across the
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
because it names this repo's actual surfaces.
## State — who is the ball with? (PRs, exactly one)
Every open PR carries exactly one `state:` label, and it answers the only
question a board scan actually asks: *who is this PR waiting on?* The states
mirror the review loop this repo runs — PRs open as drafts, three reviewer
bots pick up ready PRs with reviews requested, each round is answered in a
single reply, and a human takes the final review.
| Label | Color | Waiting on | Enters when | Leaves when |
|---|---|---|---|---|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
| `state:addressing` | `#D93F0B` | the coding agent to reply, fix, or ask | all bots reviewed and not all approved; or nobody was asked; or a blocker is up | the round-reply is posted and fixes pushed — and any blocker named alongside is cleared |
| `state:needs-human` | `#8250DF` | the human reviewer | the PR **could be merged right now**: no blockers, three formal head-current approvals — and the human review is requested | merged — or changes requested, which cycles back to `state:addressing` |
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
first means *poke the bots*, staleness in the second means *the agent dropped
the ball*. Collapsing them loses exactly the information a sweep needs.
`bots-reviewing` therefore means strictly *a request is live and an answer is
coming* — a PR nobody was asked to review is the agent's ball, not the bots'.
## The second axis: `blocker:*`
State answers *whose ball is it*. Blockers answer *what is in the way*, and
unlike states they are *facts about the branch* — mutually independent, so a
PR carries as many as apply.
| Label | Color | Means | Clears when |
|---|---|---|---|
| `blocker:conflict` | `#B60205` | GitHub says `CONFLICTING` — the agent owes a **rebase** | it merges cleanly |
| `blocker:ci-red` | `#B60205` | a check failed — the agent owes a **fix**, which a rebase will not provide | checks are green |
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody — never reviewed, or staled by a push — and **nobody was asked** for one | reviews are requested |
| `blocker:drill-pending` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/<version>.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the record (or a recorded maintainer waiver) lands for that version |
`blocker:drill-pending` is the only blocker that says nothing is *wrong* with
the code. The version bump, the stamp, the re-arm can all be perfect; what is
missing is the evidence that anyone ran the real-hardware drill against the
tree about to ship. It clears on a RECORD, not on a pass — a recorded waiver
clears it too, which is the point: skipping the drill stays possible and stays
visible. `.github/scripts/drill-recorded.sh` is what turns CI red meanwhile.
**The label does not exist in the repo yet.** Creating a label needs push
access and the bot account gets a 403, so a maintainer account has to run the
`gh label create` line below (or the workflow's manual dispatch) once. Until
then the reconciler cannot apply it, and plain `blocked` stands in — coarser,
but it keeps an unevidenced release off the merge path, which is the job.
One rule joins the axes: **`state:needs-human` requires zero blockers.** Any
blocker means the work is the agent's, whatever the review round says.
This split exists because the single-label version kept lying. Independent
facts were projected onto one totally-ordered label, so one always had to win
and the losers vanished off the board: a PR that was *both* conflicted and red
could only say one of them, and `needs-rebase` told an agent to rebase when
what it actually owed was a bug fix. Precedence between two blockers is not a
question a set has to answer, which is why every ordering bug this machine has
had — `needs-human` surviving a conflict, `MISSING` swallowing `STALE` — lived
on the axis that had to be totally ordered.
`state:needs-rebase` was the first attempt at this and is **retired**; the
reconciler strips it on sight so no PR is left carrying a label nothing
recomputes.
**`state:needs-human` means one thing: a human could merge this right now.**
The label is the only signal a maintainer scanning the board (or a phone)
actually reads, and one that says "your turn" on an unmergeable PR is worse
than no label at all. So beyond the blockers, one review fact also outranks an
explicit human request:
- **nobody reviewed *this* head** — every approval staled by a push → `state:addressing`,
because the agent owes a re-request
That case is more dangerous than any blocker: a blocked PR at least shows an X
or a disabled merge button, while a staled-approval PR reads green, mergeable
and "waiting on the human" over code no reviewer has seen.
`UNKNOWN` mergeability is deliberately **not** treated as a conflict. GitHub
reports it for about a minute after every merge while it recomputes, and
flapping every open PR through `blocker:conflict` on each merge would be worse
than the bug this fixes. A failed read of either branch fact degrades to the
same "do not know" value, for the same reason.
An *unfinished* round still yields to an explicit human request — a maintainer
pulling a PR to themselves early is a deliberate act. `MISSING` (nobody has
reviewed yet) and `STALE` (everyone reviewed something else) are different
facts and are treated differently.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
| `merge-next` | `#0E8A16` | Head of the merge queue — **merge this one next**. Queue order is *intent* (which PR lands first, given how they conflict), so the reconciler never sets it: you or the agent maintaining the queue do. The reconciler only **clears** it, the moment the PR stops being something a human could merge — so it cannot go stale the way `state:needs-human` did. |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
| Label | Covers |
|---|---|
| `scope:capture` | `draft.ts`, `capture.ts` — reading the live world into a manifest |
| `scope:apply` | `apply.ts`, `diff.ts`, `destroy.ts` — reconciling the manifest onto Coolify |
| `scope:secrets` | `secrets.ts`, age handling, the encrypted state repo |
| `scope:fleet` | `fleet.ts`, `inventory.ts`, `server.ts` — placement and the server side |
| `scope:manifest` | `manifest.ts`, `resolve.ts`, `envtemplate.ts` — the manifest language itself |
| `scope:coolify-api` | `coolify.ts`, the OpenAPI reference — the client surface |
## Issue types
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
would just say the same thing twice, drifting apart eventually.
## Maintenance
State labels are machine-owned, with exactly one exception. Every state above
is derivable from GitHub's own facts — the draft flag, requested reviewers,
review states, push timestamps — so the labels workflow
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
state and reconciles labels statelessly, on PR events (label changes included)
plus a 15-minute cron. A hand-moved label is a lie waiting to happen; the
workflow asserts the effective state instead.
The exception is `state:needs-human`, which the author sets at handoff
([CONTRIBUTING.md](CONTRIBUTING.md), step 6). That is an optimistic write, not
a transfer of ownership: because `pull_request_target: labeled` wakes the
workflow, the author's own label write fires the sweep that validates it, and
a handoff that had not earned the label is corrected within seconds.
It exists because the wake signal was missing. There is no
`pull_request_review_target` — on fork PRs, which is all of them here,
`pull_request_review` runs read-only and cannot label anything — so the moment
the label becomes true, the third approval landing, fired nothing at all. What
was left was the `*/15` cron, and GitHub deprioritises short intervals hard
enough that the delivered rate is closer to hourly. The label could therefore
lag the round it described by hours, worst on the quietest repo: every sweep
reconciles the whole board, so a busy repo stays fresh by piggybacking on
unrelated PR events, while a quiet one depends on the cron most and receives
it least. `scope:` labels on PRs are applied from the changed
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
The same workflow bootstraps the taxonomy: a manual dispatch creates any
missing label idempotently. To create them by hand (needs push access):
```sh
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
gh label create "blocker:conflict" --color B60205 --description "Does not merge — the branch conflicts and the agent owes a rebase" --force
gh label create "blocker:ci-red" --color B60205 --description "A check is failing — the agent owes a fix (not a rebase)" --force
gh label create "blocker:unrequested" --color E99695 --description "Somebody still owes a verdict and nobody was asked for one" --force
# needs a MAINTAINER account — the bot 403s on label creation, and until this
# runs the reconciler cannot apply it and `blocked` stands in.
gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/<version>.md — ceremony correct, unevidenced" --force
# retired — the reconciler strips it; delete it once no PR carries it
# gh label delete "state:needs-rebase"
gh label create "state:needs-human" --color 8250DF --description "No blockers, all bots approve — waiting on the human reviewer" --force
gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
gh label create "scope:capture" --color C5DEF5 --description "draft/capture — reading the live world into a manifest" --force
gh label create "scope:apply" --color C5DEF5 --description "apply/diff/destroy — reconciling onto Coolify" --force
gh label create "scope:secrets" --color C5DEF5 --description "secrets, age, the encrypted state repo" --force
gh label create "scope:fleet" --color C5DEF5 --description "fleet/inventory/server — placement" --force
gh label create "scope:manifest" --color C5DEF5 --description "manifest/resolve/envtemplate — the manifest language" --force
gh label create "scope:coolify-api" --color C5DEF5 --description "coolify.ts + OpenAPI reference — the client" --force
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
# that, so this block converges on re-run instead of erroring after first success.
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
gh label delete "$L" --yes 2>/dev/null || true
done
```

View file

@ -5,8 +5,8 @@ Per-release evidence: **one file per version**, named `<version>.md`, where
`0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`. `0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`.
A release PR's version must have its file here, holding at least one A release PR's version must have its file here, holding at least one
non-whitespace character, before CI will let it merge non-whitespace character, before CI will let it merge (the
(`.github/scripts/drill-recorded.sh`, wired into ci.yml). `heavy-duty/ceremony/actions/drill-recorded` guard, pinned in ci.yml).
## One file per version, and why the parser went away ## One file per version, and why the parser went away

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "cast", "name": "cast",
"version": "0.2.0", "version": "0.2.1-dev",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cast", "name": "cast",
"version": "0.2.0", "version": "0.2.1-dev",
"dependencies": { "dependencies": {
"yaml": "^2.5.0", "yaml": "^2.5.0",
"zod": "^3.25.76" "zod": "^3.25.76"

View file

@ -1,6 +1,6 @@
{ {
"name": "cast", "name": "cast",
"version": "0.2.0", "version": "0.2.1-dev",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": { "engines": {

View file

@ -1,416 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
# human), a stale approval does not promote unreviewed code, and an explicit
# human request outranks everything.
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
cd "$(dirname "$0")/.."
# shellcheck source=.github/scripts/labels-reconcile.sh
. .github/scripts/labels-reconcile.sh
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
# machine's inputs, consumed inside the sourced decide_state — not unused.
# shellcheck disable=SC2034
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
pass=0 fail=0
expect() { # $1 = description, $2 = want, $3 = got
if [ "$2" = "$3" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
fi
}
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
}
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
# -- fresh ready PR with bots requested ---------------------------------------
DRAFT=false REQUESTED="$BOT1
$BOT2
$BOT3" REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a bot that never reviewed keeps the round open ---------------------------
# With a live request that is the bots' ball; with NO request outstanding it
# is the agent's, because nothing is coming until somebody asks.
REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)")"
expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)"
REQUESTED=""
expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)"
expect "...and the blocker names the stall" blocker:unrequested "$(blockers)"
# -- a comment is a non-verdict, agreement body or not: the author escalates --
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
# ...and the author's escalation — requesting the human — flips it
REQUESTED="$HUMAN"
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
REQUESTED=""
# -- three formal approvals need no author judgment ---------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
# -- changes requested blocks, at any head ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
# -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED old1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
# -- a re-requested bot reopens the round even with an old approval on file ---
REQUESTED="$BOT1"
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
REQUESTED=""
# -- only the LATEST review per bot counts ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
"$(rev "$BOT1" APPROVED head1 "" t2)" \
"$(rev "$BOT2" APPROVED head1 "" t3)" \
"$(rev "$BOT3" APPROVED head1 "" t4)")"
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
# -- an explicit human request outranks the bot rounds ------------------------
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
expect "human requested outranks bots" state:needs-human "$(decide_state)"
REQUESTED=""
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
# ...and re-requesting the human hands it back to them
REQUESTED="$HUMAN"
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
REQUESTED=""
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a stale human APPROVAL likewise needs a re-request for the new head
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a HEAD-CURRENT human approval needs nothing more
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
# ...and a live request suppresses re-requesting
REQUESTED="$HUMAN"
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
REQUESTED=""
# ---------------------------------------------------------------------------
# #136: state:needs-human must mean "a human could merge this RIGHT NOW".
# Both cases below were observed live in this repo on 2026-07-20, and both
# showed state:needs-human while being unmergeable in different ways.
# ---------------------------------------------------------------------------
ALL_APPROVE="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
# -- flavour 1: not mergeable. The merge button is disabled, yet the board
# said "your turn" on #119/#120/#127 for hours. The branch fact now rides
# the blocker axis; the state says whose ball it is, which is the agent's.
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=CONFLICTING CHECKS=SUCCESS
expect "a CONFLICTING PR is the agent's, not the human's" state:addressing "$(decide_state)"
expect "...and says WHY on the blocker axis" blocker:conflict "$(blockers)"
REQUESTED="$HUMAN"
expect "...even with the human explicitly requested" state:addressing "$(decide_state)"
# -- red CI is the same claim, but NOT the same work: a rebase does not fix a
# failing test. Collapsing both into one needs-rebase label told the agent
# to do the wrong thing, which is why the axis split exists.
REQUESTED="" MERGEABLE=MERGEABLE CHECKS=FAILURE
expect "a red PR is the agent's" state:addressing "$(decide_state)"
expect "...and is distinguishable from a conflict" blocker:ci-red "$(blockers)"
REQUESTED="$HUMAN"
expect "...and a human request does not override red CI" state:addressing "$(decide_state)"
# -- both at once. The single-axis design could not say this at all: one label
# had to win, and the loser silently vanished off the board.
REQUESTED="" MERGEABLE=CONFLICTING CHECKS=FAILURE
expect "a conflicted AND red PR reports both blockers" "blocker:conflict
blocker:ci-red" "$(blockers)"
expect "...and is still just the agent's ball" state:addressing "$(decide_state)"
# -- UNKNOWN is NOT unmergeable. GitHub reports it for ~a minute after every
# merge while it recomputes; treating it as broken would flap every open PR
# on each merge — worse than the bug being fixed.
REQUESTED="" MERGEABLE=UNKNOWN CHECKS=PENDING
expect "UNKNOWN mergeability blocks nothing" state:needs-human "$(decide_state)"
expect "...and raises no blocker" "" "$(blockers)"
# -- blocker:unrequested — the stalled round. Nobody owes an answer because
# nobody was ever asked, yet the board read "waiting on the bots" until
# `stale` noticed 48h later.
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)"
# ...the partial case is equally stalled: one verdict in, nobody asked for the rest
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)"
# ...a STALE round with nobody asked is the same debt, and arguably worse: the
# page carries approvals that no longer describe the tree. Guarding on
# MISSING alone let this one through with no blocker at all.
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)"
expect "...and is still the agent's ball" state:addressing "$(decide_state)"
# ...but a live request means an answer IS coming
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
REQUESTED="$BOT2"
expect "a live bot request is not a stalled round" "" "$(blockers)"
# ...and a draft is exempt: the bots ignore drafts by design
DRAFT=true REQUESTED="" REVIEWS_JSON='[]'
expect "a draft with nobody asked is not stalled" "" "$(blockers)"
# ...as is an explicit human request — claiming a PR early is deliberate
DRAFT=false REQUESTED="$HUMAN"
expect "an early human claim is not a stalled round" "" "$(blockers)"
REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS
# -- flavour 2 (the dangerous one): mergeable, green, human requested, and
# NOBODY has reviewed this head. Observed on #119 after a rebase: every
# signal read "merge me" and nothing on the page contradicted it.
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN"
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing "$(decide_state)"
# -- ...and a round that is BOTH unfinished and staled is still the agent's.
# Deciding inside the bot loop made this depend on BOTS order: the MISSING
# returned before any later bot's STALE was read, so the mixed round came
# out needs-human with nothing bound to the head. Pinned at both ends of
# the array, because the whole failure was one of ordering.
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN"
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
"$(rev "$BOT2" APPROVED oldhead "" t2)")"
expect "stale approvals + a bot yet to review is addressing, not needs-human" \
state:addressing "$(decide_state)"
REVIEWS_JSON="$(reviews "$(rev "$BOT3" APPROVED oldhead "" t3)")"
expect "...and the same when the stale verdict is the LAST bot in BOTS" \
state:addressing "$(decide_state)"
# -- but an UNFINISHED round still yields to an explicit human request: a
# maintainer pulling a PR to themselves early is deliberate, and was the
# original precedence. MISSING differs from STALE — nobody has reviewed
# YET, versus everyone reviewed something else.
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
expect "an unfinished round still yields to an explicit human request" state:needs-human "$(decide_state)"
REQUESTED=""
expect "...and without that request the agent owes the ask" state:addressing "$(decide_state)"
# ---------------------------------------------------------------------------
# checks_state: the rollup classifier. It lived inline in main() for the first
# round of this PR, which is why nothing here caught it calling ERROR,
# CANCELLED and STALE green. Extracted so the enum can be pinned down.
# ---------------------------------------------------------------------------
rollup() { jq -n --argjson c "$1" '{statusCheckRollup: $c}'; }
run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, completedAt:$t}'; }
ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
'{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; }
expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)"
# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty
# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" —
# the same unknown-certified-as-green shape as #136, in the one place that
# fix did not look. The caller skips an UNREADABLE PR rather than relabelling.
expect "a failed read is UNREADABLE, not NONE" UNREADABLE "$(echo '{}' | checks_state)"
expect "...and a real empty rollup is still NONE" NONE \
"$(echo '{"mergeable":"MERGEABLE","statusCheckRollup":[]}' | checks_state)"
expect "all green is SUCCESS" SUCCESS \
"$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS)]" | checks_state)"
expect "a queued run is PENDING" PENDING \
"$(rollup "[$(run_ a SUCCESS),$(run_ b QUEUED)]" | checks_state)"
expect "a plain failure is FAILURE" FAILURE \
"$(rollup "[$(run_ a SUCCESS),$(run_ b FAILURE)]" | checks_state)"
# -- the round-1 gap: outcomes that are neither success nor pending, and that
# leave a required check unsatisfied. All three reached the old `else`.
expect "a commit status ERROR blocks" FAILURE \
"$(rollup "[$(run_ a SUCCESS),$(ctx_ lint ERROR)]" | checks_state)"
expect "a CANCELLED run blocks" FAILURE \
"$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)"
expect "a STALE run blocks" FAILURE \
"$(rollup "[$(run_ a SUCCESS),$(run_ b STALE)]" | checks_state)"
expect "an outcome the enum does not know blocks, it does not pass" FAILURE \
"$(rollup "[$(run_ a SUCCESS),$(run_ b SOME_FUTURE_STATE)]" | checks_state)"
# -- NEUTRAL and SKIPPED satisfy branch protection; path-filtered jobs skip
# constantly, and calling that red would park every PR on the agent.
expect "NEUTRAL and SKIPPED are not failures" SUCCESS \
"$(rollup "[$(run_ a SUCCESS),$(run_ b NEUTRAL),$(run_ c SKIPPED)]" | checks_state)"
# -- latest-wins. The rollup keeps superseded runs, so this PR's own tip
# carried a CANCELLED `scope` beside the SUCCESS `scope` that replaced it.
# Without collapsing, making CANCELLED block would strand it forever.
expect "a re-run supersedes the cancelled original" SUCCESS \
"$(rollup "[$(run_ scope CANCELLED 2026-07-20T15:19:39Z),\
$(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
expect "...and the reverse order is not a re-run passing, it is one failing" FAILURE \
"$(rollup "[$(run_ scope SUCCESS 2026-07-20T15:19:39Z),\
$(run_ scope CANCELLED 2026-07-20T15:19:45Z)]" | checks_state)"
# same job name in a different workflow is a different context, not a re-run
expect "same name in another workflow does not supersede" FAILURE \
"$(rollup "[$(jq -n '{__typename:"CheckRun",workflowName:"labels",name:"scope",conclusion:"FAILURE",completedAt:"2026-07-20T15:00:00Z"}'),\
$(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a
# real completedAt, which is exactly why the supersede rule shipped dating
# runs by completion and nothing caught it. Both spellings of "no
# completion" are pinned, because `gh` emits the zero sentinel (a string,
# which `//` does not fall through) while the API emits null.
inflight_() { jq -n --arg n "$1" --arg t "$2" --arg c "${3:-0001-01-01T00:00:00Z}" \
'{__typename:"CheckRun", workflowName:"ci", name:$n, status:"IN_PROGRESS",
conclusion:"", startedAt:$t, completedAt:(if $c == "null" then null else $c end)}'; }
expect "a re-run in flight beats the success it superseded (zero sentinel)" PENDING \
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
$(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)"
expect "...and the same when the absent completion is null" PENDING \
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
$(inflight_ build 2026-07-20T15:10:00Z null)]" | checks_state)"
expect "a replacement in flight for a CANCELLED run is pending, not failed" PENDING \
"$(rollup "[$(run_ build CANCELLED 2026-07-20T15:00:00Z),\
$(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)"
# an entry carrying no usable timestamp is treated as newest, not oldest —
# ambiguity resolves toward "not settled" rather than toward a stale success.
# Guarded by the sort tiebreak rather than the dating expression: reverting
# only `at:` leaves this passing, so the two changes are separately pinned.
expect "an undateable in-flight run is not discarded for a stale success" PENDING \
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
$(jq -n '{__typename:"CheckRun",workflowName:"ci",name:"build",conclusion:"",startedAt:null,completedAt:null}')]" \
| checks_state)"
# ...and the reverse direction, which stops "in flight sorts last" being
# widened into "in flight always wins": a run that FINISHED after an earlier
# in-flight entry is the newer word, and the context is settled.
expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \
"$(rollup "[$(inflight_ build 2026-07-20T15:19:00Z),\
$(run_ build SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
# -- the wind-down window. A predecessor cancelled by the concurrency group
# does not stop the instant its replacement starts, so its completion
# routinely lands AFTER the successor's start — on box's aa5a6ba the
# replacement started 15:19:38 and the run it cancelled finished 15:19:51.
# Dating by "newest stamp of any kind" compares the dead run's completion
# against the live run's start, which is not an ordering on runs, and the
# predecessor wins. Every fixture above spaces completion before start, so
# none of them can see it. run_() cannot express the overlap either — it
# carries no startedAt — hence the explicit payloads.
overlap_() { jq -n --arg n "$1" --arg o "$2" --arg s "$3" --arg c "$4" \
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o,
startedAt:$s, completedAt:$c}'; }
expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \
"$(rollup "[$(overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\
$(inflight_ scope 2026-07-20T15:19:38Z)]" | checks_state)"
expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \
"$(rollup "[$(overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\
$(inflight_ build 2026-07-20T15:19:38Z)]" | checks_state)"
# -- the classifier feeds the state machine: a cancelled required check must
# take the PR off the human's plate, which is the whole point of #136.
DRAFT=false HEAD_SHA=head1 REQUESTED="$HUMAN" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE
CHECKS="$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)"
expect "a cancelled check reaches decide_state as the agent's ball" state:addressing "$(decide_state)"
expect "...via blocker:ci-red, not a conflict" blocker:ci-red "$(blockers)"
# -- the happy path survives all of the above.
REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED=""
expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)"
# -- and a draft outranks everything, including a conflict.
DRAFT=true MERGEABLE=CONFLICTING
expect "a draft is building even when conflicted" state:building "$(decide_state)"
DRAFT=false MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
# ---------------------------------------------------------------------------
# reconcile_pr's cold-start path. Everything above tests pure functions, which
# is exactly why a per-PR `return` in the label pre-flight got through review:
# the fixtures could not reach it. A missing state:* label must skip the label
# EDIT only — merge-next clearing and the stale sweep are independent of the
# taxonomy, and stranding them reintroduced the false-invitation bug (a
# `merge-next` claim surviving on a PR the board had moved to the agent).
# ---------------------------------------------------------------------------
reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr emits
(
REPO_LABELS="$1" REPO=owner/repo NOW="$(date +%s)"
LABELS="merge-next" # the PR carries a queue claim
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations
gh() { :; } # no network
reconcile_pr 777 2>&1
)
}
cold="$(reconcile_probe "merge-next")" # state:* labels absent entirely
expect "a cold-start repo still clears merge-next" \
yes "$(grep -q 'cleared merge-next' <<<"$cold" && echo yes || echo no)"
expect "...and still runs the stale sweep" \
yes "$(grep -q 'stale (' <<<"$cold" && echo yes || echo no)"
expect "...while warning that the state label is missing" \
yes "$(grep -q "state label 'state:addressing' does not exist" <<<"$cold" && echo yes || echo no)"
warm="$(reconcile_probe "$(printf 'state:addressing\nmerge-next\nstale\nblocker:unrequested')")"
expect "a bootstrapped repo converges the state as well" \
yes "$(grep -q 'state -> state:addressing' <<<"$warm" && echo yes || echo no)"
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

File diff suppressed because it is too large Load diff