Drill records move from sections inside drill/RUNS.md to one file per
version: drills/<version>.md. The old guard parsed headings — em-dash
field matching, an optional ' — DATE' tail, a whole-version comparison
so 0.2.0-rc1 could not satisfy 0.2.0, a '(NF == 5 || $6 == dash)' tail
constraint to match box, and a non-blank body rule.
All of that existed only because records shared one file, and this repo
shipped two defects out of the complexity in review: the sed '/./,$!d'
whitespace bypass, and heading-grammar drift from box's stricter form.
One file per version makes nearly all of it unrepresentable — 0.2.0.md
and 0.2.0-rc1.md are simply different files, so the whole-version rule
is the filesystem's rather than a comparison anyone can get wrong.
One rule survives: a file of only whitespace is not a record.
Plain drills/, not .drills/ — dot-directories are invisible to globs
without dotglob, the cause of #118/#121 here and box#116.
drill/RUNS.md is deleted. It was created in this same unmerged PR and
held only format documentation, no real records; the useful reasoning
moves to drills/README.md. (box keeps ITS drill/RUNS.md, a genuine
harness log with real run history.)
The docs also drop an over-constrained ordering claim: the three repos'
drills are INDEPENDENT, run in any order and any sitting. What makes
that safe is that each pins the same fixed set of candidate refs — and
that pinning, not sequencing, is what dissolves the box/rig recursion,
since refs are static identifiers that exist as soon as the release
branches do. Each repo also drills a different thing: box the isolation
contract, rig convergence, cast promotion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Filed as cast's record of heavy-duty/box#116: a `shopt -s globstar;
files=(bin/* **/*.sh)` sweep never descends into `.github/`, because globs
do not match dot-prefixed names without `dotglob`. cast has no such sweep —
it has no shellcheck step at all. Its only shell gate was
bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
a syntax check over a hand-maintained list. The reported symptom holds
(release-notes.sh and labels-reconcile.sh ship unlinted) but so does every
other script here, and `bash -n` parses without linting: it would not catch
a quoting or unset-variable bug in any of them.
.github/scripts/shellcheck-all.sh now runs `shellcheck -x` over the tracked
tree, from CI and from `npm run check:shell`. The file list comes from
`git ls-files`, not a glob. `dotglob` was measured and does work today —
cast's dependency tree ships zero `.sh` files, so sweeping after `npm ci`
pulls in nothing — but that is a property of somebody else's package tree,
re-decided by every install. `git ls-files` does not depend on it.
Extensionless scripts are matched by shebang, which covers bin/cast without
naming it.
It carries a class check in box#112's shape: the sweep asserts its own list
covers `git ls-files '*.sh'` and fails naming the strays otherwise. Verified
by swapping the derivation for the buggy globstar glob, which reports
exactly the two .github/scripts files.
All eight scripts pass as they stood; the three findings were intentional
($PATH written literally into a profile, advice text in backticks) or a
false positive, and are annotated in place. No behavior changes.
Refs #118
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.
The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.
That inverted the two halves, and it inverted them hardest here. Deletion
needs a diff to see; duplication is the one release-notes.sh actually
mis-renders, and cast has the ABSORBING extractor — no `exit`, so `grab`
re-arms on the second heading and the published body swallows whatever sits
between the copies (box#118). The half with the live extraction bug behind it
had the most ways to silently not run.
Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main.
Found by claude-bot-andresmgsl and codex-bot-andresmgsl reviewing #134. cast
inherited the ordering from box, fixed there in heavy-duty/box#144 (#143).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release headings are append-only: the ceremony (#111) adds one and nothing
in CONTRIBUTING's release flow ever removes one. Nothing asserted that.
The arming rule (test/release.test.ts, rig#66) is narrow by design — it asks
whether the TOP section agrees with package.json's version, about ONE
heading, the one a PR is about to write under. It says nothing about the rest
of the file, and cannot: "a heading disappeared" is not a property of a tree,
it is a property of a DIFF.
So an author adding an entry under '## Unreleased' who types OVER the heading
below it instead of inserting above it produces a tree every existing guard
calls green. git merges it cleanly — a one-line edit in a file nobody touched
concurrently, no conflict, no signal. The shipped section's body is now
sitting under '## Unreleased' and the version it belonged to has no section
at all. It surfaces at the NEXT release, when release-notes.sh cannot find
the section it extracts by heading, or worse republishes the absorbed prose.
Ports box's changelog-monotonic.sh (box#122, caught in review of box#118)
rather than reimplementing the invariant a third time in TypeScript, and
keeps both halves. Containment catches a DELETED heading; it cannot catch a
DUPLICATED one, because a duplicate is head-side surplus and base-minus-head
is blind to extras on the head side. Uniqueness on HEAD is asserted alongside
it, and that half matters more in cast than in box: release-notes.sh's awk
has no `exit`, so `grab` re-arms on every matching '## ' line and two copies
of a version heading make the published body ABSORB whatever sits between
them — with the stranded entry dropped from the next release's notes too.
(rig's extractor truncates instead; cast has the absorbing one.) The existing
"double re-arm" test covers duplicate '## Unreleased' only, not duplicate
VERSION headings, which are the ones that reach release-notes.sh.
Wired into ci.yml as its own step so a red run names the invariant that
broke; pull requests only, because on a push to main the merge base IS HEAD
and the assert is vacuous; STRICT=1 with fetch-depth: 0 so a checkout that
cannot reach the base ref fails loudly instead of skipping quietly forever.
'## Unreleased' stays outside the guarded set — the arming rule owns that
heading and the ceremony legitimately consumes it.
Closes#133
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.
- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
relative to the compiled module so a source checkout and an installed
prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
must fail the assert loudly, not be pattern-skipped): asserts tag ==
package.json version FIRST, extracts that version's changelog section
(.github/scripts/release-notes.sh, shared with the tests; missing or
empty refuses), builds once (npm ci && npm run build && npm prune
--omit=dev), stages bin/ dist/ node_modules/ package.json as
cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
--verify-tag`. No tests here — ci.yml gated the merge commit, and the
suite needs age.
- install.sh grows the three channels: default = the latest release's
asset (tag resolved off the releases/latest redirect Location — no
API, no token; failure dies loudly naming CAST_REF=main, never a
silent fallback), CAST_REF=<tag> = pinned (asset first, source
fallback), CAST_REF=main = dev build-from-source. npm is required
only on the source path, and a prebuilt tree is sanity-checked
(dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
changelog, and REAL install.sh runs through all three channels with a
stub curl and a poisoned npm — including the loud no-releases refusal
with no $DEST side effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: this PR's one goal is the versioned layout, the same
one box#79 built and rig#36 ported — the release flow (tags, release.yml,
prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has.
So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware
installer channels leave this branch, and in their place cast gets the
family layout for real:
- install.sh lands each build at $DEST/versions/<package.json version>,
'current' names the default (atomic rename flips), $BINDIR/cast points
through it. Converging no-op on an installed version (nothing rebuilt),
CAST_REINSTALL=1 replaces, a new version installs beside and becomes
default. Pre-versioning flat installs migrate in place, bit for bit.
CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's
RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live
boxes, rig warns on a converged host — cast is an API client, a flip
strands nothing, 'cast use <old>' is one command away.
- bin/cast grows the layout verbs in bash (they must work when dist/ is
broken): versions (marks current+running), use (atomic flip, then
asserts the chain ANSWERS the new version), uninstall (consent gate,
CURRENT guard, dangling-current guard, ends with the absence assert).
valid_version/pkg_version are byte-identical copies in both files; a
test diffs them so the gates cannot drift.
- cast --version stays: package.json is the single source of truth,
printed with the install root, rig-style.
- ci.yml gains the install job: the real installer, from this checkout,
layout asserted, converge no-op asserted, uninstall --all asserted
absent — the box CI precedent.
- Tests drive the REAL install.sh and bin/cast offline (npm shim, local
source): the layout, the chain answering end to end, no-op/reinstall/
side-by-side/migration semantics, the hostile-version gates, refs/heads
download, every uninstall refusal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blockers, all three reviewers concurring:
- COMMENTED agreement now counts: agreement_signal recognizes the live bots'
durable markers (Verdict: Approve / I agree with everything / leading ✅) —
the gate to needs-human can actually close. Formal verdicts remain the
contract (CONTRIBUTING), this is the documented transitional workaround.
- Every counting verdict is bound to the head SHA; a stale approval parks the
PR in addressing (agent owes re-request) instead of promoting unreviewed
code. CHANGES_REQUESTED blocks at any head, per GitHub's own semantic.
- reconcile serializes under ONE job-level concurrency group; scope stays
per-PR. No more cron-vs-event race on the request-the-human-once guard.
- Sweep resilience: per-PR subshell (one failure logs and continues), label
edits warn instead of wedging; the self-heal claim now matches reality
(dispatch-only bootstrap).
- The state machine is extracted pure (globals in, state out) and sourceable:
test/labels-reconcile.sh proves 14 fixture transitions — comment-only
agreement, stale approval, comment-without-verdict, human precedence and
human-block — wired into CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>