From 77a9a1ad76cba65959fb1b282caa22817e161750 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Tue, 21 Jul 2026 15:24:42 +0000 Subject: [PATCH 1/2] feat: CI refuses a release PR with no drill record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING has always required a real-hardware drill on a release, and nothing enforced it — so no release in this family has ever carried one. Every other ceremony step is checked by a script; the one that costs an afternoon was checked by a reviewer remembering. A reviewer bot finally blocked on it. - drill/RUNS.md: rig's own run log, starting EMPTY of records. rig has no drill harness of its own yet; the harness lives in box's drill/ and this file is the record, not the instrument. - .github/scripts/drill-recorded.sh: a -dev tree asserts nothing; a bare VERSION requires a non-empty '## Release drill — X.Y.Z' section, version matched WHOLE so an -rc1 record is not evidence for the final. - Per-repo on purpose. A cross-repo lookup into box fails on a token, a fork checkout or a network blip, and all of those degrade to 'pass' on precisely the tree that ships — the UNREADABLE-vs-NONE shape #90 fixed. - It asks for a RECORD, not a RESULT, so a maintainer waiver stays possible but has to be written down under that version. - Fixtures carry their own VERSION and RUNS.md (heavy-duty/box#146: fixtures reading the repo's real VERSION exercised only the -dev branch and went red first while cutting a release). Co-Authored-By: Claude Opus 4.8 --- .github/scripts/drill-recorded.sh | 158 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 21 ++++ CHANGELOG.md | 1 + CONTRIBUTING.md | 60 +++++++++++- LABELS.md | 22 +++++ drill/RUNS.md | 80 +++++++++++++++ test/release.sh | 150 ++++++++++++++++++++++++++++ 7 files changed, 491 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/drill-recorded.sh create mode 100644 drill/RUNS.md diff --git a/.github/scripts/drill-recorded.sh b/.github/scripts/drill-recorded.sh new file mode 100755 index 0000000..1eb36ec --- /dev/null +++ b/.github/scripts/drill-recorded.sh @@ -0,0 +1,158 @@ +#!/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. +# +# 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 — 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" +} + +# 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 <&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)." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1e624f..ae79a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c14f577..a64601b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e61ea76..4943424 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 -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 diff --git a/LABELS.md b/LABELS.md index a9d2408..970a51a 100644 --- a/LABELS.md +++ b/LABELS.md @@ -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 diff --git a/drill/RUNS.md b/drill/RUNS.md new file mode 100644 index 0000000..e1877b2 --- /dev/null +++ b/drill/RUNS.md @@ -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 -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/test/release.sh b/test/release.sh index df95703..5dfdb08 100644 --- a/test/release.sh +++ b/test/release.sh @@ -465,6 +465,156 @@ 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 -> 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" + +# 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 -- 2.45.2 From b234f48e68a5123acdf298190b5d882dc1ac3010 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Tue, 21 Jul 2026 15:59:57 +0000 Subject: [PATCH 2/2] test: pin that whitespace is not a drill record --- test/release.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/release.sh b/test/release.sh index 5dfdb08..79def52 100644 --- a/test/release.sh +++ b/test/release.sh @@ -537,6 +537,21 @@ check "drill: a PRESENT but EMPTY record FAILS" 1 "NO drill record" drill "$T" 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 -- 2.45.2