feat: CI refuses a release PR with no drill record #138
7 changed files with 553 additions and 3 deletions
150
.github/scripts/drill-recorded.sh
vendored
Executable file
150
.github/scripts/drill-recorded.sh
vendored
Executable file
|
|
@ -0,0 +1,150 @@
|
|||
#!/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.
|
||||
#
|
||||
# `grab && NF` is the non-blank rule, and it is load-bearing rather than
|
||||
# tidiness. NF is 0 on a line that is empty OR contains only whitespace, so
|
||||
# `record` is non-empty exactly when a line with real content exists. The
|
||||
# first cut of this piped through `sed '/./,$!d'` and the comment here claimed
|
||||
# "a heading with nothing but whitespace under it extracts to the empty
|
||||
# string" — which is precisely what that pipeline did NOT guarantee, because
|
||||
# `.` matches a space. A heading followed by one tab satisfied the gate. The
|
||||
# comment documented the intended contract and the code did not meet it, which
|
||||
# on a gate is the whole ballgame: an evidence-free release for the price of an
|
||||
# invisible character. Found by all three reviewers on #138, independently.
|
||||
#
|
||||
# The '(NF == 5 || $6 == dash)' tail constraint keeps this in step with box's
|
||||
# twin: without it '## Release drill — 0.2.0 stray words' counts as a record
|
||||
# here and does not there. Two sibling guards disagreeing about what the same
|
||||
# heading means is the same trap as disagreeing with release-notes.sh.
|
||||
record="$(awk -v ver="$ver" -v dash="—" '
|
||||
/^## / {
|
||||
grab = ($2 == "Release" && $3 == "drill" && $4 == dash && $5 == ver \
|
||||
&& (NF == 5 || $6 == dash))
|
||||
next
|
||||
}
|
||||
grab && NF { print }
|
||||
' "$runs")"
|
||||
|
||||
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))"
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -69,6 +69,27 @@ jobs:
|
|||
CHANGELOG_MONOTONIC_STRICT: "1"
|
||||
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
|
||||
|
||||
# ...and a release carries its DRILL RECORD. CONTRIBUTING has always
|
||||
# asked for the real-hardware drill; 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 bot blocked on it.
|
||||
# Here it is a fact about the tree instead of a fact about somebody's
|
||||
# memory.
|
||||
#
|
||||
# No `if:` guard on the event or the label. The script keys off
|
||||
# package.json itself: a `-dev` tree has no ship claim and passes
|
||||
# trivially, a bare version is a release ceremony tree and must have a
|
||||
# record. Gating this step on the `release` label instead would put the
|
||||
# assert behind a hand-applied label — the guard would be absent from
|
||||
# exactly the PR that mislabels itself, and unasserted PRs are how the
|
||||
# drill went missing in the first place.
|
||||
#
|
||||
# It requires a RECORD, not a PASS: a maintainer waiver is legal, and is
|
||||
# itself a section in drill/RUNS.md. Skipping stays possible and stays
|
||||
# visible.
|
||||
- name: a release version has a drill record
|
||||
run: bash .github/scripts/drill-recorded.sh
|
||||
|
||||
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
|
||||
# this checkout, so CI proves the installer under review (the versioned
|
||||
# layout, the current symlink, the PATH chain, the uninstall's absence
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ actually cutting it, and this file starts there.
|
|||
|
||||
### Added
|
||||
|
||||
- CI refuses a release PR with no drill record in `drill/RUNS.md`
|
||||
- An application can declare HTTP basic auth, and `apply` sets it (#76)
|
||||
- `cast github-app create` / `cast github-app register` run the App Manifest
|
||||
flow (#7)
|
||||
|
|
|
|||
|
|
@ -108,13 +108,72 @@ on box#83's shape):
|
|||
re-armed `## Unreleased`, but a `## X.Y.Z` section for the version you
|
||||
are shipping **must exist and extract non-empty** — a bump without a
|
||||
stamp is red here rather than after the merge, in release.yml;
|
||||
- the moment step 3's `-dev` bump lands, the top section must be
|
||||
- the moment step 4's `-dev` bump lands, the top section must be
|
||||
`## Unreleased` or CI is red.
|
||||
|
||||
The empty `## Unreleased` this step adds is deliberately tolerated: what
|
||||
must extract non-empty is the section that SHIPS, not the top one. CI
|
||||
green on it, same loop as any PR.
|
||||
2. **Merge. That's the ship decision — nothing else to do.**
|
||||
2. **Drill, and record it.** Before the PR can be handed over, run the full
|
||||
real-hardware drill — two live Coolify instances, the whole A→B promotion:
|
||||
team, apply, an idempotent diff, smoke, inventory, emit-draft, fleet,
|
||||
destroy, and the read-only guard — and record it in
|
||||
[drill/RUNS.md](drill/RUNS.md) under a heading naming the version:
|
||||
|
||||
## Release drill — X.Y.Z — YYYY-MM-DD
|
||||
|
||||
[.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh)
|
||||
enforces this on every release PR (a `-dev` tree has no ship claim and
|
||||
passes trivially). It is **not a thing a reviewer has to remember** — that
|
||||
is precisely how every release in this family shipped without one until a
|
||||
bot blocked on it.
|
||||
|
||||
So the release flow is: **draft → ready → bot round → drill →
|
||||
`state:needs-human` → maintainer merge (which IS the release).**
|
||||
|
||||
**The drill is ONE orchestrated run over the whole stack**, not three
|
||||
independent exercises. In order:
|
||||
|
||||
1. `rig bootstrap … --host yes` on a bare Debian host — this installs box
|
||||
and runs box's `setup-host`;
|
||||
2. `box new` mints a creds-free seed;
|
||||
3. the seed converges on first boot: its cloud-init curls rig's installer
|
||||
and runs `rig bootstrap <tenant>-box`;
|
||||
4. cast's legs on top of the converged result — two live Coolify
|
||||
instances and the full A→B promotion.
|
||||
|
||||
Note that rig appears **twice**, below box and above it. box and rig are
|
||||
mutually recursive, not linearly ordered: rig builds the host that runs
|
||||
box, and box's seed calls rig back to converge the guest. cast sits on top
|
||||
of whatever that produces. So there is no "previous repo" for a repo to
|
||||
drill against, and **no fixed order in which the three releases must be
|
||||
published.**
|
||||
|
||||
The run drills **candidate refs, not released artifacts**. `RIG_REPO` and
|
||||
`RIG_REF` are mint-time environment variables (default
|
||||
`heavy-duty/rig@main`), so a run pins the exact commits under test. That
|
||||
dissolves the chicken-and-egg the mutual recursion would otherwise create:
|
||||
no repo has to be released before another can be drilled.
|
||||
|
||||
Drilling the candidate **is** drilling the release. A release PR's diff is
|
||||
the version file and `CHANGELOG.md` — nothing executable differs between
|
||||
the tree that was drilled and the tree that ships, so the evidence carries
|
||||
across the ceremony commit.
|
||||
|
||||
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 — which is what lets three separate records
|
||||
be reassembled into the single run they came from. The guard still reads
|
||||
only this repo's file: cast never queries box's or rig's drill log to
|
||||
decide whether cast may ship, because a cross-repo lookup degrades to
|
||||
"pass" the moment it fails to resolve — the unreadable-rollup bug wearing
|
||||
a different hat.
|
||||
|
||||
A maintainer **waiver** is possible — but it must be RECORDED in
|
||||
`drill/RUNS.md` for that version, saying who waived it and what is
|
||||
untested. The guard requires a *record*, not a passing result, so skipping
|
||||
the drill stays possible and stays visible and deliberate.
|
||||
3. **Merge. That's the ship decision — nothing else to do.**
|
||||
[release.yml](.github/workflows/release.yml) fires on the merged,
|
||||
`release`-labeled PR and asserts, in order, each fail-loud and creating
|
||||
nothing: the merged version is non-`-dev`; the version *changed in this
|
||||
|
|
@ -131,7 +190,7 @@ on box#83's shape):
|
|||
*Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge
|
||||
commit yourself — the same workflow runs the same asserts, build, and
|
||||
publish from the tag.
|
||||
3. **The release re-arms main itself**: the same workflow run bumps
|
||||
4. **The release re-arms main itself**: the same workflow run bumps
|
||||
`package.json` (and `package-lock.json`) to `X.Y.(Z+1)-dev` and pushes
|
||||
the commit straight to main — no follow-up PR (it opens one only if
|
||||
branch protection refuses the direct push, and says so loudly).
|
||||
|
|
|
|||
17
LABELS.md
17
LABELS.md
|
|
@ -36,6 +36,20 @@ 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 record (or a recorded maintainer waiver) lands for that version |
|
||||
|
||||
`blocker:drill-pending` is the only blocker that says nothing is *wrong* with
|
||||
the code. The version bump, the stamp, the re-arm can all be perfect; what is
|
||||
missing is the evidence that anyone ran the real-hardware drill against the
|
||||
tree about to ship. It clears on a RECORD, not on a pass — a recorded waiver
|
||||
clears it too, which is the point: skipping the drill stays possible and stays
|
||||
visible. `.github/scripts/drill-recorded.sh` is what turns CI red meanwhile.
|
||||
|
||||
**The label does not exist in the repo yet.** Creating a label needs push
|
||||
access and the bot account gets a 403, so a maintainer account has to run the
|
||||
`gh label create` line below (or the workflow's manual dispatch) once. Until
|
||||
then the reconciler cannot apply it, and plain `blocked` stands in — coarser,
|
||||
but it keeps an unevidenced release off the merge path, which is the job.
|
||||
|
||||
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 +158,9 @@ 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, and until this
|
||||
# runs the reconciler cannot apply it and `blocked` stands in.
|
||||
gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record in drill/RUNS.md — ceremony correct, 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
|
||||
|
|
|
|||
94
drill/RUNS.md
Normal file
94
drill/RUNS.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Drill runs
|
||||
|
||||
The log of cast's **real-hardware drill legs** — one section per run, appended.
|
||||
A release PR's version must have a section here before CI will let it merge
|
||||
(`.github/scripts/drill-recorded.sh`, wired into ci.yml).
|
||||
|
||||
**This file is the record, not the instrument.** cast has **no drill harness
|
||||
script of its own yet**. Its legs are run by hand, against the documented
|
||||
procedure: two live Coolify instances and the full A→B promotion —
|
||||
|
||||
team → apply → diff (idempotent) → smoke → inventory → emit-draft →
|
||||
fleet → destroy → read-only guard
|
||||
|
||||
A harness would make the run reproducible; it would not make it recorded. Those
|
||||
are separate problems, and this file is the second one. When cast grows a
|
||||
harness, its output gets pasted into a section here in the same format — the
|
||||
gate does not change.
|
||||
|
||||
## Per-repo, by construction
|
||||
|
||||
cast records **cast's own** legs. It does not read box's or rig's drill log to
|
||||
decide whether cast may ship: a cross-repo lookup silently degrades to "pass"
|
||||
the moment it fails to resolve — the unreadable-rollup class of bug, where a
|
||||
guard that cannot read its input reports the happy answer.
|
||||
|
||||
The legs above are the **top of one orchestrated run over the whole stack**
|
||||
(CONTRIBUTING.md, *Releasing*): `rig bootstrap --host yes` on bare Debian
|
||||
installs box and runs `setup-host`; `box new` mints a seed; the seed converges
|
||||
by calling `rig bootstrap <tenant>-box`; cast's legs run on the result. rig
|
||||
appears twice — below box and above it — so the three repos are mutually
|
||||
recursive, not linearly ordered, and their releases are **not** published in a
|
||||
fixed order.
|
||||
|
||||
The run drills candidate refs rather than released artifacts (`RIG_REPO` /
|
||||
`RIG_REF` are mint-time variables, default `heavy-duty/rig@main`), which is
|
||||
what makes that possible: no repo has to ship before another can be drilled.
|
||||
A record therefore cites the shared **run ID** and the other two repos' commit
|
||||
SHAs — three records, one run, reassemblable — while each repo's evidence
|
||||
still lives in its own tree.
|
||||
|
||||
## Format
|
||||
|
||||
The gate looks for a heading of exactly this shape, and requires the section
|
||||
under it to be non-empty:
|
||||
|
||||
## Release drill — X.Y.Z — YYYY-MM-DD
|
||||
|
||||
The version is matched **whole**: a `0.2.0` release is not satisfied by a
|
||||
`0.2.0-rc1` section, or the other way round.
|
||||
|
||||
A record says three things: **what ran**, **the numbers**, and **what failed**.
|
||||
"Nothing failed" is a finding and is worth a line; an empty section is not a
|
||||
record and the gate refuses it.
|
||||
|
||||
### Example of the shape
|
||||
|
||||
Illustrative only — no run has happened yet, and the placeholder version keeps
|
||||
this block from ever satisfying the gate for a real release.
|
||||
|
||||
## Release drill — X.Y.Z — YYYY-MM-DD
|
||||
|
||||
Run ID: `drill-YYYYMMDD-NN` (the stack run these legs are the top of).
|
||||
Stack under test: box `<sha>`, rig `<sha>`, cast `<sha>` — candidate
|
||||
refs, pinned at mint time via RIG_REPO/RIG_REF.
|
||||
Instances: A `coolify-a.example` (v4.x), B `coolify-b.example` (v4.x).
|
||||
Manifest: `examples/two-env.yaml`, 3 applications, 2 environments.
|
||||
Operator: @maintainer. Elapsed: 41m.
|
||||
|
||||
| Leg | Result | Notes |
|
||||
|---|---|---|
|
||||
| team | pass | 2 teams, 4 members reconciled |
|
||||
| apply (A→B) | pass | 3 apps created, 11 env vars set |
|
||||
| diff (idempotent) | pass | second apply: 0 changes |
|
||||
| smoke | pass | 3/3 endpoints 200 |
|
||||
| inventory | pass | 3 apps, 2 envs, matches manifest |
|
||||
| emit-draft | pass | draft matches inventory round-trip |
|
||||
| fleet | pass | both instances listed, versions read |
|
||||
| destroy | pass | 3 apps removed, absence asserted |
|
||||
| read-only guard | pass | write refused against B with the guard on |
|
||||
|
||||
Failures: none. One rough edge: `smoke` needed a 20s retry window on B —
|
||||
filed as #NNN, not a release blocker.
|
||||
|
||||
## Waivers
|
||||
|
||||
A maintainer may ship without a passing drill, but **not without a record**.
|
||||
The waiver is a section under the same heading saying who waived it, why, and
|
||||
what is untested — a deliberate, reviewable commit. The gate requires a record,
|
||||
not a pass, precisely so that skipping is visible.
|
||||
|
||||
## Records
|
||||
|
||||
*None yet.* cast has recorded no drill runs. New runs are appended below,
|
||||
newest first.
|
||||
|
|
@ -26,6 +26,7 @@ import { tmp } from "./helpers/tmp.js";
|
|||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const NOTES = join(ROOT, ".github/scripts/release-notes.sh");
|
||||
const MONOTONIC = join(ROOT, ".github/scripts/changelog-monotonic.sh");
|
||||
const DRILL = join(ROOT, ".github/scripts/drill-recorded.sh");
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
|
|
@ -728,6 +729,213 @@ describe("changelog-monotonic.sh — release headings are append-only (#133)", (
|
|||
});
|
||||
});
|
||||
|
||||
// --- a release carries its DRILL RECORD -------------------------------------
|
||||
// CONTRIBUTING has always asked for the full real-hardware drill on a release
|
||||
// PR; nothing asserted it, so no release in the family ever carried one. The
|
||||
// gate moves the requirement out of a reviewer's memory and into the tree.
|
||||
//
|
||||
// What it asserts is that a RECORD EXISTS, never that the drill passed — a
|
||||
// maintainer waiver is legal and is itself a section in drill/RUNS.md. That is
|
||||
// the point: skipping stays possible and stays a deliberate, reviewable
|
||||
// commit. So the cases below are all about presence, emptiness, and whether
|
||||
// the version was matched whole; none of them inspect a result.
|
||||
//
|
||||
// Every fixture carries its OWN package.json and RUNS.md inside the temp dir.
|
||||
// Pointing the guard at the repo's real package.json would make these cases
|
||||
// change meaning on every version bump — green or red depending on where the
|
||||
// ceremony happens to be standing, which is the opposite of a fixture.
|
||||
|
||||
describe("drill-recorded.sh — a release version has a drill record", () => {
|
||||
const work = tmp("cast-drill-");
|
||||
|
||||
/** A fixture tree: its own package.json + drill runs file. */
|
||||
function treeWith(name: string, version: string, runs?: string): string {
|
||||
const dir = join(work, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
`${JSON.stringify({ name: "cast", version }, null, 2)}\n`,
|
||||
);
|
||||
if (runs !== undefined) writeFileSync(join(dir, "RUNS.md"), runs);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const check = (dir: string) =>
|
||||
run("bash", [DRILL, "RUNS.md", "package.json"], {}, dir);
|
||||
|
||||
const heading = (v: string) => `## Release drill — ${v} — 2026-07-21`;
|
||||
const legs =
|
||||
"\nInstances: A and B, live. All legs pass: team, apply, diff, smoke,\ninventory, emit-draft, fleet, destroy, read-only guard.\n";
|
||||
|
||||
it("a -dev tree passes with no drill record at all — nothing ships from it", async () => {
|
||||
const r = await check(treeWith("dev", "0.1.2-dev"));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toContain("development tree");
|
||||
});
|
||||
|
||||
it("a bare version with a matching, non-empty record passes", async () => {
|
||||
const runs = `# Drill runs\n\n${heading("0.2.0")}\n${legs}`;
|
||||
const r = await check(treeWith("ok", "0.2.0", runs));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.output).toContain("0.2.0");
|
||||
});
|
||||
|
||||
it("a bare version with NO record fails, naming the version", async () => {
|
||||
const runs = "# Drill runs\n\n*None yet.*\n";
|
||||
const r = await check(treeWith("none", "0.2.0", runs));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no drill record for version '0.2.0'");
|
||||
});
|
||||
|
||||
// A heading with nothing under it is ceremony without evidence — the exact
|
||||
// shape a hurried release produces, and the one a heading-only check would
|
||||
// wave through. release-notes.sh refuses an empty section for the same
|
||||
// reason.
|
||||
it("a present-but-EMPTY record fails — a heading is not a record", async () => {
|
||||
const runs = `# Drill runs\n\n${heading("0.2.0")}\n\n\n## Notes\n\nUnrelated.\n`;
|
||||
const r = await check(treeWith("empty", "0.2.0", runs));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no drill record for version '0.2.0'");
|
||||
});
|
||||
|
||||
// ...and whitespace is not evidence either. The first cut extracted with
|
||||
// `sed '/./,$!d'`, where `.` matches a space, so a heading followed by one
|
||||
// tab passed the gate — while the comment above the extractor claimed the
|
||||
// opposite. An evidence-free release for the price of an invisible
|
||||
// character, on the one check whose whole job is to demand evidence.
|
||||
// Found independently by all three reviewers on #138.
|
||||
it("a record body of only spaces and tabs fails (#138)", async () => {
|
||||
const runs = `# Drill runs\n\n${heading("0.2.0")}\n \n\t\n\n## Notes\n\nUnrelated.\n`;
|
||||
const r = await check(treeWith("blank", "0.2.0", runs));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no drill record for version '0.2.0'");
|
||||
});
|
||||
|
||||
// The sibling guards must agree about what a heading IS, not just about the
|
||||
// version in it. box#149 requires the version to be the last field or be
|
||||
// followed by the em dash; without the same constraint here, this heading is
|
||||
// a record in cast and not in box.
|
||||
it("a stray tail after the version is not a heading — in step with box#149", async () => {
|
||||
const runs = `# Drill runs\n\n## Release drill — 0.2.0 stray words\n${legs}`;
|
||||
const r = await check(treeWith("tail", "0.2.0", runs));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no drill record for version '0.2.0'");
|
||||
});
|
||||
|
||||
// The version is matched WHOLE, both directions — release-notes.sh's trap,
|
||||
// solved the same way (awk field equality, no regex, no dot-escaping). A
|
||||
// release candidate's drill is not the release's drill: different tree,
|
||||
// different build, and a prefix match would silently accept it.
|
||||
it("0.2.0-rc1's record does NOT satisfy 0.2.0", async () => {
|
||||
const runs = `# Drill runs\n\n${heading("0.2.0-rc1")}\n${legs}`;
|
||||
const r = await check(treeWith("rc-for-bare", "0.2.0", runs));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no drill record for version '0.2.0'");
|
||||
});
|
||||
|
||||
it("...and 0.2.0's record does NOT satisfy 0.2.0-rc1", async () => {
|
||||
const runs = `# Drill runs\n\n${heading("0.2.0")}\n${legs}`;
|
||||
const r = await check(treeWith("bare-for-rc", "0.2.0-rc1", runs));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no drill record for version '0.2.0-rc1'");
|
||||
});
|
||||
|
||||
it("...while each still matches its own record", async () => {
|
||||
const runs = `# Drill runs\n\n${heading("0.2.0")}\n${legs}\n${heading("0.2.0-rc1")}\n${legs}`;
|
||||
expect((await check(treeWith("both-a", "0.2.0", runs))).code).toBe(0);
|
||||
expect((await check(treeWith("both-b", "0.2.0-rc1", runs))).code).toBe(0);
|
||||
});
|
||||
|
||||
// The message is the whole user interface of a blocking guard. A failure
|
||||
// that names the problem without naming the way out gets bypassed rather
|
||||
// than satisfied — including the waiver, which must be visibly ALLOWED or
|
||||
// somebody will route around the gate instead of recording one.
|
||||
it("the failure names the unblock: run the drill, or record a waiver", async () => {
|
||||
const r = await check(treeWith("unblock", "0.2.0", "# Drill runs\n"));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("## Release drill — 0.2.0");
|
||||
expect(r.output).toContain("run the drill and record it");
|
||||
expect(r.output).toContain("WAIVER");
|
||||
expect(r.output).toContain("RECORD, not a");
|
||||
});
|
||||
|
||||
it("a missing runs file on a release tree refuses — never a silent pass", async () => {
|
||||
const r = await check(treeWith("norunsfile", "0.2.0"));
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no RUNS.md at all");
|
||||
});
|
||||
|
||||
it("a missing version file refuses by path", async () => {
|
||||
const r = await run("bash", [DRILL, "RUNS.md", "nope.json"], {}, work);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.output).toContain("no such file");
|
||||
});
|
||||
|
||||
it("too many arguments is a usage error", async () => {
|
||||
const r = await run("bash", [DRILL, "a", "b", "c"], {}, work);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.output).toContain("usage:");
|
||||
});
|
||||
|
||||
// The REAL tree, run with the REAL defaults — the same discipline as the
|
||||
// arming rule's "the REAL tree is armed". Whatever state the ceremony is in,
|
||||
// this repo must satisfy its own gate.
|
||||
it("the real tree satisfies its own gate, with default arguments", async () => {
|
||||
const r = await run("bash", [DRILL], {}, ROOT);
|
||||
expect(r.code).toBe(0);
|
||||
});
|
||||
|
||||
it("drill/RUNS.md documents the heading format the gate parses", () => {
|
||||
const runs = readFileSync(join(ROOT, "drill/RUNS.md"), "utf8");
|
||||
expect(runs).toContain("## Release drill — X.Y.Z — YYYY-MM-DD");
|
||||
// Per-repo by construction: cast records cast's legs and never reads
|
||||
// another repo's log to decide whether cast may ship.
|
||||
expect(runs).toMatch(/waiver/i);
|
||||
});
|
||||
|
||||
it("ci.yml runs it, ungated by event or label", () => {
|
||||
const CI = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8");
|
||||
expect(CI).toContain(".github/scripts/drill-recorded.sh");
|
||||
// Scoped to the step's own block (the monotonic assert above explains the
|
||||
// bounding): an `if:` here would put the guard behind a hand-applied
|
||||
// label, absent from exactly the PR that mislabels itself.
|
||||
const lines = CI.split("\n");
|
||||
const start = lines.findIndex((l) =>
|
||||
/^ {6}- name: a release version has a drill record/.test(l),
|
||||
);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
const after = lines.slice(start + 1);
|
||||
const end = after.findIndex((l) => /^ {6}- /.test(l) || /^ {2}\S/.test(l));
|
||||
const block = [
|
||||
lines[start],
|
||||
...after.slice(0, end < 0 ? after.length : end),
|
||||
].join("\n");
|
||||
expect(block).toContain("drill-recorded.sh");
|
||||
expect(block).not.toMatch(/^ {8}if:/m);
|
||||
});
|
||||
|
||||
it("CONTRIBUTING documents the gate and the ONE-RUN stack drill", () => {
|
||||
const doc = readFileSync(join(ROOT, "CONTRIBUTING.md"), "utf8");
|
||||
expect(doc).toContain("drill/RUNS.md");
|
||||
expect(doc).toContain("drill-recorded.sh");
|
||||
// One orchestrated run over the whole stack, in order: rig builds the
|
||||
// host (installing box), box mints a seed, the seed calls rig back to
|
||||
// converge, cast's legs run on the result.
|
||||
expect(doc).toContain("--host yes");
|
||||
expect(doc).toContain("box new");
|
||||
expect(doc).toContain("rig bootstrap <tenant>-box");
|
||||
// rig sits BELOW box and ABOVE it — mutually recursive, not linear. So
|
||||
// the docs must not promise a fixed release order, and must say the run
|
||||
// pins CANDIDATE refs, which is what dissolves the chicken-and-egg.
|
||||
expect(doc).toMatch(/mutually recursive/);
|
||||
expect(doc).toContain("RIG_REF");
|
||||
expect(doc).toMatch(/candidate refs, not released artifacts/);
|
||||
expect(doc).toMatch(/no fixed order|not.*published in a fixed order/i);
|
||||
// Three records, one run: each repo cites the shared run ID.
|
||||
expect(doc).toMatch(/run ID/i);
|
||||
});
|
||||
});
|
||||
|
||||
// --- release.yml — the wiring, pinned --------------------------------------
|
||||
// The workflow itself only runs on a tag push upstream, so its load-bearing
|
||||
// pieces are pinned here, fail-closed (the house discipline: the labels
|
||||
|
|
|
|||
Loading…
Reference in a new issue