diff --git a/.github/scripts/drill-recorded.sh b/.github/scripts/drill-recorded.sh index 1eb36ec..975fdc9 100755 --- a/.github/scripts/drill-recorded.sh +++ b/.github/scripts/drill-recorded.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# drill-recorded.sh [] [] — assert that the version -# this tree is about to ship has a DRILL RECORD in drill/RUNS.md. +# drill-recorded.sh [] [] — assert that the version +# this tree is about to ship has a DRILL RECORD at /.md. # -# defaults: drill/RUNS.md VERSION +# defaults: drills VERSION # # CONTRIBUTING ("Releasing") says a release carries a real-hardware drill. # Nothing enforced it, so no release in this family has ever had one: the @@ -15,6 +15,21 @@ set -euo pipefail # interesting, which is never at 0.4.3. A bot finally blocked on it; this is # that block, moved into CI where it does not depend on anyone's attention. # +# ONE FILE PER VERSION, which is what this script is now mostly about. The +# first cut of this guard kept every record as a section inside one +# drill/RUNS.md, and paid for it: it needed an awk extractor that matched a +# literal '## Release drill — ' prefix, tolerated an optional ' — DATE' tail, +# compared the version WHOLE so that '0.3.0-rc1' could not answer for '0.3.0', +# and then separately insisted the extracted body hold a non-blank line. Every +# one of those rules existed only because records shared a file. Both sibling +# repos shipped a DEFECT out of that complexity during review — a +# `sed '/./,$!d'` extractor where `.` matches a space, so a heading plus one +# tab satisfied the gate (box#149, cast#138), and heading-grammar drift on the +# other side. Splitting the records makes nearly all of it unrepresentable: +# `0.3.0.md` and `0.3.0-rc1.md` are simply different files, there is no +# heading to parse and no grammar to drift, and the whole-version comparison +# is done by the filesystem. +# # PER-REPO, and that is the load-bearing design decision. The obvious # alternative — have rig ask box's repo whether the drill ran — cannot fail # safely: the lookup needs a network call, a token, and a checkout that may be @@ -26,13 +41,18 @@ set -euo pipefail # records rig's own legs in rig's own repo, and this script reads a file that # is either in the checkout or is not. # +# The directory is `drills/`, NOT `.drills/`. A dot-directory is invisible to +# every glob that has not set `dotglob`, which is how #70 here and box#116 / +# box#118 all happened: a file that exists but that no sweep can see is worse +# than no file, because it reads as covered. +# # What it asserts is a RECORD, not a RESULT — and that is deliberate, not a # weakness. A gate that demanded "the drill passed" would have to parse # somebody's prose for a verdict, and would leave a maintainer who consciously # ships without a full drill (a doc-only release, a hardware outage) with no # move except deleting the check. Requiring a record means the waiver is -# WRITTEN DOWN, under the version it applies to, in a commit a reviewer sees. -# Skipping stays possible; skipping silently does not. +# WRITTEN DOWN, in a file named for the version it applies to, in a commit a +# reviewer sees. Skipping stays possible; skipping silently does not. # # Vacuous on a `-dev` tree, which is why it needs no trigger scoping in # ci.yml (unlike changelog-monotonic.sh, whose input is a diff): every ordinary @@ -40,9 +60,13 @@ set -euo pipefail # all. The check has something to say on exactly one tree — the release # ceremony PR — and that is the tree it must be impossible to merge without. -runs="${1:-drill/RUNS.md}" +drills="${1:-drills}" version_file="${2:-VERSION}" +# An unreadable version file is an ERROR, never a silent pass. There is no +# version to be lenient about, so leniency here could only mean "ship +# unevidenced" — the exact degradation the per-repo decision above exists to +# avoid. [ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2 exit 1 @@ -65,88 +89,72 @@ case "$version" in ;; esac -# drill_section — print the BODY under that version's -# heading: everything between it and the next '## ' (or EOF), leading blank -# lines dropped. Empty output means "no record", which is the failure. -# -# Modelled on release-lib.sh's changelog_section(), with one difference that -# is the whole reason it is not that function: changelog_section() matches on -# awk's field $2, which works because a changelog heading is '## …'. -# Here the version is the FOURTH field of '## Release drill — X.Y.Z — DATE', -# and matching by field number would break the moment the em dashes moved. So -# this matches the literal prefix and then compares the version WHOLE. -# -# Whole is the point. A prefix match makes '0.3.0' satisfied by a record for -# '0.3.0-rc1' — a drill run against a release candidate, silently accepted as -# evidence for the final — and, in the other direction, makes an '0.3.0' -# record satisfy '0.3.0-rc1'. Both are the same defect: the string that -# matched is not the artefact that ships. changelog_section()'s exact `$2 == -# ver` is the precedent; this preserves it through a longer heading. -drill_section() { - awk -v ver="$2" ' - /^## / { - if (found) exit - line = $0; sub(/[[:space:]]+$/, "", line) - found = 0 - pfx = "## Release drill — " - if (index(line, pfx) == 1) { - rest = substr(line, length(pfx) + 1) - # Split at the version LENGTH, then demand the remainder be either - # nothing or the optional " — ". A trailing "-rc1" lands in - # tail, fails both, and is correctly not a match. (Double quotes on - # purpose: an apostrophe here would close the awk program.) - if (substr(rest, 1, length(ver)) == ver) { - tail = substr(rest, length(ver) + 1) - if (tail == "" || index(tail, " —") == 1) found = 1 - } - } - next - } - found && !body && /^[[:space:]]*$/ { next } - found { body = 1; print } - ' "$1" -} +record="$drills/$version.md" -# A missing runs file is not a different failure from a missing section: both -# mean "this release has no recorded drill", and both want the same unblock -# text. The first release under this gate in a repo with no drill/ directory -# yet is the missing-file case, and it must read as a to-do, not as a broken -# invocation. -record="" -if [ -f "$runs" ]; then - record="$(drill_section "$runs" "$version")" -fi - -if [ -z "$record" ]; then +# WHITESPACE IS NOT A RECORD. This is the one surviving piece of the rule set +# the old section-parsing guard needed, and it survives because it is the one +# part that splitting the files does not make unrepresentable: an empty file, +# or a file holding only spaces, tabs and newlines, exists at the right path +# and is still no evidence. It is the same property box#149 and cast#138 both +# got wrong with `sed '/./,$!d'` (`.` matches a space), where a record of one +# tab shipped an evidence-free release. `grep -q '[^[:space:]]'` is the whole +# check now, with no extractor in front of it to get wrong. +# +# The negated form below, matching box's and cast's twins exactly, so there is +# no divergence between the three to explain. +# +# It also avoids a real `set -e` hazard, which is worth naming precisely +# because an earlier draft of this comment named it BACKWARDS. A bare +# `[ -f "$record" ] && grep -q ... "$record"` mid-script does NOT abort when +# the file is missing: the left-hand side of `&&` is exempt from errexit, so a +# miss simply continues. What DOES abort is the other case — the file exists +# and `grep` finds nothing, i.e. exactly the whitespace-only record this guard +# is here to refuse. The script would die on its most interesting input, +# before printing the message that explains it. +# +# Verified rather than reasoned about: +# bash -ec '[ -f /nonexistent ] && r=yes; echo reached' -> prints, exit 0 +# bash -ec 'f=$(mktemp); echo " " >"$f" +# [ -f "$f" ] && grep -q "[^[:space:]]" "$f" +# echo reached' -> silent, exit 1 +# +# Caught by all three reviewers on #104. The lesson is the same one #149 and +# cast#138 taught: this family's comments get read as contracts, so a comment +# that misstates the semantics is a defect even when the code is correct. +if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then { - echo "drill-recorded: VERSION is $version, and $runs has NO drill record for it." + echo "drill-recorded: VERSION is $version, and there is no drill record at $record." echo cat <.md). CONTRIBUTING ("Releasing") has always required one and # nothing enforced it, so no release in this family has ever carried one # — the drill was the single ceremony step checked by a reviewer # remembering rather than by a script. diff --git a/CHANGELOG.md b/CHANGELOG.md index a64601b..7db2e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ on the way to cutting its first release, and this file starts there. ### Added -- CI refuses a release PR with no drill record in `drill/RUNS.md` +- CI refuses a release PR with no drill record at `drills/.md` - `rig platform` — what this machine is, computed at run time, stored nowhere (#64) - `/etc/rig/manifest` records which rig converged a machine, and when (#61) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4943424..8dc4945 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,33 +100,52 @@ extra gate before the handoff: The **drill** is a real-hardware run — tenant guests minted and converged via box, `test/db-integration.sh`, the GitHub runner lifecycle against a fork, a -coolify install — recorded in [`drill/RUNS.md`](drill/RUNS.md) under a heading -of exactly: +coolify install — recorded in **one file per version**: ``` -## Release drill — X.Y.Z — YYYY-MM-DD +drills/.md ``` +named for the version exactly as `VERSION` carries it. See +[`drills/README.md`](drills/README.md) for what a record should contain. + `.github/scripts/drill-recorded.sh` enforces it on every release: a bare -`VERSION` with no non-empty section for it turns CI red, naming the version. -It is **not a thing a reviewer has to remember** — that is how every release -in this family shipped undrilled until a bot finally blocked on one. On a -`-dev` tree it asserts nothing, so it is invisible to ordinary PRs. rig reads +`VERSION` with no non-empty `drills/.md` turns CI red, naming the +version. It is **not a thing a reviewer has to remember** — that is how every +release in this family shipped undrilled until a bot finally blocked on one. On +a `-dev` tree it asserts nothing, so it is invisible to ordinary PRs. rig reads rig's own record and never box's repo: a cross-repo lookup fails on a token, a fork checkout or a network blip, and all of those degrade to "pass" — the UNREADABLE-vs-NONE shape #90 fixed. -**The drill is ONE orchestrated run over the whole stack**, not three -independent ones. box and rig are mutually recursive, so there is no linear -order to drill them in: rig sits *below* box as the host-builder and *above* -it as the guest-converger. The run therefore goes: +One file per version is what keeps the guard small. Records used to share a +single log, which forced a heading grammar, an optional-date tail, a +whole-version comparison and a non-blank-body rule just to read them back — and +both sibling repos shipped a defect out of that complexity in review. Now +`0.3.0.md` and `0.3.0-rc1.md` are simply different files. -1. `rig bootstrap … --host yes` on a bare Debian host — which installs box and - runs box's `setup-host` (`RIG_SKIP_BOX_INSTALL=1` skips it; see README) -2. `box new` mints a creds-free seed -3. the seed converges via `rig bootstrap -box` — the seed's cloud-init - curls rig's installer at `@RIG_REPO@/@RIG_REF@` -4. cast on top +**The three repos' drills are INDEPENDENT.** Run them 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's drill runs `--host yes` with +`BOX_REF=release/`, so it exercises the box that will actually +ship; box's drill mints with `RIG_REF=release/`, so it exercises +the rig that will actually ship. Both measure the same pair. + +That — not sequencing — is what dissolves the box↔rig recursion. box and rig +are mutually recursive (`rig bootstrap … --host yes` installs box and runs +box's `setup-host`; box's `box new` seeds converge back through rig's installer +at `@RIG_REPO@/@RIG_REF@`), but the 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. Within a single drill +you naturally bring the substrate up before probing it — a host before a guest +— but that is how you run a drill, not an ordering rule between repos. + +Each repo drills in a **different way** and asserts a different thing: rig +asserts **convergence** (a machine reaches its role, idempotently), box asserts +the **isolation contract** (the VM trust boundary), cast asserts **promotion** +(A→B reproduces, the diff is idempotent). Three different exercises sharing a +substrate, not three phases of one script — which is exactly why the records +are per-repo. It drills **candidate refs, not released artifacts.** `RIG_REPO`/`RIG_REF` are mint-time environment variables (default `heavy-duty/rig@main`), so a run pins @@ -137,16 +156,18 @@ repo has to be released before another can be drilled. `VERSION` + `CHANGELOG.md` and nothing else — no executable difference exists between the tree that was drilled and the tree that ships. -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, so the three records can be joined after the fact by -anyone reading them. The guard still reads only this repo's file — there is no +Drills that share a substrate share **one run ID**. Each repo records *its own* +legs in its own `drills/.md`, citing that run ID and the other two +repos' commit SHAs, so the records can be joined after the fact by anyone +reading them. The guard still reads only this repo's file — there is no cross-repo lookup anywhere in the gate. Releases do **not** have to be -published in a fixed order. +published in a fixed order. 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 — a doc-only release, a hardware outage — -but it must be **recorded in `drill/RUNS.md` for that version**, saying who -waived it and why. The guard asks for a *record*, not a passing result, +but it must be **recorded in `drills/.md` for that version**, saying +who waived it and why. The guard asks for a *record*, not a passing result, precisely so that skipping is a deliberate, reviewable commit instead of a silence. Deleting the check is not the move. diff --git a/LABELS.md b/LABELS.md index 970a51a..865541f 100644 --- a/LABELS.md +++ b/LABELS.md @@ -36,7 +36,7 @@ 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 drill is run and recorded, or a maintainer waiver is recorded for that version | +| `blocker:drill-pending` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the drill is run and recorded, or a maintainer waiver is recorded for that version | `blocker:drill-pending` is the one blocker that is not about the code: the branch merges, the checks that read the tree are green, and the release is @@ -165,7 +165,7 @@ gh label create "blocker:conflict" --color B60205 --description "Does not me 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. Until it exists, `blocked` stands in. -gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record in drill/RUNS.md — correct but unevidenced" --force +gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/.md — correct but 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 deleted file mode 100644 index e1877b2..0000000 --- a/drill/RUNS.md +++ /dev/null @@ -1,80 +0,0 @@ -# Drill run log - -rig's own record of its real-hardware drill legs. One section per run, -appended. **This file is the record, not the instrument.** - -rig has **no drill harness script of its own yet**. Its legs are run by hand, -following the documented procedure: - -- tenant guests minted and converged **via box** -- `bash test/db-integration.sh` against a real Postgres on the machine -- the GitHub runner lifecycle — register, take a job, deregister — against a - fork -- a coolify install - -The harness lives in heavy-duty/box's `drill/`. rig does **not** reach into it -to decide whether rig may ship. A cross-repo lookup that fails silently -degrades to "pass", which is the UNREADABLE-vs-NONE shape #90 fixed — so the -gate reads this file, in this repo, and nothing else. - -The drill itself is **one orchestrated run over the whole stack** — -`rig bootstrap --host yes` on a bare host (which installs box and runs box's -`setup-host`), then `box new` for a creds-free seed, then that seed converging -via `rig bootstrap -box`, then cast on top. box and rig are mutually -recursive, so there is no order to drill them in: rig is both the host-builder -below box and the guest-converger above it. - -It drills **candidate refs, not released artifacts**: `RIG_REPO`/`RIG_REF` are -mint-time environment variables, so a run pins the exact commits under test. -Drilling the candidate *is* drilling the release, because a release PR's diff -is `VERSION` + `CHANGELOG.md` and nothing executable differs. One run emits one -shared **run ID**; each repo records its own legs under its own heading, citing -that run ID and the other two repos' commit SHAs (CONTRIBUTING, "Releasing"). - -## What the gate requires - -`.github/scripts/drill-recorded.sh` runs on every PR. On a `-dev` tree it -asserts nothing — a development tree has no release to evidence. On a bare -`VERSION` — a release ceremony tree — it requires a section here headed -exactly: - - ## Release drill — X.Y.Z — YYYY-MM-DD - -The trailing ` — DATE` is optional; the version is matched **whole**, so a -`0.3.0-rc1` record does not satisfy `0.3.0` and the reverse is equally false. -The section must extract non-empty: at least one non-blank line before the -next `## `. - -The guard requires a **record**, not a passing result. A maintainer waiver is -a legitimate outcome of a release — but it is written here, under that -version's heading, so that skipping the drill is a deliberate, reviewable -commit rather than a silence. - -### What a record should contain - -What ran, on what, the numbers, and what failed. Below is the *shape*, not a -run that happened — no drill has been recorded here yet: - - ## Release drill — 9.9.9 — 2026-01-01 - - Run ID: drill-2026-01-01-a. Host: bare Debian 13 cloud image, 4 vCPU / 8 GB. - Candidate refs: box@1a2b3c4, rig@5d6e7f8, cast@9a0b1c2. - - | Leg | Result | - | --- | --- | - | tenant guests minted + converged via box | 3/3 | - | `test/db-integration.sh` | 14/14 | - | runner lifecycle against a fork | PASS — registered, took a job, deregistered clean | - | coolify install | PASS, ~6 min | - - Failed: `rig users apply` left one revoked key in `authorized_keys` - (filed #NNN). Everything else clean. - -State what failed. A record with no failures listed reads as "nothing broke", -so if a leg was not run, say that instead of omitting it. - -## Runs - -*None recorded yet.* This log starts empty rather than reconstructing runs -from memory — an invented number is worse than no number. The first release -cut under the gate writes the first section here. diff --git a/drills/README.md b/drills/README.md new file mode 100644 index 0000000..28afea0 --- /dev/null +++ b/drills/README.md @@ -0,0 +1,113 @@ +# Drill records + +Per-release evidence for rig's real-hardware drill. **One file per version**, +named for the version exactly as `VERSION` carries it: + + drills/.md + +So `0.3.0` is recorded in `drills/0.3.0.md`, and `0.3.0-rc1` in +`drills/0.3.0-rc1.md`. They are different files, which is the whole point: the +filesystem does the whole-version comparison that an earlier single-file +version of this had to do with an awk extractor and a heading grammar. A +record for the candidate cannot be mistaken for evidence for the final. + +The directory is `drills/`, not `.drills/` — a dot-directory is invisible to +any glob without `dotglob`, which is how #70 here and box#116 / box#118 all +happened. + +**This directory is the record, not the instrument.** rig has **no drill +harness script of its own**; its legs are run by following the documented +procedure, and the harness lives in heavy-duty/box's `drill/`. rig does not +reach into it to decide whether rig may ship: a cross-repo lookup that fails +silently degrades to "pass", which is the UNREADABLE-vs-NONE shape #90 fixed. +The gate reads a file in this repo, and nothing else. + +## What the gate requires + +`.github/scripts/drill-recorded.sh` runs on every PR. On a `-dev` tree it +asserts nothing — a development tree has no release to evidence. On a bare +`VERSION` — a release ceremony tree — it requires `drills/.md` to +exist and to hold at least one non-whitespace character. An empty file, or one +of only spaces and tabs, is not a record (box#149 and cast#138 both shipped an +extractor where a single tab satisfied the gate). + +The guard requires a **record**, not a passing result. A maintainer waiver is +a legitimate outcome of a release — but it is written in that version's file, +so that skipping the drill is a deliberate, reviewable commit rather than a +silence. **A failed drill is still a valid record**: the gate wants evidence, +not success. + +## The drill + +rig's legs: + +- tenant guests minted and converged **via box** +- `bash test/db-integration.sh` against a real Postgres on the machine +- the GitHub runner lifecycle — register, take a job, deregister — against a + fork +- a coolify install + +box and rig are **mutually recursive**: `rig bootstrap --host yes` installs box +and runs box's `setup-host`, while box's guests converge back through rig's +installer. Within a single drill you naturally bring the substrate up before +probing it — a host before a guest — but that is how you run a drill, not an +ordering rule between repos. + +**The three repos' drills are independent.** Run them 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's drill runs `--host yes` +with `BOX_REF=release/`, so it exercises the box that will +actually ship; box's drill mints with `RIG_REF=release/`, so it +exercises the rig that will actually ship. Both measure the same pair. + +That — not sequencing — is what dissolves the box↔rig recursion. The refs are +static identifiers that exist as soon as the release branches do, long before +any drill runs, so a cycle at runtime becomes two independent tests against +one fixed pair. It also means **candidate refs, not released artifacts**: +`RIG_REPO`/`RIG_REF` are mint-time environment variables, so no repo has to be +released before another can be drilled. Drilling the candidate *is* drilling +the release, because a release PR's diff is `VERSION` + `CHANGELOG.md` and +nothing executable differs. + +Each repo drills in a **different way** and asserts a different thing: rig +asserts **convergence** (a machine reaches its role, idempotently), box asserts +the **isolation contract** (the VM trust boundary), cast asserts **promotion** +(A→B reproduces, the diff is idempotent). Three different exercises sharing a +substrate, not three phases of one script — which is exactly why the records +are per-repo. + +Drills that share a substrate share **one run ID**; each repo records its own +legs in its own file, citing that run ID and the other repos' commit SHAs, so +the records can be joined after the fact. 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. Releases +do **not** have to be published in a fixed order. + +## What a record should contain + +What ran, on what host, the pinned candidate refs, the numbers, and what +failed. Below is the *shape*, in a file named `drills/9.9.9.md` — a version +that can never collide with a real release. **No drill has been recorded here +yet**; this log starts empty rather than reconstructing runs from memory, since +an invented number is worse than no number. + +```markdown +# Release drill — 9.9.9 — 2026-01-01 + +Run ID: drill-2026-01-01-a. Host: bare Debian 13 cloud image, 4 vCPU / 8 GB. +Candidate refs: box@1a2b3c4 (BOX_REF=release/0.4.0), rig@5d6e7f8, cast@9a0b1c2. + +| Leg | Result | +| --- | --- | +| tenant guests minted + converged via box | 3/3 | +| re-converge (idempotence) | clean, no changes | +| `test/db-integration.sh` | 14/14 | +| runner lifecycle against a fork | PASS — registered, took a job, deregistered clean | +| coolify install | PASS, ~6 min | + +Failed: `rig users apply` left one revoked key in `authorized_keys` +(filed #NNN). Everything else clean. +``` + +State what failed. A record with no failures listed reads as "nothing broke", +so if a leg was not run, say that instead of omitting it. diff --git a/test/release.sh b/test/release.sh index 79def52..2ff49b0 100644 --- a/test/release.sh +++ b/test/release.sh @@ -471,49 +471,71 @@ check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \ # other ceremony step is checked by a script, and the one that costs an # afternoon was checked by a reviewer remembering. A bot finally blocked on it. # -# Fixtures carry their OWN VERSION and RUNS.md, inside the fixture dir. This is -# not tidiness — it is heavy-duty/box#146, verbatim: fixtures that read the -# REPO's VERSION exercised the `-dev` branch on every ordinary tree, so the -# whole bare-version half of the guard was untested and went red for the first -# time while somebody was cutting a release. A fixture must state the tree it -# is about. +# Records are ONE FILE PER VERSION, at drills/.md. The first cut of +# this guard kept them as sections in a single drill/RUNS.md and needed a +# heading grammar, an optional-date tail, a whole-version comparison and a +# non-blank-body rule to read them back — all of it there only because the +# records shared a file, and both sibling repos shipped a defect out of it in +# review. Splitting the files deletes most of these tests along with the code +# they covered: `0.3.0.md` and `0.3.0-rc1.md` cannot be confused by any +# grammar, because there is no grammar. +# +# Fixtures carry their OWN version file and their OWN drills dir, inside the +# fixture dir. This is not tidiness — it is heavy-duty/box#146, verbatim: +# fixtures that read the REPO's VERSION exercised the `-dev` branch on every +# ordinary tree, so the whole bare-version half of the guard was untested and +# went red for the first time while somebody was cutting a release. A fixture +# must state the tree it is about. DRILL="$ROOT/.github/scripts/drill-recorded.sh" check "drill-recorded.sh: exists and is the guard under test" 0 "" test -f "$DRILL" check "drill-recorded.sh: is executable" 0 "" test -x "$DRILL" -drilltree() { # drilltree -> prints the dir +drilltree() { # drilltree -> prints the dir (no drills/ yet) local d="$WORK/drill-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION" - shift 2; printf '%s\n' "$@" > "$d/RUNS.md"; printf '%s' "$d" + printf '%s' "$d" } -drill() { bash "$DRILL" "$1/RUNS.md" "$1/VERSION" 2>&1; } +drillrec() { # drillrec — write drills/.md + mkdir -p "$1/drills"; local f="$1/drills/$2.md"; shift 2 + printf '%s\n' "$@" > "$f" +} +drill() { bash "$DRILL" "$1/drills" "$1/VERSION" 2>&1; } # A development tree. Vacuous by construction — every ordinary PR looks like # this, and none of them can be asked to have drilled a release that does not -# exist. It must pass with the log empty of records, which is the state -# drill/RUNS.md ships in today. -T="$(drilltree dev 0.2.1-dev '# Drill run log' '' 'No runs recorded yet.')" -check "drill: a -dev tree passes with NO drill record" 0 "" drill "$T" +# exist. It passes with no drills/ directory present AT ALL, which is the +# state this repo ships in today. +T="$(drilltree dev 0.2.1-dev)" +check "drill: a -dev tree passes with NO drills dir at all" 0 "" drill "$T" check "drill: ...saying so out loud, not exiting 0 in silence" 0 \ "nothing to assert" drill "$T" # The release ceremony tree, drilled and recorded. GREEN. -T="$(drilltree recorded 0.3.0 '# Drill run log' '' '## Release drill — 0.3.0 — 2026-07-21' '' \ - 'Host: bare Debian 13. Guests via box 0.4.0 (released, pinned).' '' \ - '- db-integration: 14/14' '- runner lifecycle: PASS' '' \ - '## Release drill — 0.2.0 — 2026-07-01' '' 'an older run')" -check "drill: a bare VERSION with a matching non-empty record passes" 0 "records a drill for 0.3.0" drill "$T" +T="$(drilltree recorded 0.3.0)" +drillrec "$T" 0.3.0 '# Release drill — 0.3.0 — 2026-07-21' '' \ + 'Host: bare Debian 13. Candidate refs pinned: box@1a2b3c4, cast@9a0b1c2.' '' \ + '- convergence, then re-converge: clean' \ + '- db-integration: 14/14' '- runner lifecycle: PASS' +drillrec "$T" 0.2.0 '# Release drill — 0.2.0 — 2026-07-01' '' 'an older run' +check "drill: a bare VERSION with a non-empty record for it passes" 0 \ + "records a drill for 0.3.0" drill "$T" -# The heading with no date — the trailing ' — DATE' is optional, so a record -# written without one is still a record. -T="$(drilltree nodate 0.3.0 '# Drill run log' '' '## Release drill — 0.3.0' '' 'ran it, 12/12')" -check "drill: the date suffix is optional" 0 "records a drill" drill "$T" - -# The gate itself: a release tree with no record at all. RED, naming the -# version, because "which release is unevidenced" is the only fact the author -# needs. -T="$(drilltree norecord 0.3.0 '# Drill run log' '' 'No runs recorded yet.')" -check "drill: a bare VERSION with NO record FAILS" 1 "NO drill record" drill "$T" +# The gate itself: a release tree with no drills/ directory at all. RED, +# naming the version, because "which release is unevidenced" is the only fact +# the author needs. This is the state a repo is in the first time it cuts a +# release under the gate — it must read as a to-do, not a broken invocation. +T="$(drilltree norecord 0.3.0)" +check "drill: a bare VERSION with NO drills dir FAILS" 1 \ + "no drill record" drill "$T" check "drill: ...and the failure names the version" 1 "VERSION is 0.3.0" drill "$T" +check "drill: ...and names the file it wanted" 1 "drills/0.3.0.md" drill "$T" + +# A drills/ that exists but holds nothing for THIS version. Same failure — +# other releases having been drilled says nothing about this one. +T="$(drilltree otherversion 0.4.0)" +drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the previous release' +check "drill: a drills dir with no file for THIS version FAILS" 1 \ + "no drill record" drill "$T" +check "drill: ...naming the version that is unevidenced" 1 "VERSION is 0.4.0" drill "$T" # ...and the failure has to say how to get out of it. Both moves are a commit # on the PR, and the second one is the point of asking for a RECORD rather @@ -522,75 +544,97 @@ check "drill: ...and the failure names the unblock — run the drill" 1 \ "RUN THE DRILL" drill "$T" check "drill: ...and the waiver, recorded, as the other way out" 1 \ "MAINTAINER WAIVER" drill "$T" -check "drill: ...and shows the exact heading the guard wants" 1 \ - "## Release drill — 0.3.0" drill "$T" +check "drill: ...and points at the README for what a record contains" 1 \ + "README.md" drill "$T" -# A heading with nothing under it. This is the failure a laxer guard invites — -# the ceremony PR adds the heading to get green and fills it in never. A -# section is a record only if something is in it. -T="$(drilltree empty 0.3.0 '# Drill run log' '' '## Release drill — 0.3.0 — 2026-07-21' '' '' \ - '## Release drill — 0.2.0 — 2026-07-01' '' 'an older run')" -check "drill: a PRESENT but EMPTY record FAILS" 1 "NO drill record" drill "$T" -# ...and it fails for being empty, not for being unfindable: the older section -# below it must not be scavenged to satisfy the newer heading. -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "drill: ...an empty section never borrows the next section's body" 1 "" \ - bash -c 'bash "$1" "$2/RUNS.md" "$2/VERSION" 2>&1 | grep -q "an older run"' _ "$DRILL" "$T" +# An EMPTY file at the right path. This is the failure a laxer guard invites — +# the ceremony PR touches the file to get green and fills it in never. +T="$(drilltree emptyfile 0.3.0)" +mkdir -p "$T/drills"; : > "$T/drills/0.3.0.md" +check "drill: a PRESENT but EMPTY record FAILS" 1 "no drill record" drill "$T" -# ...and WHITESPACE is not a record either. This guard already gets it right — -# `/^[[:space:]]*$/` skips a spaces-or-tabs line the same as a bare one — so -# this pins behaviour rather than fixing it. It is here because the siblings -# did NOT get it right: box#149 and cast#138 both extracted with -# `sed '/./,$!d'`, where `.` matches a space, so a heading followed by one tab -# satisfied the gate and shipped an evidence-free release. All three reviewers -# caught it there. Nothing caught it here, because there was nothing to catch — -# which is exactly the state in which a later "simplify this to match its -# siblings" quietly reintroduces it. The cheapest moment to pin a property is -# while you still remember why it matters. -T="$(drilltree blank 0.3.0 '# Drill run log' '' '## Release drill — 0.3.0 — 2026-07-21' ' ' ' ' '' \ - '## Release drill — 0.2.0 — 2026-07-01' '' 'an older run')" -check "drill: a record body of only spaces and tabs FAILS (box#149, cast#138)" \ - 1 "NO drill record" drill "$T" +# ...and WHITESPACE is not a record either. This is the ONE piece of the old +# section-parsing rule set that splitting the files did not make +# unrepresentable, so it is the one that still needs a test. It is here because +# the siblings got it wrong: box#149 and cast#138 both extracted with +# `sed '/./,$!d'`, where `.` matches a space, so one tab satisfied the gate and +# shipped an evidence-free release. All three reviewers caught it there. +# Nothing caught it here, because there was nothing to catch — which is exactly +# the state in which a later simplification quietly reintroduces it. +T="$(drilltree blank 0.3.0)" +drillrec "$T" 0.3.0 ' ' ' ' '' +check "drill: a record of only spaces, tabs and newlines FAILS (box#149, cast#138)" \ + 1 "no drill record" drill "$T" -# The version is matched WHOLE, both directions. A drill run against a release +# The version is matched WHOLE, both directions — and now the filesystem does +# it, since the version IS the filename. A drill run against a release # candidate is not evidence for the final release, and the reverse is equally -# false — in both cases the string that matched is not the artefact that -# ships. changelog_section()'s exact `$2 == ver` is the precedent; this heading -# is longer, so the comparison had to be rebuilt rather than inherited. -T="$(drilltree whole-rc 0.3.0 '# Drill run log' '' '## Release drill — 0.3.0-rc1 — 2026-07-20' '' 'the rc drill')" -check "drill: an -rc1 record does NOT satisfy the bare version" 1 "NO drill record" drill "$T" -T="$(drilltree whole-final 0.3.0-rc1 '# Drill run log' '' '## Release drill — 0.3.0 — 2026-07-21' '' 'the final drill')" -check "drill: ...and a bare-version record does NOT satisfy the -rc1" 1 "NO drill record" drill "$T" -# A shorter version is not a prefix win either: 0.3.0 must not be answered by -# a 0.3.0.1 heading, nor 0.3 by 0.3.0. -T="$(drilltree whole-longer 0.3.0 '# Drill run log' '' '## Release drill — 0.3.0.1 — 2026-07-21' '' 'a different thing')" -check "drill: ...nor does a LONGER version number match by prefix" 1 "NO drill record" drill "$T" -# And an unrelated version is simply absent, which is the same failure. -T="$(drilltree other 0.4.0 '# Drill run log' '' '## Release drill — 0.3.0 — 2026-07-21' '' 'the previous release')" -check "drill: a record for a DIFFERENT version is not this version's record" 1 "VERSION is 0.4.0" drill "$T" - -# The repo with no drill/ directory yet — the state rig was in before this -# guard. It must read as "this release has no record", the same to-do as an -# empty log, not as a broken invocation. -T="$(drilltree norunsfile 0.3.0 'placeholder')" -rm -f "$T/RUNS.md" -check "drill: a MISSING runs file is the same failure, not a crash" 1 "NO drill record" drill "$T" -check "drill: ...and still names the unblock" 1 "RUN THE DRILL" drill "$T" +# false: in both cases the string that matched is not the artefact that ships. +T="$(drilltree whole-rc 0.3.0)" +drillrec "$T" 0.3.0-rc1 '# Release drill — 0.3.0-rc1' '' 'the rc drill' +check "drill: an -rc1 record does NOT satisfy the bare version" 1 \ + "no drill record" drill "$T" +T="$(drilltree whole-final 0.3.0-rc1)" +drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the final drill' +check "drill: ...and a bare-version record does NOT satisfy the -rc1" 1 \ + "no drill record" drill "$T" # A missing VERSION file is a wrong invocation, not a degradation — there is -# no version to be lenient about. -T="$(drilltree noversion 0.3.0 '# Drill run log')" +# no version to be lenient about, so it must never read as a pass. +T="$(drilltree noversion 0.3.0)" rm -f "$T/VERSION" check "drill: a missing VERSION file is an error, never a pass" 1 "no such file" drill "$T" -# The real files, last. The shipped log must be readable by the guard the -# repo actually runs, on the VERSION the repo actually carries. -check "drill/RUNS.md: exists" 0 "" test -f "$ROOT/drill/RUNS.md" +# The real files, last. The shipped README must be readable, and the guard the +# repo actually runs must pass on the VERSION the repo actually carries. +check "drills/README.md: exists" 0 "" test -f "$ROOT/drills/README.md" # shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "drill/RUNS.md: documents the heading format the guard requires" 0 "" \ - bash -c 'grep -qF "## Release drill — X.Y.Z — YYYY-MM-DD" "$1"' _ "$ROOT/drill/RUNS.md" -check "drill-recorded.sh: passes against the real tree" 0 "" \ - bash "$DRILL" "$ROOT/drill/RUNS.md" "$ROOT/VERSION" +check "drills/README.md: documents the one-file-per-version naming rule" 0 "" \ + bash -c 'grep -qF "drills/.md" "$1"' _ "$ROOT/drills/README.md" +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "drills/README.md: says a FAILED drill is still a valid record" 0 "" \ + bash -c 'grep -qi "failed drill is still a valid record" "$1"' _ "$ROOT/drills/README.md" +# The old single-file record must be gone, not merely unreferenced: a stale +# drill/RUNS.md would be a second place to write a record that nothing reads. +check "drill/RUNS.md: is gone — records live one per version now" 1 "" \ + test -e "$ROOT/drill/RUNS.md" +# The property is that the guard's VERDICT IS CORRECT FOR THIS TREE — not that +# it always passes. Those come apart on a ceremony tree: a -dev tree is vacuous +# and must pass, but a ceremony tree passes only once a human has run the drill +# and written the record, which is the entire point of the gate. Asserting +# exit 0 unconditionally made test/release.sh UN-GREENABLE on every release +# branch before its drill, and surfaced as a `release-flow tests` failure rather +# than as the gate doing its job — the same misattribution shape as box#146, +# where a fixture read the repo's real VERSION and only misbehaved on the +# ceremony tree. Caught when box#148 went red for the wrong-looking reason. +THIS_VER="$(tr -d '[:space:]' < "$ROOT/VERSION")" +case "$THIS_VER" in + *-dev) + check "drill-recorded.sh: THIS tree is -dev, and the guard is vacuous on it" 0 "" \ + bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" ;; + *) + if [ -s "$ROOT/drills/$THIS_VER.md" ]; then + check "drill-recorded.sh: THIS ceremony tree HAS its record, and the guard accepts it" 0 "" \ + bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" + else + check "drill-recorded.sh: THIS ceremony tree has NO record yet, and the guard refuses it" 1 "no drill record at" \ + bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" + fi ;; +esac +# ...and with no arguments at all, since that is how ci.yml invokes it. The +# defaults must be the paths this repo actually uses. +# ...and with its DEFAULT arguments, as CI runs it. What this pins is that the +# defaults ARE the paths this repo uses — so it asserts the defaults reach the +# same verdict as the explicit call above, not a fixed exit code. Hard-coding 0 +# here would fail on a ceremony tree for the same wrong reason the check above +# used to. +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "drill-recorded.sh: ...and its DEFAULT arguments agree, as CI runs it" 0 "" \ + bash -c ' + cd "$1" || exit 9 + bash .github/scripts/drill-recorded.sh >/dev/null 2>&1; d=$? + bash .github/scripts/drill-recorded.sh drills VERSION >/dev/null 2>&1; e=$? + [ "$d" -eq "$e" ]' _ "$ROOT" # ci.yml: the guard runs from there, so pin the wiring — a script nothing # invokes is not a check (same reasoning as the monotonic pins above). @@ -607,22 +651,26 @@ check "ci.yml: the drill step itself is NOT trigger-gated" 1 "" drill_step_gated check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \ "drill-recorded" drill_step_block -# CONTRIBUTING must state the gate, and must state what the drill actually is: -# ONE orchestrated run over the whole stack, on CANDIDATE refs. box and rig are -# mutually recursive — rig builds the host box runs on, and box mints the seeds -# rig converges — so there is no linear "drill A, release A, then drill B" -# order to write down, and pinning RIG_REPO/RIG_REF at mint time is what makes -# the recursion drillable at all. An earlier draft of this doc claimed a fixed -# box → rig → cast release order; it is wrong, and this pins the correction. +# CONTRIBUTING must state the gate, and must state what the drill actually is. +# The three repos' drills are INDEPENDENT — run in any order, on any schedule — +# and what makes that safe is that each one pins the same fixed set of +# CANDIDATE refs, so box and rig measure the same pair. That, not sequencing, +# is what dissolves the mutual recursion (rig builds the host box runs on, and +# box mints the seeds rig converges). An earlier draft of this doc claimed a +# fixed box → rig → cast release order; it is wrong, and this pins the +# correction. CONTRIB="$ROOT/CONTRIBUTING.md" # shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately check "CONTRIBUTING: the release flow names the drill gate" 0 "" \ - bash -c 'grep -qF "drill/RUNS.md" "$1"' _ "$CONTRIB" + bash -c 'grep -qF "drills/.md" "$1"' _ "$CONTRIB" # shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: ...and says the drill is ONE orchestrated stack run" 0 "" \ - bash -c 'grep -qi "one orchestrated run" "$1"' _ "$CONTRIB" +check "CONTRIBUTING: ...and says the three repos' drills are INDEPENDENT" 0 "" \ + bash -c 'grep -qi "drills are independent" "$1"' _ "$CONTRIB" # shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: ...on candidate refs, which is what dissolves the recursion" 0 "" \ +check "CONTRIBUTING: ...pinned to one fixed set of candidate refs" 0 "" \ + bash -c 'grep -qi "same fixed set of candidate refs" "$1"' _ "$CONTRIB" +# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately +check "CONTRIBUTING: ...which is what dissolves the recursion, not sequencing" 0 "" \ bash -c 'grep -qF "RIG_REF" "$1"' _ "$CONTRIB" # The negative that keeps the correction from being re-lost: no fixed release # order may be claimed. Nothing requires box to ship before rig.