diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5490034..a765652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,12 @@ jobs: - uses: ./actions/changelog-armed with: changelog: CHANGELOG.scratch.md + # The same scratch tree exercises drill-recorded: 0.0.1-dev is a + # development tree, so the guard proves its wiring (action.yml, + # $GITHUB_ACTION_PATH, the lib sourcing) through the nothing-to-assert + # path — the state every consumer PR is in. The bare path is the test + # suite's job. + - uses: ./actions/drill-recorded - name: Construct a scratch history for the monotonic guard # The monotonic guard's input is a DIFF, so its exercise needs # history, not just a file: commit a scratch changelog, mark that diff --git a/actions/drill-recorded/action.yml b/actions/drill-recorded/action.yml new file mode 100644 index 0000000..b2e32ca --- /dev/null +++ b/actions/drill-recorded/action.yml @@ -0,0 +1,25 @@ +name: Drill recorded +description: >- + Assert a release tree carries its drill record — the release-evidence + gate, keyed on the tree's version (from box, where box#95, box#114 and + box#148 all shipped with no drill because the gate was only a sentence in + CONTRIBUTING). The caller must have checked out its own repository first: + the guard reads the consumer's tree at the workspace. +inputs: + version-source: + description: Where the tree's version lives ("file" or "package-json") + required: false + default: file + drills-dir: + description: Directory of drill records, relative to the workspace + required: false + default: drills +runs: + using: composite + steps: + - name: drill recorded + shell: bash + env: + DRILLS_DIR: ${{ inputs.drills-dir }} + VERSION_SOURCE: ${{ inputs.version-source }} + run: bash "$GITHUB_ACTION_PATH/drill-recorded.sh" diff --git a/actions/drill-recorded/drill-recorded.sh b/actions/drill-recorded/drill-recorded.sh new file mode 100644 index 0000000..f2076e9 --- /dev/null +++ b/actions/drill-recorded/drill-recorded.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +# drill-recorded.sh [] [] — assert that a +# RELEASE tree carries a drill record: that the ritual this repo says a +# release rests on was actually run for this version, and written down as +# /.md. +# +# Ported from box .github/scripts/drill-recorded.sh (the origin); rig and +# cast carry their own rephrasings of the same rule, and this action is the +# one copy they all converge on (#13–#16). +# +# Why it exists: box's CONTRIBUTING said since box#96 that "this PR is where +# the release ritual hangs: the full drill on real hardware, recorded". No +# release ever did it. box#95, box#114 and box#148 all shipped as a VERSION +# bump plus a CHANGELOG.md stamp and nothing else. That is three releases +# through the same gap, because the gate was a sentence in a document and +# the only thing standing on it was a reviewer remembering to ask. A +# reviewer finally did — which is the point: the ONE time it was caught is +# the time somebody happened to look, and that is not a gate, it is luck +# with good manners. So the rule moves into CI, where it fires on every +# release PR whether or not anyone is paying attention. +# +# It is keyed on the tree's version for the same reason changelog-armed is — +# the two states are genuinely different: +# +# version ends in -dev -> PASS. A development tree ships nothing, so +# there is nothing for it to have proven. Almost +# every PR in a consumer repo is this case, and +# a guard that nagged all of them would be +# turned off. +# version is bare -> the ceremony tree, the one about to ship. +# /.md MUST exist and carry +# at least one non-whitespace character. +# +# ONE FILE PER VERSION, and that is the whole design. Records used to be +# sections sharing one file, and every hard edge the old guard had existed +# only because of that sharing: em-dash field matching, an optional +# ' — DATE' tail, whole-version comparison so '0.9.0-rc1' could not satisfy +# '0.9.0', avoiding '\x' escapes because CI runs mawk not gawk, and a +# non-blank body rule to tell an empty section from a filled one. Two +# separate defects were found in review because of that complexity — a +# `sed '/./,$!d'` whitespace bypass, and heading-grammar drift from the +# sibling repos. Splitting the file makes almost all of it UNREPRESENTABLE: +# '0.9.0.md' and '0.9.0-rc1.md' are simply different files, so whole-version +# matching is free rather than a trap, and there is no grammar left to +# drift. +# +# The directory is plain 'drills', NOT '.drills'. A dot-directory is +# invisible to a glob without dotglob, which is exactly what caused box#116 +# and box#118; evidence a sweep cannot see is evidence that goes missing +# quietly. +# +# What this guard asserts is a RECORD, deliberately — not a passing drill. +# CI cannot run a consumer's drill: box's wants real hardware, a real Incus, +# and the better part of an hour. What CI can do is refuse to let a release +# claim a ritual it left no evidence of. That also leaves the maintainer +# waiver intact and honest: a release that must ship without a full drill +# records WHY in its own file, which is a deliberate, reviewable commit in +# the diff — rather than the silent skip that let box#95, box#114 and +# box#148 all ship unproven. +# +# What a drill MEANS is the consumer's business (box: the isolation +# contract; rig: convergence; cast: promotion; each names it in its own +# drills/README.md). This guard only ever reads the record file in the tree +# it runs in. +# +# A file of its own (not inlined in action.yml) so +# test/drill-recorded.test.sh can drive it against constructed trees for +# both states — the same discipline as the libs it sources. + +drills="${1:-${DRILLS_DIR:-drills}}" +version_source="${2:-${VERSION_SOURCE:-file}}" + +# The shared libs travel with this action: a consumer's +# `uses: heavy-duty/ceremony/actions/drill-recorded@` downloads this +# whole repository at that ref, so ../../lib is always present and always at +# the same ref — no checkout step, no version skew possible. +here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/version.sh +. "$here/../../lib/version.sh" + +# A missing or empty version source is an ERROR, never a silent pass. A +# guard that cannot read the version cannot know whether this tree is its +# business, and "could not tell" must not resolve to "allowed". version_read +# refuses loudly on its own; the wrapper line names the guard so a workflow +# log shows which check refused. +ver="$(version_read "$version_source")" || { + echo "drill-recorded: cannot read the version (version-source: $version_source)" >&2 + exit 1 +} + +# version_is_dev is the single definition of the -dev special case (#3): an +# rc is a pre-release, not a dev tree — it ships, so it needs its own record +# ('2.0.0-rc1.md'), which one-file-per-version makes just another path. +if version_is_dev "$ver"; then + # Nothing to assert, and saying so is the point: the operator reading a + # green log should be able to tell "the guard passed" from "the guard + # decided this tree was not its business". + echo "drill-recorded: version '$ver' is a development tree — nothing to assert; only ceremony trees ship" + exit 0 +fi + +record="$drills/$ver.md" + +# The one rule that survives the rewrite, and it survives because it was +# never really about heading parsing: a file of only spaces, tabs and +# newlines is NOT a record. The first cut of box's guard extracted with +# `sed '/./,$!d'`, where `.` matches a space — so a record whose body was +# one tab satisfied a guard that promised "at least one non-blank line". An +# evidence-free release for the price of an invisible character, on the one +# check whose entire job is to demand evidence. Existence alone is a weaker +# claim than `touch` can defeat, so existence alone is not the test. +if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then + cat >&2 < — a fixture tree with a VERSION file and no drills +# dir; cases add records themselves. +tree() { + mkdir -p "$TMP/$1" + printf '%s\n' "$2" >"$TMP/$1/VERSION" +} + +# pkg_tree — the same, package-json backend. +pkg_tree() { + mkdir -p "$TMP/$1" + printf '{ "name": "fixture", "version": "%s" }\n' "$2" >"$TMP/$1/package.json" +} + +# record — write drills/.md in the tree. +record() { + mkdir -p "$TMP/$1/drills" + printf '%s\n' "$3" >"$TMP/$1/drills/$2.md" +} + +# --- the -dev row: nothing to assert, and the log says why ------------------- + +tree dev-tree 0.9.1-dev +check "-dev tree with no drills dir at all passes" 0 "nothing to assert" \ + in_tree dev-tree +check "-dev pass names the version it keyed on" 0 "0.9.1-dev" in_tree dev-tree + +# --- the bare rows: the record file is the whole gate ------------------------ + +tree bare-recorded 0.9.0 +record bare-recorded 0.9.0 "Ran the drill on real hardware; all green." +check "bare + recorded drill passes" 0 "drills/0.9.0.md" in_tree bare-recorded + +tree bare-missing 0.9.0 +mkdir -p "$TMP/bare-missing/drills" +check "bare + missing record fails" 1 "drills/0.9.0.md" in_tree bare-missing +check "bare + missing record says the release is unproven" 1 "unproven" \ + in_tree bare-missing + +tree bare-no-dir 0.9.0 +check "bare + no drills dir at all fails the same way" 1 "drills/0.9.0.md" \ + in_tree bare-no-dir + +# The `sed '/./,$!d'` lesson: a record of only whitespace is not a record. +tree bare-blank 0.9.0 +mkdir -p "$TMP/bare-blank/drills" +printf ' \t\n\n' >"$TMP/bare-blank/drills/0.9.0.md" +check "bare + whitespace-only record fails" 1 "drills/0.9.0.md" in_tree bare-blank +check "blank-record failure is the same message family" 1 "unproven" \ + in_tree bare-blank + +# Prefix confusion is unrepresentable — '0.9.0-rc1.md' is a different path +# than '0.9.0.md' — and this row asserts it STAYS that way. +tree bare-rc-only 0.9.0 +record bare-rc-only 0.9.0-rc1 "The candidate's drill." +check "bare 0.9.0: an rc's record never satisfies it" 1 "drills/0.9.0.md" \ + in_tree bare-rc-only + +# An rc is a pre-release, not a dev tree (#3's version_is_dev): it ships, so +# it keys bare and wants its own record. +tree rc-recorded 1.0.0-rc1 +record rc-recorded 1.0.0-rc1 "Drilled the candidate." +check "rc keys as bare, its own record passes" 0 "drills/1.0.0-rc1.md" \ + in_tree rc-recorded + +tree rc-missing 1.0.0-rc1 +check "rc keys as bare, no record fails" 1 "drills/1.0.0-rc1.md" \ + in_tree rc-missing + +# --- degenerate version sources ---------------------------------------------- + +mkdir -p "$TMP/no-version" +check "missing version source fails" 1 "cannot read the version" \ + in_tree no-version + +mkdir -p "$TMP/empty-version" +printf '\n' >"$TMP/empty-version/VERSION" +check "empty version source fails" 1 "cannot read the version" \ + in_tree empty-version + +tree unknown-backend 0.9.0 +check "unknown version-source refused" 1 "unknown backend" \ + in_tree unknown-backend drills carrier-pigeon + +# --- the package-json backend ------------------------------------------------ + +pkg_tree pkg-recorded 0.3.0 +record pkg-recorded 0.3.0 "Promotion drill ran; notes attached." +check "package-json: bare + recorded passes" 0 "drills/0.3.0.md" \ + in_tree pkg-recorded drills package-json + +pkg_tree pkg-missing 0.3.0 +check "package-json: bare + missing record fails" 1 "drills/0.3.0.md" \ + in_tree pkg-missing drills package-json + +# --- a non-default drills dir ------------------------------------------------ + +tree alt-dir 0.9.0 +mkdir -p "$TMP/alt-dir/evidence" +printf 'Ran it.\n' >"$TMP/alt-dir/evidence/0.9.0.md" +check "a non-default drills-dir is honored" 0 "evidence/0.9.0.md" \ + in_tree alt-dir evidence + +# --- the action's wiring: inputs arrive as env vars -------------------------- + +tree env-tree 0.9.0 +mkdir -p "$TMP/env-tree/evidence" +printf 'Ran it.\n' >"$TMP/env-tree/evidence/0.9.0.md" +# A non-default drills dir proves the env var is honored, not the default. +env_tree() { + (cd "$TMP/env-tree" && DRILLS_DIR=evidence VERSION_SOURCE=file bash "$SCRIPT") +} +check "env vars drive the script the way action.yml does" 0 "evidence/0.9.0.md" \ + env_tree + +summary