cast/.github/scripts/drill-recorded.sh
dan-claude-bot 2578a570ca feat: CI refuses a release PR with no drill record
CONTRIBUTING has always asked for the full real-hardware drill on a
release. 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 reviewer bot finally blocked on it. The gate moves out of
memory and into the tree.

drill/RUNS.md is cast's own run log, starting empty: no fabricated
history, and an honest note that cast has no drill harness script yet —
its legs are run by the documented procedure. The file is the record, not
the instrument.

.github/scripts/drill-recorded.sh reads package.json and asserts that a
bare version has a non-empty '## Release drill — X.Y.Z' section. A -dev
tree has no ship claim and passes trivially. The version is matched
WHOLE via awk field equality, release-notes.sh's fix for the same trap:
0.2.0 is not satisfied by 0.2.0-rc1, or the reverse.

It requires a RECORD, not a PASS. A maintainer waiver is legal and is
itself a section in drill/RUNS.md, so skipping the drill stays possible
and stays a deliberate, reviewable commit rather than an oversight.

The drill itself is ONE orchestrated run over the whole stack: rig
bootstraps a bare host and installs box, box new mints a seed, the seed
calls rig back to converge, and cast's legs run on the result. rig sits
below box and above it, so the repos are mutually recursive rather than
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, and drilling the
candidate is drilling the release — a release diff is the version file
and CHANGELOG.md, nothing executable.

Each repo records its own legs from that run, citing the shared run ID
and the other repos' SHAs. cast never reads box's or rig's drill log to
decide whether cast may ship: a cross-repo lookup degrades to "pass" the
moment it fails to resolve — the unreadable-rollup class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:08 +00:00

134 lines
6.8 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
# drill-recorded.sh [<runs-file>] [<version-file>] — assert that the version
# this tree claims to ship has a DRILL RECORD in drill/RUNS.md.
#
# CONTRIBUTING says a release carries the full real-hardware drill. Nothing
# checked that, so no release in this family ever carried one: the step lived
# only in a reviewer's memory, and a step that lives in a reviewer's memory is
# performed exactly as often as the reviewer remembers it. A bot finally
# blocked on it, which is the first time the omission was visible at all. So
# the gate moves into CI, where it is asserted on every release PR rather than
# recalled.
#
# WHAT IT ASSERTS, AND WHAT IT DELIBERATELY DOES NOT
#
# It asserts a RECORD EXISTS — not that the drill passed. That is the whole
# design. A maintainer may ship on a failed or partial drill; what they may not
# do is ship on silence. Requiring a record makes a waiver a deliberate,
# reviewable commit (a section saying who waived it and what is untested)
# instead of the default outcome of forgetting. A guard that demanded a PASS
# would be argued with and eventually bypassed; one that demands EVIDENCE has
# nothing to argue about.
#
# PER-REPO, ON PURPOSE
#
# This reads cast's OWN drill/RUNS.md. It does not reach into box or rig to ask
# whether the family drilled. A cross-repo lookup has a failure mode this repo
# keeps refusing: when the fetch fails — no network, moved file, renamed repo,
# a token without read on the other repo — the honest answers are "unknown" and
# "blocked", but the shape such code actually takes degrades to "pass". Same
# class as the unreadable check rollup that read as "nothing is failing".
#
# There is also nothing to look up. The drill is ONE orchestrated run over the
# whole stack (CONTRIBUTING.md, "Releasing"): rig bootstraps the host and
# installs box, box mints a seed, the seed calls rig back to converge, and
# cast's legs run on the result. rig therefore sits BELOW box and ABOVE it —
# the three repos are mutually recursive, not linearly ordered, and their
# releases are NOT published in a fixed sequence. The run pins CANDIDATE refs
# (RIG_REPO/RIG_REF at mint time), so no repo must ship before another can be
# drilled. Each repo records its own legs from that run, citing the shared run
# ID and the other repos' SHAs — which is how three records reassemble into one
# run without any repo reading another's file.
#
# A file of its own, not a clause inlined in ci.yml, for the same reason as
# release-notes.sh and changelog-monotonic.sh: test/release.test.ts drives the
# REAL script against fixtures, so what the tests prove is what CI runs.
runs="${1:-drill/RUNS.md}"
version_file="${2:-package.json}"
[ "$#" -le 2 ] || { echo "usage: drill-recorded.sh [<runs-file>] [<version-file>]" >&2; exit 2; }
[ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2; exit 1; }
# cast's version lives in package.json (there is no VERSION file), so this
# reads JSON — with sed, not node. release-notes.sh takes the version as an
# ARGUMENT and so never had to; this one is invoked by CI with no arguments and
# has to find it itself. sed keeps the script runnable by `bash -n`, shellcheck
# and a bare shell alike, with no dependency on a toolchain being installed
# before the guard can speak. The first "version" key in package.json is the
# package's own by npm's schema; dependency entries are "<name>": "<range>"
# pairs and carry no "version" key to be confused with it.
ver="$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file" | head -1)"
[ -n "$ver" ] || { echo "drill-recorded: no \"version\" key in $version_file" >&2; exit 1; }
# A -dev tree is main between releases. Nothing ships from it, so there is no
# claim to evidence — and demanding a record here would make every ordinary
# feature PR red until somebody drilled for a version that will never be cut.
# The gate is about the SHIP CLAIM, and `-dev` is the absence of one.
case "$ver" in
*-dev)
echo "drill-recorded: version $ver is a development tree — nothing ships from it, so there is nothing to assert."
exit 0
;;
esac
# A bare version is a release ceremony tree: this is the tree whose merge IS
# the release, so this is where the evidence has to exist.
[ -f "$runs" ] || {
{
echo "drill-recorded: version $ver is a release, but there is no $runs at all."
echo
echo " The release drill has to be RECORDED, not just performed. Create the file"
echo " with the run's section (see the format below) and commit it."
} >&2
exit 1
}
# $5 of a drill heading ('## Release drill — 0.2.0 — 2026-07-21') is the bare
# version, compared WHOLE — so 0.2.0 can never be satisfied by a 0.2.0-rc1
# section, or the reverse, and no regex-escaping of dots. Exactly the trap
# release-notes.sh solves the same way, with the same awk field split, so the
# two cannot drift apart about what a heading is. The trailing ' — DATE' is
# optional and unread: the gate is about the version, and the date is for the
# humans reading the log.
#
# grab is re-armed by every '## ' line, so the section ends at the next one —
# a record cannot borrow the body of the record below it. 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))"