feat: CI refuses a release PR with no drill record #138

Merged
dan-claude-bot merged 2 commits from feat/drill-gate into main 2026-07-21 16:17:22 +00:00
7 changed files with 553 additions and 3 deletions

150
.github/scripts/drill-recorded.sh vendored Executable file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
# drill-recorded.sh [<runs-file>] [<version-file>] — assert that the version
# this tree claims to ship has a DRILL RECORD in drill/RUNS.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.
#
# 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 section 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 drill/RUNS.md. 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 drill is ONE orchestrated run over the
# whole stack (CONTRIBUTING.md, "Releasing"): rig bootstraps the host and
# installs box, box mints a seed, the seed calls rig back to converge, and
# cast's legs run on the result. rig therefore sits BELOW box and ABOVE it —
# the three repos are mutually recursive, not linearly ordered, and their
# releases are NOT published in a fixed sequence. The run pins CANDIDATE refs
# (RIG_REPO/RIG_REF at mint time), so no repo must ship before another can be
# drilled. Each repo records its own legs from that run, citing the shared run
# ID and the other repos' SHAs — which is how three records reassemble into one
# run 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.
runs="${1:-drill/RUNS.md}"
version_file="${2:-package.json}"
[ "$#" -le 2 ] || { echo "usage: drill-recorded.sh [<runs-file>] [<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.
[ -f "$runs" ] || {
{
echo "drill-recorded: version $ver is a release, but there is no $runs at all."
echo
echo " The release drill has to be RECORDED, not just performed. Create the file"
echo " with the run's section (see the format below) and commit it."
} >&2
exit 1
}
# $5 of a drill heading ('## Release drill — 0.2.0 — 2026-07-21') is the bare
# version, compared WHOLE — so 0.2.0 can never be satisfied by a 0.2.0-rc1
# section, or the reverse, and no regex-escaping of dots. Exactly the trap
# release-notes.sh solves the same way, with the same awk field split, so the
# two cannot drift apart about what a heading is. The trailing ' — DATE' is
# optional and unread: the gate is about the version, and the date is for the
# humans reading the log.
#
# grab is re-armed by every '## ' line, so the section ends at the next one —
# a record cannot borrow the body of the record below it.
#
# `grab && NF` is the non-blank rule, and it is load-bearing rather than
# tidiness. NF is 0 on a line that is empty OR contains only whitespace, so
# `record` is non-empty exactly when a line with real content exists. The
# first cut of this piped through `sed '/./,$!d'` and the comment here claimed
# "a heading with nothing but whitespace under it extracts to the empty
# string" — which is precisely what that pipeline did NOT guarantee, because
# `.` matches a space. A heading followed by one tab satisfied the gate. The
# comment documented the intended contract and the code did not meet it, which
# on a gate is the whole ballgame: an evidence-free release for the price of an
# invisible character. Found by all three reviewers on #138, independently.
#
# The '(NF == 5 || $6 == dash)' tail constraint keeps this in step with box's
# twin: without it '## Release drill — 0.2.0 stray words' counts as a record
# here and does not there. Two sibling guards disagreeing about what the same
# heading means is the same trap as disagreeing with release-notes.sh.
record="$(awk -v ver="$ver" -v dash="—" '
/^## / {
grab = ($2 == "Release" && $3 == "drill" && $4 == dash && $5 == ver \
&& (NF == 5 || $6 == dash))
next
}
grab && NF { print }
' "$runs")"
if [ -z "$record" ]; then
{
echo "drill-recorded: $runs has no drill record for version '$ver'."
echo
echo " A release PR's version must have a NON-EMPTY section headed:"
echo
echo " ## Release drill — $ver — YYYY-MM-DD"
echo
echo " (An empty section under a correct heading counts as no record. The"
echo " heading matches the version WHOLE — a '$ver-rc1' section does not"
echo " satisfy '$ver', and vice versa.)"
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 $runs,"
echo " 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: $runs carries a drill record for $ver ($(printf '%s\n' "$record" | grep -c .) line(s))"

View file

@ -69,6 +69,27 @@ jobs:
CHANGELOG_MONOTONIC_STRICT: "1"
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
# ...and a release carries its DRILL RECORD. CONTRIBUTING has always
# asked for the real-hardware drill; nothing asserted it, so it was
# performed exactly as often as a reviewer remembered to ask — which is
# never, across every release in the family, until a bot blocked on it.
# 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 a section in drill/RUNS.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
# this checkout, so CI proves the installer under review (the versioned
# layout, the current symlink, the PATH chain, the uninstall's absence

View file

@ -9,6 +9,7 @@ actually cutting it, and this file starts there.
### Added
- CI refuses a release PR with no drill record in `drill/RUNS.md`
- An application can declare HTTP basic auth, and `apply` sets it (#76)
- `cast github-app create` / `cast github-app register` run the App Manifest
flow (#7)

View file

@ -108,13 +108,72 @@ on box#83's shape):
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 3's `-dev` bump lands, the top section must be
- 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
must extract non-empty is the section that SHIPS, not the top one. CI
green on it, same loop as any PR.
2. **Merge. That's the ship decision — nothing else to do.**
2. **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
[drill/RUNS.md](drill/RUNS.md) under a heading naming the version:
## Release drill — X.Y.Z — YYYY-MM-DD
[.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 drill is ONE orchestrated run over the whole stack**, not three
independent exercises. In order:
1. `rig bootstrap … --host yes` on a bare Debian host — this installs box
and runs box's `setup-host`;
2. `box new` mints a creds-free seed;
3. the seed converges on first boot: its cloud-init curls rig's installer
and runs `rig bootstrap <tenant>-box`;
4. cast's legs on top of the converged result — two live Coolify
instances and the full A→B promotion.
Note that rig appears **twice**, below box and above it. box and rig are
mutually recursive, not linearly ordered: rig builds the host that runs
box, and box's seed calls rig back to converge the guest. cast sits on top
of whatever that produces. So there is no "previous repo" for a repo to
drill against, and **no fixed order in which the three releases must be
published.**
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
dissolves the chicken-and-egg the mutual recursion would otherwise create:
no repo has to be released before another can be drilled.
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.
One run emits one shared **run ID**. Each repo records ITS OWN legs under
its own `## Release drill — X.Y.Z — DATE`, citing that run ID and the
other two repos' commit SHAs — which is what lets three separate records
be reassembled into the single run they came from. The guard still reads
only this repo's file: cast never queries box's or rig's drill log 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.
A maintainer **waiver** is possible — but it must be RECORDED in
`drill/RUNS.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
@ -131,7 +190,7 @@ on box#83's shape):
*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.
3. **The release re-arms main itself**: the same workflow run bumps
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).

View file

@ -36,6 +36,20 @@ PR carries as many as apply.
| `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** in [drill/RUNS.md](drill/RUNS.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.
@ -144,6 +158,9 @@ gh label create "state:addressing" --color D93F0B --description "All bots re
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 in drill/RUNS.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

94
drill/RUNS.md Normal file
View file

@ -0,0 +1,94 @@
# Drill runs
The log of cast's **real-hardware drill legs** — one section per run, appended.
A release PR's version must have a section here before CI will let it merge
(`.github/scripts/drill-recorded.sh`, wired into ci.yml).
**This file is the record, not the instrument.** cast has **no drill harness
script of its own yet**. Its legs are run by hand, against the documented
procedure: two live Coolify instances and the full A→B promotion —
team → apply → diff (idempotent) → smoke → inventory → emit-draft →
fleet → destroy → read-only guard
A harness would make the run reproducible; it would not make it recorded. Those
are separate problems, and this file is the second one. When cast grows a
harness, its output gets pasted into a section here in the same format — the
gate does not change.
## Per-repo, by construction
cast records **cast's own** legs. It does not read box's or rig's drill log to
decide whether cast may ship: a cross-repo lookup silently degrades to "pass"
the moment it fails to resolve — the unreadable-rollup class of bug, where a
guard that cannot read its input reports the happy answer.
The legs above are the **top of one orchestrated run over the whole stack**
(CONTRIBUTING.md, *Releasing*): `rig bootstrap --host yes` on bare Debian
installs box and runs `setup-host`; `box new` mints a seed; the seed converges
by calling `rig bootstrap <tenant>-box`; cast's legs run on the result. rig
appears twice — below box and above it — so the three repos are mutually
recursive, not linearly ordered, and their releases are **not** published in a
fixed order.
The run drills candidate refs rather than released artifacts (`RIG_REPO` /
`RIG_REF` are mint-time variables, default `heavy-duty/rig@main`), which is
what makes that possible: no repo has to ship before another can be drilled.
A record therefore cites the shared **run ID** and the other two repos' commit
SHAs — three records, one run, reassemblable — while each repo's evidence
still lives in its own tree.
## Format
The gate looks for a heading of exactly this shape, and requires the section
under it to be non-empty:
## Release drill — X.Y.Z — YYYY-MM-DD
The version is matched **whole**: a `0.2.0` release is not satisfied by a
`0.2.0-rc1` section, or the other way round.
A record says three things: **what ran**, **the numbers**, and **what failed**.
"Nothing failed" is a finding and is worth a line; an empty section is not a
record and the gate refuses it.
### Example of the shape
Illustrative only — no run has happened yet, and the placeholder version keeps
this block from ever satisfying the gate for a real release.
## Release drill — X.Y.Z — YYYY-MM-DD
Run ID: `drill-YYYYMMDD-NN` (the stack run these legs are the top of).
Stack under test: box `<sha>`, rig `<sha>`, cast `<sha>` — candidate
refs, pinned at mint time via RIG_REPO/RIG_REF.
Instances: A `coolify-a.example` (v4.x), B `coolify-b.example` (v4.x).
Manifest: `examples/two-env.yaml`, 3 applications, 2 environments.
Operator: @maintainer. Elapsed: 41m.
| Leg | Result | Notes |
|---|---|---|
| team | pass | 2 teams, 4 members reconciled |
| apply (A→B) | pass | 3 apps created, 11 env vars set |
| diff (idempotent) | pass | second apply: 0 changes |
| smoke | pass | 3/3 endpoints 200 |
| inventory | pass | 3 apps, 2 envs, matches manifest |
| emit-draft | pass | draft matches inventory round-trip |
| fleet | pass | both instances listed, versions read |
| destroy | pass | 3 apps removed, absence asserted |
| read-only guard | pass | write refused against B with the guard on |
Failures: none. One rough edge: `smoke` needed a 20s retry window on B —
filed as #NNN, not a release blocker.
## Waivers
A maintainer may ship without a passing drill, but **not without a record**.
The waiver is a section under the same heading saying who waived it, why, and
what is untested — a deliberate, reviewable commit. The gate requires a record,
not a pass, precisely so that skipping is visible.
## Records
*None yet.* cast has recorded no drill runs. New runs are appended below,
newest first.

View file

@ -26,6 +26,7 @@ import { tmp } from "./helpers/tmp.js";
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const NOTES = join(ROOT, ".github/scripts/release-notes.sh");
const MONOTONIC = join(ROOT, ".github/scripts/changelog-monotonic.sh");
const DRILL = join(ROOT, ".github/scripts/drill-recorded.sh");
function run(
cmd: string,
@ -728,6 +729,213 @@ describe("changelog-monotonic.sh — release headings are append-only (#133)", (
});
});
// --- a release carries its DRILL RECORD -------------------------------------
// CONTRIBUTING has always asked for the full real-hardware drill on a release
// PR; nothing asserted it, so no release in the family ever carried one. The
// gate moves the requirement out of a reviewer's memory and into the tree.
//
// What it asserts is that a RECORD EXISTS, never that the drill passed — a
// maintainer waiver is legal and is itself a section in drill/RUNS.md. That is
// the point: skipping stays possible and stays a deliberate, reviewable
// commit. So the cases below are all about presence, emptiness, and whether
// the version was matched whole; none of them inspect a result.
//
// Every fixture carries its OWN package.json and RUNS.md inside the temp dir.
// Pointing the guard at the repo's real package.json would make these cases
// change meaning on every version bump — green or red depending on where the
// ceremony happens to be standing, which is the opposite of a fixture.
describe("drill-recorded.sh — a release version has a drill record", () => {
const work = tmp("cast-drill-");
/** A fixture tree: its own package.json + drill runs file. */
function treeWith(name: string, version: string, runs?: string): string {
const dir = join(work, name);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "package.json"),
`${JSON.stringify({ name: "cast", version }, null, 2)}\n`,
);
if (runs !== undefined) writeFileSync(join(dir, "RUNS.md"), runs);
return dir;
}
const check = (dir: string) =>
run("bash", [DRILL, "RUNS.md", "package.json"], {}, dir);
const heading = (v: string) => `## Release drill — ${v} — 2026-07-21`;
const legs =
"\nInstances: A and B, live. All legs pass: team, apply, diff, smoke,\ninventory, emit-draft, fleet, destroy, read-only guard.\n";
it("a -dev tree passes with no drill record at all — nothing ships from it", async () => {
const r = await check(treeWith("dev", "0.1.2-dev"));
expect(r.code).toBe(0);
expect(r.output).toContain("development tree");
});
it("a bare version with a matching, non-empty record passes", async () => {
const runs = `# Drill runs\n\n${heading("0.2.0")}\n${legs}`;
const r = await check(treeWith("ok", "0.2.0", runs));
expect(r.code).toBe(0);
expect(r.output).toContain("0.2.0");
});
it("a bare version with NO record fails, naming the version", async () => {
const runs = "# Drill runs\n\n*None yet.*\n";
const r = await check(treeWith("none", "0.2.0", runs));
expect(r.code).toBe(1);
expect(r.output).toContain("no drill record for version '0.2.0'");
});
// A heading with nothing under it is ceremony without evidence — the exact
// shape a hurried release produces, and the one a heading-only check would
// wave through. release-notes.sh refuses an empty section for the same
// reason.
it("a present-but-EMPTY record fails — a heading is not a record", async () => {
const runs = `# Drill runs\n\n${heading("0.2.0")}\n\n\n## Notes\n\nUnrelated.\n`;
const r = await check(treeWith("empty", "0.2.0", runs));
expect(r.code).toBe(1);
expect(r.output).toContain("no drill record for version '0.2.0'");
});
// ...and whitespace is not evidence either. The first cut extracted with
// `sed '/./,$!d'`, where `.` matches a space, so a heading followed by one
// tab passed the gate — while the comment above the extractor claimed the
// opposite. An evidence-free release for the price of an invisible
// character, on the one check whose whole job is to demand evidence.
// Found independently by all three reviewers on #138.
it("a record body of only spaces and tabs fails (#138)", async () => {
const runs = `# Drill runs\n\n${heading("0.2.0")}\n \n\t\n\n## Notes\n\nUnrelated.\n`;
const r = await check(treeWith("blank", "0.2.0", runs));
expect(r.code).toBe(1);
expect(r.output).toContain("no drill record for version '0.2.0'");
});
// The sibling guards must agree about what a heading IS, not just about the
// version in it. box#149 requires the version to be the last field or be
// followed by the em dash; without the same constraint here, this heading is
// a record in cast and not in box.
it("a stray tail after the version is not a heading — in step with box#149", async () => {
const runs = `# Drill runs\n\n## Release drill — 0.2.0 stray words\n${legs}`;
const r = await check(treeWith("tail", "0.2.0", runs));
expect(r.code).toBe(1);
expect(r.output).toContain("no drill record for version '0.2.0'");
});
// The version is matched WHOLE, both directions — release-notes.sh's trap,
// solved the same way (awk field equality, no regex, no dot-escaping). A
// release candidate's drill is not the release's drill: different tree,
// different build, and a prefix match would silently accept it.
it("0.2.0-rc1's record does NOT satisfy 0.2.0", async () => {
const runs = `# Drill runs\n\n${heading("0.2.0-rc1")}\n${legs}`;
const r = await check(treeWith("rc-for-bare", "0.2.0", runs));
expect(r.code).toBe(1);
expect(r.output).toContain("no drill record for version '0.2.0'");
});
it("...and 0.2.0's record does NOT satisfy 0.2.0-rc1", async () => {
const runs = `# Drill runs\n\n${heading("0.2.0")}\n${legs}`;
const r = await check(treeWith("bare-for-rc", "0.2.0-rc1", runs));
expect(r.code).toBe(1);
expect(r.output).toContain("no drill record for version '0.2.0-rc1'");
});
it("...while each still matches its own record", async () => {
const runs = `# Drill runs\n\n${heading("0.2.0")}\n${legs}\n${heading("0.2.0-rc1")}\n${legs}`;
expect((await check(treeWith("both-a", "0.2.0", runs))).code).toBe(0);
expect((await check(treeWith("both-b", "0.2.0-rc1", runs))).code).toBe(0);
});
// The message is the whole user interface of a blocking guard. A failure
// that names the problem without naming the way out gets bypassed rather
// than satisfied — including the waiver, which must be visibly ALLOWED or
// somebody will route around the gate instead of recording one.
it("the failure names the unblock: run the drill, or record a waiver", async () => {
const r = await check(treeWith("unblock", "0.2.0", "# Drill runs\n"));
expect(r.code).toBe(1);
expect(r.output).toContain("## Release drill — 0.2.0");
expect(r.output).toContain("run the drill and record it");
expect(r.output).toContain("WAIVER");
expect(r.output).toContain("RECORD, not a");
});
it("a missing runs file on a release tree refuses — never a silent pass", async () => {
const r = await check(treeWith("norunsfile", "0.2.0"));
expect(r.code).toBe(1);
expect(r.output).toContain("no RUNS.md at all");
});
it("a missing version file refuses by path", async () => {
const r = await run("bash", [DRILL, "RUNS.md", "nope.json"], {}, work);
expect(r.code).toBe(1);
expect(r.output).toContain("no such file");
});
it("too many arguments is a usage error", async () => {
const r = await run("bash", [DRILL, "a", "b", "c"], {}, work);
expect(r.code).toBe(2);
expect(r.output).toContain("usage:");
});
// The REAL tree, run with the REAL defaults — the same discipline as the
// arming rule's "the REAL tree is armed". Whatever state the ceremony is in,
// this repo must satisfy its own gate.
it("the real tree satisfies its own gate, with default arguments", async () => {
const r = await run("bash", [DRILL], {}, ROOT);
expect(r.code).toBe(0);
});
it("drill/RUNS.md documents the heading format the gate parses", () => {
const runs = readFileSync(join(ROOT, "drill/RUNS.md"), "utf8");
expect(runs).toContain("## Release drill — X.Y.Z — YYYY-MM-DD");
// Per-repo by construction: cast records cast's legs and never reads
// another repo's log to decide whether cast may ship.
expect(runs).toMatch(/waiver/i);
});
it("ci.yml runs it, ungated by event or label", () => {
const CI = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8");
expect(CI).toContain(".github/scripts/drill-recorded.sh");
// Scoped to the step's own block (the monotonic assert above explains the
// bounding): an `if:` here would put the guard behind a hand-applied
// label, absent from exactly the PR that mislabels itself.
const lines = CI.split("\n");
const start = lines.findIndex((l) =>
/^ {6}- name: a release version has a drill record/.test(l),
);
expect(start).toBeGreaterThanOrEqual(0);
const after = lines.slice(start + 1);
const end = after.findIndex((l) => /^ {6}- /.test(l) || /^ {2}\S/.test(l));
const block = [
lines[start],
...after.slice(0, end < 0 ? after.length : end),
].join("\n");
expect(block).toContain("drill-recorded.sh");
expect(block).not.toMatch(/^ {8}if:/m);
});
it("CONTRIBUTING documents the gate and the ONE-RUN stack drill", () => {
const doc = readFileSync(join(ROOT, "CONTRIBUTING.md"), "utf8");
expect(doc).toContain("drill/RUNS.md");
expect(doc).toContain("drill-recorded.sh");
// One orchestrated run over the whole stack, in order: rig builds the
// host (installing box), box mints a seed, the seed calls rig back to
// converge, cast's legs run on the result.
expect(doc).toContain("--host yes");
expect(doc).toContain("box new");
expect(doc).toContain("rig bootstrap <tenant>-box");
// rig sits BELOW box and ABOVE it — mutually recursive, not linear. So
// the docs must not promise a fixed release order, and must say the run
// pins CANDIDATE refs, which is what dissolves the chicken-and-egg.
expect(doc).toMatch(/mutually recursive/);
expect(doc).toContain("RIG_REF");
expect(doc).toMatch(/candidate refs, not released artifacts/);
expect(doc).toMatch(/no fixed order|not.*published in a fixed order/i);
// Three records, one run: each repo cites the shared run ID.
expect(doc).toMatch(/run ID/i);
});
});
// --- release.yml — the wiring, pinned --------------------------------------
// The workflow itself only runs on a tag push upstream, so its load-bearing
// pieces are pinned here, fail-closed (the house discipline: the labels