feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
|
|
|
import {
|
|
|
|
|
chmodSync,
|
|
|
|
|
cpSync,
|
|
|
|
|
existsSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
readlinkSync,
|
|
|
|
|
realpathSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
} from "node:fs";
|
|
|
|
|
import { dirname, join } from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
|
import { describe, expect, it } from "vitest";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.js";
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
// What remains CAST'S OWN of the release flow, after the ceremony moved
|
|
|
|
|
// upstream (heavy-duty/ceremony#15): the caller stubs' load-bearing shape,
|
|
|
|
|
// the artifact hook's install contract, the drill doctrine cast's docs must
|
|
|
|
|
// not lose, and the installer's three channels — REAL install.sh runs
|
|
|
|
|
// against throwaway roots, with a stub curl on PATH standing in for GitHub
|
|
|
|
|
// and a POISONED npm proving the release channels never build. Nothing here
|
|
|
|
|
// touches the network. The machinery the old halves of this file drove —
|
|
|
|
|
// notes extraction, arming, monotonicity, the drill gate — is tested
|
|
|
|
|
// upstream in ceremony's test/ and enforced here by the pinned actions in
|
|
|
|
|
// ci.yml. (`cast --version` itself is test/version-cli.test.ts's; the
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
// versioned LAYOUT every channel lands in is test/install-sh.test.ts's —
|
|
|
|
|
// here the layout is asserted only where a channel decides what fills it.)
|
|
|
|
|
|
|
|
|
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
|
|
|
|
|
|
|
function run(
|
|
|
|
|
cmd: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
env: Record<string, string> = {},
|
fix: assert no shipped changelog heading is deleted or duplicated
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>
2026-07-20 20:00:12 +00:00
|
|
|
cwd?: string,
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
): Promise<{ code: number; output: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const child = spawn(cmd, args, {
|
|
|
|
|
stdio: ["ignore", "pipe", "pipe"],
|
fix: assert no shipped changelog heading is deleted or duplicated
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>
2026-07-20 20:00:12 +00:00
|
|
|
cwd,
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
env: { ...process.env, ...env },
|
|
|
|
|
});
|
|
|
|
|
let output = "";
|
|
|
|
|
child.stdout.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.stderr.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
// --- the ceremony callers — the stubs' load-bearing shape -------------------
|
|
|
|
|
// The workflow logic lives upstream at the pin; what can still break HERE is
|
|
|
|
|
// the caller: its triggers, its permissions grant, its backend input, and the
|
|
|
|
|
// pins themselves. Fail-closed, same discipline as the old workflow pins.
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
describe("the ceremony callers", () => {
|
|
|
|
|
const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8");
|
2026-07-19 19:39:45 +00:00
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("ONE push key, both filters — a second sibling push: silently kills a door", () => {
|
|
|
|
|
// YAML maps are last-key-wins (grok's round-2 catch on the old
|
|
|
|
|
// workflow: the tag fallback had stopped triggering).
|
|
|
|
|
expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1);
|
|
|
|
|
expect(RY).toContain('tags: ["**"]');
|
|
|
|
|
expect(RY).toContain("branches: [main]");
|
|
|
|
|
// The merge door rides push, never pull_request: a fork PR's token is
|
|
|
|
|
// read-only and permissions: cannot raise it (box#97).
|
|
|
|
|
expect(RY).not.toContain("pull_request:");
|
2026-07-19 20:33:49 +00:00
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("the version backend is package-json", () => {
|
|
|
|
|
expect(RY).toContain("version-source: package-json");
|
2026-07-19 20:33:49 +00:00
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("every ceremony reference in .github/ names ONE tag", () => {
|
|
|
|
|
// CONSUMERS.md's same-tag rule: the two workflow callers and each guard
|
|
|
|
|
// step pin the same ceremony tag — one reference bumped alone leaves
|
|
|
|
|
// the repo split across ceremony versions.
|
|
|
|
|
const all = [
|
|
|
|
|
"workflows/release.yml",
|
|
|
|
|
"workflows/labels.yml",
|
|
|
|
|
"workflows/ci.yml",
|
|
|
|
|
]
|
|
|
|
|
.map((f) => readFileSync(join(ROOT, ".github", f), "utf8"))
|
|
|
|
|
.join("\n");
|
|
|
|
|
const refs = [...all.matchAll(/heavy-duty\/ceremony\/[^@\s]+@(\S+)/g)].map(
|
|
|
|
|
(m) => m[1],
|
2026-07-19 19:39:45 +00:00
|
|
|
);
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
expect(refs.length).toBeGreaterThanOrEqual(6); // 2 callers + 4 guards
|
|
|
|
|
expect(new Set(refs).size).toBe(1);
|
2026-07-19 19:39:45 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
// --- the artifact hook — the install contract the workflow used to carry ----
|
|
|
|
|
// The asset name `cast-X.Y.Z.tgz` and the staged layout are what the
|
|
|
|
|
// installer's release channels download; they never run npm or tsc, so the
|
|
|
|
|
// build happens ONCE, in the hook, and the asset is the runnable tree.
|
fix: assert no shipped changelog heading is deleted or duplicated
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>
2026-07-20 20:00:12 +00:00
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
describe("release-artifact hook", () => {
|
|
|
|
|
const HOOK = readFileSync(
|
|
|
|
|
join(ROOT, ".github/actions/release-artifact/action.yml"),
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
fix: assert no shipped changelog heading is deleted or duplicated
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>
2026-07-20 20:00:12 +00:00
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("builds the prod-only tree once and stages the runnable layout", () => {
|
|
|
|
|
expect(HOOK).toContain("npm ci");
|
|
|
|
|
expect(HOOK).toContain("npm run build");
|
|
|
|
|
expect(HOOK).toContain("npm prune --omit=dev");
|
|
|
|
|
expect(HOOK).toContain("cp -R bin dist node_modules package.json");
|
2026-07-20 20:49:53 +00:00
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("drops cast-<version>.tgz into $RELEASE_ASSETS_DIR — the channels' exact download name", () => {
|
|
|
|
|
expect(HOOK).toContain('"$RELEASE_ASSETS_DIR/cast-$VERSION.tgz"');
|
|
|
|
|
expect(HOOK).toContain('"$RUNNER_TEMP/stage/cast-$VERSION"');
|
2026-07-20 20:49:53 +00:00
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("runs no tests — ci.yml gated the merge commit already, and the suite needs age", () => {
|
|
|
|
|
expect(HOOK).not.toContain("npm test");
|
|
|
|
|
expect(HOOK).not.toContain("npm run check");
|
2026-07-20 20:49:53 +00:00
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
it("owns its toolchain — setup-node moved INTO the hook; the shared workflow is node-free", () => {
|
|
|
|
|
expect(HOOK).toContain("actions/setup-node");
|
fix: assert no shipped changelog heading is deleted or duplicated
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>
2026-07-20 20:00:12 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
// --- the drill doctrine, in cast's own docs ---------------------------------
|
|
|
|
|
// The drill-recorded GATE is ceremony's (actions/drill-recorded, tested
|
|
|
|
|
// upstream); the MEANING is cast's. The three drills are INDEPENDENT — any
|
|
|
|
|
// order, any schedule, separate sittings — because each pins the same fixed
|
|
|
|
|
// candidate refs: static identifiers that exist as soon as the release
|
|
|
|
|
// branches do, which is what dissolves the box<->rig recursion. The docs
|
|
|
|
|
// must not re-acquire an ordering rule between repos.
|
2026-07-21 16:42:32 +00:00
|
|
|
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
describe("drills/README.md — the independent, ref-pinned drills", () => {
|
|
|
|
|
it("documents the record files and the INDEPENDENT, ref-pinned drills", () => {
|
refactor: one drill record per version, in drills/
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>
2026-07-21 16:27:09 +00:00
|
|
|
const doc = readFileSync(join(ROOT, "drills/README.md"), "utf8");
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
expect(doc).toContain("`<version>.md`");
|
refactor: one drill record per version, in drills/
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>
2026-07-21 16:27:09 +00:00
|
|
|
expect(doc).toMatch(/drills are independent/i);
|
|
|
|
|
expect(doc).toMatch(/any order/i);
|
|
|
|
|
expect(doc).toMatch(/pins the same fixed set of\s+candidate\s+refs/i);
|
|
|
|
|
expect(doc).toMatch(/not sequencing/i);
|
|
|
|
|
// box and rig stay mutually recursive; the pinning is what makes that a
|
|
|
|
|
// non-problem, so both halves have to survive together.
|
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:21:46 +00:00
|
|
|
expect(doc).toMatch(/mutually recursive/);
|
|
|
|
|
expect(doc).toContain("RIG_REF");
|
|
|
|
|
expect(doc).toMatch(/candidate refs, not released artifacts/);
|
test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
|
|
|
expect(doc).toMatch(/no fixed order/i);
|
refactor: one drill record per version, in drills/
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>
2026-07-21 16:27:09 +00:00
|
|
|
// Each repo drills a different thing — which is WHY records are per-repo.
|
|
|
|
|
expect(doc).toMatch(/isolation\s+contract/i);
|
|
|
|
|
expect(doc).toMatch(/convergence/i);
|
|
|
|
|
expect(doc).toMatch(/promotion/i);
|
|
|
|
|
// Separate records, one pinned set: each cites the shared run ID.
|
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:21:46 +00:00
|
|
|
expect(doc).toMatch(/run ID/i);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
// --- the installer's three channels, driven for real ------------------------
|
|
|
|
|
// Full install.sh runs against throwaway roots. The curl on PATH is a stub
|
|
|
|
|
// scripted via env (CURL_*); the npm on PATH is POISONED (exits 97) unless a
|
|
|
|
|
// test opts into the stub build — so any release-channel install that
|
|
|
|
|
// touches npm fails its assertion by failing the install.
|
|
|
|
|
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const STUB = tmp("cast-stub-");
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
writeFileSync(
|
|
|
|
|
join(STUB, "curl"),
|
|
|
|
|
`#!/usr/bin/env bash
|
|
|
|
|
# Stub curl — never the network. Scripted via env:
|
|
|
|
|
# CURL_FAIL_ALL nonempty -> every call exits 6 (network down)
|
|
|
|
|
# CURL_REDIRECT what -w %{redirect_url} answers (the HEAD probe)
|
|
|
|
|
# CURL_SERVE_SUBSTR substring a download URL must carry to succeed
|
|
|
|
|
# CURL_TARBALL copied to -o's target on a successful download
|
|
|
|
|
# CURL_LOG every URL asked for, one per line, appended
|
|
|
|
|
url="" out="" probe=0
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
-o) out="$2"; shift 2 ;;
|
|
|
|
|
-w) probe=1; shift 2 ;;
|
|
|
|
|
-*) shift ;;
|
|
|
|
|
*) url="$1"; shift ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
if [ -n "\${CURL_LOG:-}" ]; then printf '%s\\n' "$url" >> "$CURL_LOG"; fi
|
|
|
|
|
if [ -n "\${CURL_FAIL_ALL:-}" ]; then exit 6; fi
|
|
|
|
|
if [ "$probe" -eq 1 ]; then printf '%s' "\${CURL_REDIRECT:-}"; exit 0; fi
|
|
|
|
|
case "$url" in
|
|
|
|
|
*"\${CURL_SERVE_SUBSTR:-/__nothing_succeeds__/}"*)
|
|
|
|
|
cp "\${CURL_TARBALL:?}" "\${out:?}"; exit 0 ;;
|
|
|
|
|
*) exit 22 ;;
|
|
|
|
|
esac
|
|
|
|
|
`,
|
|
|
|
|
);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(STUB, "npm"),
|
|
|
|
|
`#!/usr/bin/env bash
|
|
|
|
|
# Poisoned npm: the release-asset channels must NEVER build (#96). A test
|
|
|
|
|
# that legitimately builds from source sets NPM_EXIT=0; every invocation is
|
|
|
|
|
# logged so order can be asserted.
|
|
|
|
|
if [ -n "\${NPM_LOG:-}" ]; then printf '%s\\n' "$*" >> "$NPM_LOG"; fi
|
|
|
|
|
exit "\${NPM_EXIT:-97}"
|
|
|
|
|
`,
|
|
|
|
|
);
|
|
|
|
|
chmodSync(join(STUB, "curl"), 0o755);
|
|
|
|
|
chmodSync(join(STUB, "npm"), 0o755);
|
|
|
|
|
|
|
|
|
|
// A fabricated tree shaped like release.yml's staging (one top-level
|
|
|
|
|
// cast-<ref>/ dir — the same shape GitHub's source tarballs have), tarred.
|
|
|
|
|
// Version 9.9.9 so nothing collides with the tree under test.
|
|
|
|
|
function makeTarball(
|
|
|
|
|
work: string,
|
|
|
|
|
ref: string,
|
|
|
|
|
opts: { dist?: boolean; nodeModules?: boolean } = {},
|
|
|
|
|
): string {
|
|
|
|
|
const top = join(work, `cast-${ref}`);
|
|
|
|
|
mkdirSync(join(top, "bin"), { recursive: true });
|
|
|
|
|
cpSync(join(ROOT, "bin/cast"), join(top, "bin/cast"));
|
|
|
|
|
chmodSync(join(top, "bin/cast"), 0o755);
|
|
|
|
|
if (opts.dist !== false) {
|
|
|
|
|
mkdirSync(join(top, "dist"), { recursive: true });
|
|
|
|
|
writeFileSync(join(top, "dist/cli.js"), `console.log("cast 9.9.9");\n`);
|
|
|
|
|
}
|
|
|
|
|
if (opts.nodeModules !== false) {
|
|
|
|
|
mkdirSync(join(top, "node_modules"), { recursive: true });
|
|
|
|
|
writeFileSync(join(top, "node_modules/.package-lock.json"), "{}");
|
|
|
|
|
}
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(top, "package.json"),
|
|
|
|
|
JSON.stringify({ name: "cast", version: "9.9.9" }),
|
|
|
|
|
);
|
|
|
|
|
const tgz = join(work, `cast-${ref}.tgz`);
|
|
|
|
|
execFileSync("tar", ["-C", work, "-czf", tgz, `cast-${ref}`]);
|
|
|
|
|
return tgz;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Install = {
|
|
|
|
|
code: number;
|
|
|
|
|
output: string;
|
|
|
|
|
dest: string;
|
|
|
|
|
bin: string;
|
|
|
|
|
curlLog: string[];
|
|
|
|
|
npmLog: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function runInstall(
|
|
|
|
|
env: Record<string, string>,
|
|
|
|
|
opts: { preexistingDest?: boolean } = {},
|
|
|
|
|
): Promise<Install> {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const work = tmp("cast-inst-");
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
const dest = join(work, "dest");
|
|
|
|
|
const bin = join(work, "bin");
|
|
|
|
|
const curlLog = join(work, "curl.log");
|
|
|
|
|
const npmLog = join(work, "npm.log");
|
|
|
|
|
if (opts.preexistingDest) {
|
|
|
|
|
mkdirSync(dest, { recursive: true });
|
|
|
|
|
writeFileSync(join(dest, "MARKER"), "the previous install\n");
|
|
|
|
|
}
|
|
|
|
|
mkdirSync(join(work, "home"), { recursive: true });
|
|
|
|
|
const r = await run("bash", [join(ROOT, "install.sh")], {
|
|
|
|
|
PATH: `${STUB}:${process.env.PATH}`,
|
|
|
|
|
HOME: join(work, "home"),
|
|
|
|
|
CAST_HOME: dest,
|
|
|
|
|
CAST_BIN: bin,
|
|
|
|
|
CAST_NO_MODIFY_PATH: "1",
|
|
|
|
|
CURL_LOG: curlLog,
|
|
|
|
|
NPM_LOG: npmLog,
|
|
|
|
|
...env,
|
|
|
|
|
});
|
|
|
|
|
const lines = (f: string) =>
|
|
|
|
|
existsSync(f) ? readFileSync(f, "utf8").split("\n").filter(Boolean) : [];
|
|
|
|
|
return {
|
|
|
|
|
code: r.code,
|
|
|
|
|
output: r.output,
|
|
|
|
|
dest,
|
|
|
|
|
bin,
|
|
|
|
|
curlLog: lines(curlLog),
|
|
|
|
|
npmLog: lines(npmLog),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("install.sh — the three channels", () => {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const work = tmp("cast-tarballs-");
|
feat: release flow — tagged releases with a prebuilt dist asset (#96)
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>
2026-07-18 21:29:53 +00:00
|
|
|
const asset = makeTarball(work, "9.9.9");
|
|
|
|
|
const mainSrc = makeTarball(work, "main");
|
|
|
|
|
const brokenAsset = makeTarball(join(work, "broken"), "9.9.9", {
|
|
|
|
|
dist: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("default channel: resolves the latest release and installs its PREBUILT asset — npm never runs", async () => {
|
|
|
|
|
const r = await runInstall({
|
|
|
|
|
CURL_REDIRECT: "https://github.com/heavy-duty/cast/releases/tag/9.9.9",
|
|
|
|
|
CURL_SERVE_SUBSTR: "releases/download/9.9.9/cast-9.9.9.tgz",
|
|
|
|
|
CURL_TARBALL: asset,
|
|
|
|
|
});
|
|
|
|
|
expect(r.output).toContain("latest release: 9.9.9");
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
// The download was the asset — never a source tarball...
|
|
|
|
|
expect(r.curlLog.some((u) => u.includes("releases/download/"))).toBe(true);
|
|
|
|
|
expect(r.curlLog.some((u) => u.includes("archive/"))).toBe(false);
|
|
|
|
|
// ...and the poisoned npm was never touched.
|
|
|
|
|
expect(r.npmLog).toEqual([]);
|
|
|
|
|
// The channel decided WHAT arrived; the versioned layout decided WHERE:
|
|
|
|
|
// the prebuilt tree landed in versions/<its package.json version>, with
|
|
|
|
|
// current flipped to it and the PATH link pointing through the chain.
|
|
|
|
|
expect(existsSync(join(r.dest, "versions/9.9.9/dist/cli.js"))).toBe(true);
|
|
|
|
|
expect(realpathSync(join(r.dest, "current"))).toBe(
|
|
|
|
|
realpathSync(join(r.dest, "versions/9.9.9")),
|
|
|
|
|
);
|
|
|
|
|
expect(readlinkSync(join(r.bin, "cast"))).toBe(
|
|
|
|
|
join(r.dest, "current/bin/cast"),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
readFileSync(join(r.dest, "versions/9.9.9/INSTALLED_FROM"), "utf8"),
|
|
|
|
|
).toBe("heavy-duty/cast@9.9.9 (release asset)\n");
|
|
|
|
|
// The installed tree runs, through the whole chain — with zero build
|
|
|
|
|
// steps on this machine.
|
|
|
|
|
const v = await run(join(r.bin, "cast"), []);
|
|
|
|
|
expect(v.output.trim()).toBe("cast 9.9.9");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("default channel, no releases yet: dies LOUDLY naming CAST_REF=main, installs nothing", async () => {
|
|
|
|
|
// A repo with no releases redirects releases/latest to /releases —
|
|
|
|
|
// GitHub's real shape (measured; rig pinned the same fact).
|
|
|
|
|
const r = await runInstall({
|
|
|
|
|
CURL_REDIRECT: "https://github.com/heavy-duty/cast/releases",
|
|
|
|
|
CURL_SERVE_SUBSTR: "archive/refs/heads/main",
|
|
|
|
|
CURL_TARBALL: mainSrc,
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain("no release");
|
|
|
|
|
expect(r.output).toContain("CAST_REF=main");
|
|
|
|
|
// The stub would have happily served main — a silent fallback would
|
|
|
|
|
// succeed here and FAIL this test. Nothing was downloaded or created.
|
|
|
|
|
expect(r.curlLog.filter((u) => !u.includes("releases/latest"))).toEqual([]);
|
|
|
|
|
expect(existsSync(r.dest)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("default channel: a resolved release with a missing asset refuses — no source fallback", async () => {
|
|
|
|
|
const r = await runInstall({
|
|
|
|
|
CURL_REDIRECT: "https://github.com/heavy-duty/cast/releases/tag/9.9.9",
|
|
|
|
|
// Nothing served: the asset 404s, and so would the source tarballs.
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain("no cast-9.9.9.tgz asset");
|
|
|
|
|
expect(r.curlLog.some((u) => u.includes("archive/"))).toBe(false);
|
|
|
|
|
expect(existsSync(r.dest)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("pinned channel: CAST_REF=<tag> installs that release's asset, resolves nothing, builds nothing", async () => {
|
|
|
|
|
const r = await runInstall({
|
|
|
|
|
CAST_REF: "9.9.9",
|
|
|
|
|
CURL_SERVE_SUBSTR: "releases/download/9.9.9/cast-9.9.9.tgz",
|
|
|
|
|
CURL_TARBALL: asset,
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.curlLog.some((u) => u.includes("releases/latest"))).toBe(false);
|
|
|
|
|
expect(r.npmLog).toEqual([]);
|
|
|
|
|
expect(existsSync(join(r.dest, "versions/9.9.9/dist/cli.js"))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("pinned channel: a ref without an asset falls back to source — refs/tags first, then the build", async () => {
|
|
|
|
|
const r = await runInstall({
|
|
|
|
|
CAST_REF: "9.9.9",
|
|
|
|
|
CURL_SERVE_SUBSTR: "archive/refs/tags/9.9.9.tar.gz",
|
|
|
|
|
CURL_TARBALL: asset,
|
|
|
|
|
NPM_EXIT: "0",
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
// Asset first, tag second — and the build ran, in order.
|
|
|
|
|
expect(r.curlLog[0]).toContain("releases/download/9.9.9/cast-9.9.9.tgz");
|
|
|
|
|
expect(r.curlLog[1]).toContain("archive/refs/tags/9.9.9.tar.gz");
|
|
|
|
|
expect(r.npmLog[0]).toContain("ci");
|
|
|
|
|
expect(r.npmLog[1]).toContain("run build");
|
|
|
|
|
expect(r.npmLog[2]).toContain("prune");
|
|
|
|
|
// The source-built tree lands by the same rule as everything else:
|
|
|
|
|
// versions/<its package.json version>.
|
|
|
|
|
expect(existsSync(join(r.dest, "versions/9.9.9/bin/cast"))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("dev channel: CAST_REF=main tries asset, tag, then branch — and builds from source", async () => {
|
|
|
|
|
const r = await runInstall({
|
|
|
|
|
CAST_REF: "main",
|
|
|
|
|
CURL_SERVE_SUBSTR: "archive/refs/heads/main.tar.gz",
|
|
|
|
|
CURL_TARBALL: mainSrc,
|
|
|
|
|
NPM_EXIT: "0",
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.curlLog[0]).toContain("releases/download/main/cast-main.tgz");
|
|
|
|
|
expect(r.curlLog[1]).toContain("archive/refs/tags/main.tar.gz");
|
|
|
|
|
expect(r.curlLog[2]).toContain("archive/refs/heads/main.tar.gz");
|
|
|
|
|
expect(r.npmLog.length).toBe(3);
|
|
|
|
|
// The version dir is named by the TREE's package.json (9.9.9 in this
|
|
|
|
|
// fixture), never by the ref that fetched it — main's tree between
|
|
|
|
|
// releases must say so in its own version.
|
|
|
|
|
expect(existsSync(join(r.dest, "versions/9.9.9/bin/cast"))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a ref that is neither a release, a tag nor a branch dies naming all three tries", async () => {
|
|
|
|
|
const r = await runInstall({ CAST_REF: "no-such-ref", NPM_EXIT: "0" });
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain("neither a tag nor a branch");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a broken asset (no dist/) refuses BEFORE touching an existing install", async () => {
|
|
|
|
|
const r = await runInstall(
|
|
|
|
|
{
|
|
|
|
|
CAST_REF: "9.9.9",
|
|
|
|
|
CURL_SERVE_SUBSTR: "releases/download/9.9.9/cast-9.9.9.tgz",
|
|
|
|
|
CURL_TARBALL: brokenAsset,
|
|
|
|
|
},
|
|
|
|
|
{ preexistingDest: true },
|
|
|
|
|
);
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain("not a runnable cast tree");
|
|
|
|
|
// The sanity check ran before anything landed in $DEST: whatever was
|
|
|
|
|
// already there survives, untouched.
|
|
|
|
|
expect(existsSync(join(r.dest, "MARKER"))).toBe(true);
|
|
|
|
|
expect(existsSync(join(r.dest, "versions"))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|