cast/.github/workflows/ci.yml
dan-claude-bot 2578a570ca feat: CI refuses a release PR with no drill record
CONTRIBUTING has always asked for the full real-hardware drill on a
release. 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 reviewer bot finally blocked on it. The gate moves out of
memory and into the tree.

drill/RUNS.md is cast's own run log, starting empty: no fabricated
history, and an honest note that cast has no drill harness script yet —
its legs are run by the documented procedure. The file is the record, not
the instrument.

.github/scripts/drill-recorded.sh reads package.json and asserts that a
bare version has a non-empty '## Release drill — X.Y.Z' section. A -dev
tree has no ship claim and passes trivially. The version is matched
WHOLE via awk field equality, release-notes.sh's fix for the same trap:
0.2.0 is not satisfied by 0.2.0-rc1, or the reverse.

It requires a RECORD, not a PASS. A maintainer waiver is legal and is
itself a section in drill/RUNS.md, so skipping the drill stays possible
and stays a deliberate, reviewable commit rather than an oversight.

The drill itself is ONE orchestrated run over the whole stack: rig
bootstraps a bare host and installs box, box new mints a seed, the seed
calls rig back to converge, and cast's legs run on the result. rig sits
below box and above it, so the repos are mutually recursive rather than
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, and drilling the
candidate is drilling the release — a release diff is the version file
and CHANGELOG.md, nothing executable.

Each repo records its own legs from that run, citing the shared run ID
and the other repos' SHAs. cast never reads box's or rig's drill log to
decide whether cast may ship: a cross-repo lookup degrades to "pass" the
moment it fails to resolve — the unreadable-rollup class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:08 +00:00

125 lines
6.2 KiB
YAML

name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# fetch-depth: 0, for the changelog-monotonic step below and only
# for it. That check is about a DIFF — which release headings the
# merge base had — so it needs the base branch's history present,
# and the default depth-1 checkout has none of it. An explicit
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# tree this size costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
# the secrets tests round-trip a real age identity
- run: sudo apt-get update && sudo apt-get install -y age
- run: npm ci
- run: npm run check
- run: npm run build
- run: npm test
- name: installer is valid bash
run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
# `bash -n` above is a syntax check on a hand-maintained list; it is not
# a linter and it does not notice a script it was never told about.
# This sweep derives its list from git and asserts the list covers every
# tracked *.sh, so a new script cannot go unlinted quietly (#118).
- name: shellcheck — every tracked shell script
run: bash .github/scripts/shellcheck-all.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
# ...and no SHIPPED release heading was deleted or DUPLICATED (#133;
# box#122's guard, box#143's ordering fix). Its own step so that when it
# goes red the log names the invariant that broke — and a DIFFERENT
# invariant from the arming rule npm test carries: arming is a fact
# about this tree, monotonicity is a fact about this tree versus its
# merge base. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
#
# NOT pull-request-only, and that is the #133 fix at the workflow level.
# The two halves have different vacuity: DELETION is vacuous on a push
# to main (the merge base IS HEAD), but DUPLICATION is vacuous on no
# tree at all, so gating the whole script on `pull_request` left a
# duplicate that reached main by any other route unasserted forever.
#
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
# push event `github.base_ref` is EMPTY, so the argument would collapse
# to a bare `origin/`, which does not resolve — and STRICT=1 correctly
# promotes that to a hard failure, turning every push to main red. With
# the fallback it resolves to the pushed branch, whose merge base with
# HEAD is HEAD or its parent: containment passes vacuously, exactly as
# the old `if` intended, while uniqueness now runs on every push.
- name: no shipped changelog heading was deleted or duplicated
env:
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
# assert), not a hand-built imitation of it. Box's CI installs box the
# same way. This is the one place the real npm ci + tsc build path runs
# end to end; the vitest installer tests cover the layout semantics
# offline with a shimmed npm.
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: install via install.sh, from this checkout
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
# assert what landed: the layout, the chain, and that it answers
readlink -f "$HOME/.local/bin/cast" | grep '/versions/'
"$HOME/.local/bin/cast" --version
"$HOME/.local/bin/cast" versions
- name: converging no-op — a re-run changes nothing and builds nothing
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh \
| tee /tmp/rerun.log
grep -q 'already installed' /tmp/rerun.log
- name: uninstall --all — ends with the absence assert
run: |
CAST_YES=1 "$HOME/.local/bin/cast" uninstall --all
test ! -e "$HOME/.local/share/cast"
test ! -e "$HOME/.local/bin/cast"
test ! -L "$HOME/.local/bin/cast"