diff --git a/.github/scripts/drill-recorded.sh b/.github/scripts/drill-recorded.sh new file mode 100755 index 0000000..ee77c27 --- /dev/null +++ b/.github/scripts/drill-recorded.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +# drill-recorded.sh [] [] — 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 [] []" >&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 "": "" +# 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. sed drops the blank +# padding under the heading and the command substitution eats the trailing +# blanks, which is what makes "present but EMPTY" distinguishable from +# "present with content": a heading with nothing but whitespace under it +# extracts to the empty string. +record="$(awk -v ver="$ver" ' + /^## / { grab = ($2 == "Release" && $3 == "drill" && $4 == "—" && $5 == ver); next } + grab { print } +' "$runs" | sed '/./,$!d')" + +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))" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a44759e..48a9705 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d608cf9..ed1380a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d8f746..d7eacb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 -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). diff --git a/LABELS.md b/LABELS.md index 9d2ca8c..c264c19 100644 --- a/LABELS.md +++ b/LABELS.md @@ -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 diff --git a/drill/RUNS.md b/drill/RUNS.md new file mode 100644 index 0000000..436bf6c --- /dev/null +++ b/drill/RUNS.md @@ -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 -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 ``, rig ``, cast `` — 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. diff --git a/test/release.test.ts b/test/release.test.ts index a803ec7..8e7457b 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -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,189 @@ 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'"); + }); + + // 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 -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