feat: CI refuses a release PR with no drill record #102
7 changed files with 491 additions and 1 deletions
158
.github/scripts/drill-recorded.sh
vendored
Executable file
158
.github/scripts/drill-recorded.sh
vendored
Executable 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)."
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
LABELS.md
22
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
|
||||
|
|
|
|||
80
drill/RUNS.md
Normal file
80
drill/RUNS.md
Normal 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.
|
||||
150
test/release.sh
150
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 <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"
|
||||
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue