Drill records move from sections inside drill/RUNS.md to one file per version: drills/<version>.md. The old guard parsed headings — 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 match box, and a non-blank body rule. All of that existed only because records shared one file, and this repo shipped two defects out of the complexity in review: the sed '/./,$!d' whitespace bypass, and heading-grammar drift from box's stricter form. One file per version makes nearly all of it unrepresentable — 0.2.0.md and 0.2.0-rc1.md are simply different files, so the whole-version rule is the filesystem's rather than a comparison anyone can get wrong. One rule survives: a file of only whitespace is not a record. Plain drills/, not .drills/ — dot-directories are invisible to globs without dotglob, the cause of #118/#121 here and box#116. drill/RUNS.md is deleted. It was created in this same unmerged PR and held only format documentation, no real records; the useful reasoning moves to drills/README.md. (box keeps ITS drill/RUNS.md, a genuine harness log with real run history.) The docs also drop an over-constrained ordering claim: the three repos' drills are INDEPENDENT, run in any order and any sitting. What makes that safe is that each pins the same fixed set of candidate refs — and that pinning, not sequencing, is what dissolves the box/rig recursion, since refs are static identifiers that exist as soon as the release branches do. Each repo also drills a different thing: box the isolation contract, rig convergence, cast promotion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Contributing
How change lands in this repo. The short version: PRs are born as drafts, three reviewer bots take the first rounds, a human takes the last word — and labels tell you where everything is without opening anything.
The PR loop
-
Fork and branch. Contributors work from forks; upstream branches are for maintainers. Title the PR conventionally (
feat:,fix:,docs:). -
Open as a draft while you build. Drafts are invisible to the reviewer bots on purpose.
-
When it's ready: mark ready-for-review and request all three bots —
claude-bot-andresmgsl,codex-bot-andresmgsl,grok-bot-andresmgsl. They poll roughly every 15 minutes. -
Rounds are answered whole. Wait until all three have reviewed, then answer the entire round in a single reply, push the fixes, and re-request the bots that didn't approve. Prefer verification over argument: a test settles what a comment thread can't.
-
Reviews end in a verdict. A reviewer — bot or human — either approves or requests changes, never a bare comment. A comment-only review is a non-verdict: it doesn't say whether the round passed, and the state machine (and anyone scanning the board) has to 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.
-
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-humanyourself — removing the state label it replaces. The review request is what earns the label, provided the PR carries noblocker:*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. -
Checks must be green:
npm run check,npm run build, andnpm testlocally mirror what CI runs. -
Feature PRs land their changelog entry as part of the PR (box's convention): add it under
CHANGELOG.md's## Unreleasedheading — that section becomes the release notes verbatim when a release is cut.
Changelog entries
Every PR that changes behaviour adds one line to ## Unreleased. One line is
the whole rule — if it wraps more than twice in your editor, cut it down.
- Say what changed, and stop. Why it was wrong, how it was found, what it cost, what it implies — that belongs in the PR body and the commit message, which is where anyone chasing the reasoning already goes. This file answers one question: what is different in this version.
- Any word that can be removed, is removed.
- Lead with the surface, not the mechanism. "
state:needs-humanis set at handoff" beats "the labels workflow now also wakes onlabeled". - Cite the issue or PR —
(#131)— and let the reader follow it for the rest. - Mark a breaking change with a leading
BREAKING:. - Group under
### Added/### Changed/### Fixed/### Removed. - No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
Good:
state:needs-humanis set at handoff, not by the cron (#131)- An unreadable check rollup no longer reads as "nothing is failing" (#128)
- BREAKING:
scripts/register-github-app.shis gone; usecast github-app(#7)
Not an entry — that is a PR body:
state:needs-humanno longer waits on the cron to become true (#131) — the labels workflow now also wakes onpull_request_target: labeledandunlabeled, and the author sets it themselves when handing a PR over. A review landing was never a trigger. There is nopull_request_review_target, and on fork PRs — which is all of them here — ...
Releasing
A release is a PR, and merging it IS the release (#111; box#96's design, on box#83's shape):
-
A small PR —
release: X.Y.Z, labeledrelease— bumpspackage.json'sversion(andpackage-lock.json;npm install --package-lock-onlykeeps them in step) and stampsCHANGELOG.md's Unreleased section as## X.Y.Z — YYYY-MM-DD. Then re-arm: add a fresh, empty## Unreleasedimmediately 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.tskeys this to the version, and checks both halves of the stamp:- while
package.jsonis bare, the top section may be the stamp or the re-armed## Unreleased, but a## X.Y.Zsection 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
-devbump lands, the top section must be## Unreleasedor CI is red.
The empty
## Unreleasedthis step adds is deliberately tolerated: what must extract non-empty is the section that SHIPS, not the top one. CI green on it, same loop as any PR. - while
-
Drill, and record it. Before the PR can be handed over, run the full real-hardware drill — two live Coolify instances, the whole A→B promotion: team, apply, an idempotent diff, smoke, inventory, emit-draft, fleet, destroy, and the read-only guard — and record it in a file named for the version, one record per version:
drills/X.Y.Z.mdThe name matches
package.json'sversionexactly, and the file must hold at least one non-whitespace character. See drills/README.md for what a record contains..github/scripts/drill-recorded.sh enforces this on every release PR (a
-devtree 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
mainhappens to be that afternoon. The run drills candidate refs, not released artifacts:RIG_REPOandRIG_REFare mint-time environment variables (defaultheavy-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.mdfor 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. -
Merge. That's the ship decision — nothing else to do. 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-devtransition is the interlock — a mislabeled ordinary PR fails here); that version's changelog section extracts non-empty (.github/scripts/release-notes.sh); and no tag or release exists for it yet. Then, in the same job, it tags the merge commit bareX.Y.Z(novprefix — 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/, productionnode_modules/,package.json— attached ascast-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 bareX.Y.Ztag on the merge commit yourself — the same workflow runs the same asserts, build, and publish from the tag. -
The release re-arms main itself: the same workflow run bumps
package.json(andpackage-lock.json) toX.Y.(Z+1)-devand 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'spackage.jsonversion, so aCAST_REF=maininstall between releases must land asversions/X.Y.(Z+1)-dev, never asversions/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## Unreleasedheading is step 1's, in the ceremony PR's own diff, because no workflow ever writesCHANGELOG.md. The two halves meet intest/release.test.ts: once this bump makes the version-dev, a missing## Unreleasedis CI-red.
Labels — who sets what
The full taxonomy lives in LABELS.md. 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 |
|---|---|
state:* |
the labels workflow (.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. |
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. |
scope:* on PRs |
actions/labeler, from the changed paths (.github/labeler.yml). Additive — you may add more, the machine won't remove them. |
scope:* on issues |
you, when opening or triaging — issues have no paths to derive from. |
blocked, release |
you — automation never guesses intent. |
merge-next |
you or the agent owning the queue. Which PR lands first is a judgement about how they conflict, so the workflow never sets it — it only clears it, the moment the PR stops being something a human could merge. |
bug / enhancement / documentation |
you, on issues only — a PR's type already lives in its title. |
Issues
Give issues the same care as PR titles: say the surface in the title, apply a
scope: label and a type label (bug / enhancement / documentation) when
you open one, and blocked when it waits on something — that is what keeps
the board navigable as the issue count grows.