Merge pull request #102 from dan-claude-bot/feat/drill-gate

feat: CI refuses a release PR with no drill record
This commit is contained in:
Daniel Marin 2026-07-21 17:23:34 +01:00 committed by GitHub
commit 750d93deda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 506 additions and 1 deletions

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

@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
# drill-recorded.sh [<runs-file>] [<version-file>] — assert that the version
# this tree is about to ship has a DRILL RECORD in drill/RUNS.md.
#
# defaults: drill/RUNS.md 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
# ceremony is four correct mechanical steps — bump VERSION, stamp the
# changelog, re-arm, merge — and every one of them is checked by a script,
# while the one step that costs an afternoon on real hardware was checked by
# a reviewer remembering. Reviewers remember exactly as long as the release is
# 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.
#
# 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
# a fork, and every one of those failure modes lands on "could not read", which
# a naive implementation spells `|| true` and reads as PASS. That is exactly
# the UNREADABLE-vs-NONE bug #90 fixed one layer up (an unreadable check rollup
# reading as "nothing is failing"), and re-introducing it in the release gate
# would be worse: it degrades to green on precisely the tree that ships. So rig
# 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.
#
# 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.
#
# 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
# PR carries a `-dev` VERSION and passes without a drill record existing at
# 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}"
version_file="${2:-VERSION}"
[ -f "$version_file" ] || {
echo "drill-recorded: no such file: $version_file" >&2
exit 1
}
version="$(tr -d '[:space:]' < "$version_file")"
[ -n "$version" ] || {
echo "drill-recorded: $version_file is empty — there is no version to check a drill against." >&2
exit 1
}
# The -dev half. A development tree is not shipping anything, so there is
# nothing to evidence; saying so out loud (rather than exiting 0 in silence)
# is the #98 lesson — a guard that prints nothing is indistinguishable from a
# guard that did nothing.
case "$version" in
*-dev)
echo "drill-recorded: VERSION is $version — a development tree has nothing to assert (the drill gates a RELEASE, and this is not one)."
exit 0
;;
esac
# drill_section <file> <version> — 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 '## <version> …'.
# 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 " — <date>". 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"
}
# 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
{
echo "drill-recorded: VERSION is $version, and $runs has NO drill record for it."
echo
cat <<EOF
This tree is a release ceremony tree — VERSION is bare, so merging it ships
$version. CONTRIBUTING ("Releasing") requires that release to carry a real
hardware drill, recorded in $runs under a heading of exactly this shape:
## Release drill — $version — YYYY-MM-DD
The trailing date is optional; the version is matched WHOLE, so a record for
$version-rc1 (or for a different version entirely) does not count. The
section must have at least one non-blank line under it — a bare heading is
not a record.
Two ways to unblock, and both are a commit on this PR:
1. RUN THE DRILL and record it. What ran, on what hardware, the numbers,
and what failed. It is ONE orchestrated run over the whole stack, on
CANDIDATE refs (RIG_REPO/RIG_REF are mint-time variables, so the run
pins the commits under test) — drilling the candidate IS drilling the
release, since a release PR's diff is VERSION + CHANGELOG.md and nothing
executable differs. Cite the run ID and the other repos' SHAs.
2. RECORD AN EXPLICIT MAINTAINER WAIVER under the same heading, saying who
waived it and why. This guard asks for a RECORD, not a passing result,
so a deliberate skip is allowed — it just has to be visible and
reviewable rather than silent.
Do not delete this step to get green. A release that cannot say what was
drilled is the state this check exists to end.
EOF
} >&2
exit 1
fi
lines="$(printf '%s\n' "$record" | grep -c . || true)"
echo "drill-recorded: $runs records a drill for $version ($lines line(s) under the heading)."

View file

@ -77,6 +77,27 @@ jobs:
env:
CHANGELOG_MONOTONIC_STRICT: '1'
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
# The release this tree would ship has a recorded real-hardware drill
# (drill/RUNS.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.
#
# Deliberately NOT trigger-scoped, and for the opposite reason to the
# step above. That one needs a base ref, so its argument has to be right
# on both event types; this one reads two files in the checkout and is
# VACUOUS BY CONSTRUCTION on a `-dev` VERSION, which every ordinary PR
# and every push to main carries. It has something to say on exactly one
# tree — the `release: X.Y.Z` ceremony PR — so an `if:` could only add a
# way for that one tree to slip past.
#
# PER-REPO on purpose: rig reads rig's own record, never box's repo. A
# cross-repo lookup fails on a token, a network blip or a fork checkout,
# and every one of those lands on "could not read" — which degrades to
# green on precisely the tree that ships (the UNREADABLE-vs-NONE shape
# #90 fixed).
- name: a release version has a recorded drill
run: bash .github/scripts/drill-recorded.sh
# Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and
# stands up throwaway containers, and a slow image pull must never delay the

View file

@ -23,6 +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`
- `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)

View file

@ -92,7 +92,65 @@ Not an entry — that is a PR body:
## Releasing
A release is a PR, and merging it is the release (#47; box#96's design, on
top of #32/box#83's tag flow):
top of #32/box#83's tag flow). It takes the ordinary PR loop above, with one
extra gate before the handoff:
**draft → ready → bot round → drill → `state:needs-human` → maintainer merge
(which IS the release).**
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:
```
## Release drill — X.Y.Z — YYYY-MM-DD
```
`.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
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:
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 <tenant>-box` — the seed's cloud-init
curls rig's installer at `@RIG_REPO@/@RIG_REF@`
4. cast on top
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
the exact commits under test. That is what dissolves the chicken-and-egg: no
repo has to be released before another can be drilled.
**Drilling the candidate IS drilling the release.** A release PR's diff is
`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
cross-repo lookup anywhere in the gate. Releases do **not** have to be
published in a fixed order.
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,
precisely so that skipping is a deliberate, reviewable commit instead of a
silence. Deleting the check is not the move.
The mechanics:
1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps
`VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased

View file

@ -36,6 +36,26 @@ 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` 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
still not shippable because nothing says it was ever run on real hardware.
`.github/scripts/drill-recorded.sh` is the authority — the label just makes
the reason legible on the board, so a release PR sitting still reads as
"waiting on an afternoon of hardware", not as "forgotten". It only ever
appears on a `release` PR: every `-dev` tree satisfies the guard vacuously.
It is the one `blocker:*` the reconciler does **not** compute — its `BLOCKERS`
set is the three above — so it is applied by hand and, being outside that set,
is not stripped on the next sweep. (The red check itself still shows up as
`blocker:ci-red`; this label says *which* red.)
**A maintainer account must create this label.** The bot account 403s on label
creation, so until someone with push access runs the `gh label create` line
below, use plain `blocked` on such a PR — it carries the right meaning
(waiting on something else to happen first) and the staleness sweep already
exempts it.
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 +164,8 @@ 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. 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
# 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

80
drill/RUNS.md Normal file
View file

@ -0,0 +1,80 @@
# 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 <tenant>-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.

View file

@ -465,6 +465,171 @@ check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0
check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \
grep -qF "fetch-depth: 0" "$CIY"
# --- the drill rule: does the version being shipped have a record? ----------
# CONTRIBUTING ("Releasing") has always required a real-hardware drill and
# nothing enforced it, so no release in this family has ever carried one: every
# 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.
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 <name> <version> <runs-line...> -> prints the dir
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"
}
drill() { bash "$DRILL" "$1/RUNS.md" "$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"
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"
# 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"
check "drill: ...and the failure names the version" 1 "VERSION is 0.3.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
# than a RESULT: a waiver is allowed, it just cannot be silent.
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"
# 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"
# ...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"
# The version is matched WHOLE, both directions. 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"
# 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')"
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"
# 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"
# 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).
check "ci.yml: runs the drill guard" 0 "" grep -q "drill-recorded.sh" "$CIY"
# ...and is NOT trigger-gated. It is vacuous on every -dev tree already, so an
# `if:` could only ever exempt the one tree it exists for.
drill_step_block() {
awk '/^ - name: a release version has a recorded drill/ {f=1; print; next}
f && (/^ - / || /^ [^ ]/) {exit}
f {print}' "$CIY"
}
drill_step_gated() { drill_step_block | grep -q '^ if:'; }
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.
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"
# 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"
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
check "CONTRIBUTING: ...on candidate refs, which is what dissolves the recursion" 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.
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
check "CONTRIBUTING: ...and never claims a fixed box-then-rig release order" 1 "" \
bash -c 'grep -qi "box first, then rig" "$1"' _ "$CONTRIB"
# --- release.yml: the pins ---------------------------------------------------
# The workflow itself runs only on a tag push upstream, so pin its
# load-bearing pieces the way the harness pins root-only paths (repo