Merge pull request #42 from claude-bot-andresmgsl/build/7-drill-recorded
feat: actions/drill-recorded — a release carries its evidence
This commit is contained in:
commit
91863259f9
4 changed files with 312 additions and 0 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -72,6 +72,12 @@ jobs:
|
||||||
- uses: ./actions/changelog-armed
|
- uses: ./actions/changelog-armed
|
||||||
with:
|
with:
|
||||||
changelog: CHANGELOG.scratch.md
|
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
|
- name: Construct a scratch history for the monotonic guard
|
||||||
# The monotonic guard's input is a DIFF, so its exercise needs
|
# The monotonic guard's input is a DIFF, so its exercise needs
|
||||||
# history, not just a file: commit a scratch changelog, mark that
|
# history, not just a file: commit a scratch changelog, mark that
|
||||||
|
|
|
||||||
25
actions/drill-recorded/action.yml
Normal file
25
actions/drill-recorded/action.yml
Normal file
|
|
@ -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"
|
||||||
141
actions/drill-recorded/drill-recorded.sh
Normal file
141
actions/drill-recorded/drill-recorded.sh
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# drill-recorded.sh [<drills-dir>] [<version-source>] — 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
|
||||||
|
# <drills-dir>/<version>.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.
|
||||||
|
# <drills-dir>/<version>.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@<tag>` 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 <<EOF
|
||||||
|
drill-recorded: the version is '$ver' — a release — and there is no drill
|
||||||
|
record for it. The file this looks for is:
|
||||||
|
|
||||||
|
$record
|
||||||
|
|
||||||
|
...with something written in it. Either the file is absent entirely, or it
|
||||||
|
is present and blank; both mean the same thing, which is that this release
|
||||||
|
is asserting a ritual it has left no evidence of — this release is
|
||||||
|
unproven.
|
||||||
|
|
||||||
|
The unblock is to RUN THE DRILL and record it at that path — what it
|
||||||
|
measured, what it found, what it cost. See $drills/README.md for what a
|
||||||
|
drill means in this repo and what a record should contain. CI cannot run
|
||||||
|
the drill for you; it can only refuse a release that never ran one.
|
||||||
|
|
||||||
|
If this release must ship without a full drill, that is a maintainer's
|
||||||
|
call to make and it is still recorded: create the same file and say
|
||||||
|
plainly that the drill was WAIVED and why. The guard requires a record,
|
||||||
|
not a passing result — a failed drill honestly written down satisfies it
|
||||||
|
too — so a skip is a visible, reviewable file in the diff rather than the
|
||||||
|
silent gap that let box#95, box#114 and box#148 all ship unproven.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "drill-recorded: version '$ver' has a drill record at $record"
|
||||||
140
test/drill-recorded.test.sh
Normal file
140
test/drill-recorded.test.sh
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contract tests for actions/drill-recorded (issue #7). Constructed fixture
|
||||||
|
# trees — a dir with a drills/ directory plus a VERSION file or
|
||||||
|
# package.json, not git repos — the same discipline as the box suite this
|
||||||
|
# guard is ported from. set -u, not -e: failing commands are behavior for
|
||||||
|
# the harness to inspect.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=test/harness.sh
|
||||||
|
. "$ROOT/test/harness.sh"
|
||||||
|
|
||||||
|
SCRIPT="$ROOT/actions/drill-recorded/drill-recorded.sh"
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# The guard reads the consumer's tree at its working directory, so every
|
||||||
|
# case runs from inside a constructed fixture tree.
|
||||||
|
in_tree() {
|
||||||
|
local dir="$1"
|
||||||
|
shift
|
||||||
|
(cd "$TMP/$dir" && bash "$SCRIPT" "$@")
|
||||||
|
}
|
||||||
|
|
||||||
|
# tree <name> <version> — 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 <name> <version> — the same, package-json backend.
|
||||||
|
pkg_tree() {
|
||||||
|
mkdir -p "$TMP/$1"
|
||||||
|
printf '{ "name": "fixture", "version": "%s" }\n' "$2" >"$TMP/$1/package.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# record <tree> <version> <body> — write drills/<version>.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
|
||||||
Loading…
Reference in a new issue