cast/test/install-sh.test.ts
dan-claude-bot ced5c497c0 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 22:27:55 +00:00

368 lines
14 KiB
TypeScript

import { execFile } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
// These tests drive the REAL install.sh — not a reimplementation of its
// logic — via CAST_INSTALL_SOURCE (the offline channel the installer carries
// for exactly this, rig's RIG_INSTALL_SOURCE precedent) and an npm PATH shim
// whose `run build` drops a tiny runnable cli.js. Every assertion reads the
// resulting tree, the symlink chain, or the shim's invocation log. The real
// npm-ci-and-tsc build path runs end to end in CI's install job instead —
// here it would cost minutes per test for no extra layout coverage.
const INSTALL_SH = join(process.cwd(), "install.sh");
const REAL_BIN_CAST = join(process.cwd(), "bin", "cast");
// What `npm run build` produces in a fixture tree: enough of a cli.js that
// the launcher chain — BINDIR/cast -> current -> versions/<v>/bin/cast ->
// node dist/cli.js — can be asserted end to end, --version included (cmd_use
// verifies the flip through it).
const FAKE_CLI = `const path = require("path");
const root = path.resolve(__dirname, "..");
const pkg = require(path.join(root, "package.json"));
const [cmd] = process.argv.slice(2);
if (cmd === "--version" || cmd === "-V") {
console.log("cast " + pkg.version + " (" + root + ")");
process.exit(0);
}
console.log("fake-cast " + pkg.version);
`;
const NPM_SHIM = `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
case "\${1:-}" in
run) mkdir -p dist && cp "$CAST_TEST_FAKECLI" dist/cli.js ;;
esac
`;
type Sandbox = {
root: string;
dest: string;
bindir: string;
npmLog: string;
env: Record<string, string>;
};
function sandbox(): Sandbox {
const root = mkdtempSync(join(tmpdir(), "cast-install-"));
const stubs = join(root, "stubs");
const home = join(root, "home");
const dest = join(root, "cast-home");
const bindir = join(root, "bin");
mkdirSync(stubs);
mkdirSync(home);
const npmLog = join(root, "npm.log");
const fakeCli = join(root, "fake-cli.js");
writeFileSync(npmLog, "");
writeFileSync(fakeCli, FAKE_CLI);
writeFileSync(join(stubs, "npm"), NPM_SHIM);
chmodSync(join(stubs, "npm"), 0o755);
return {
root,
dest,
bindir,
npmLog,
env: {
PATH: `${stubs}:${process.env.PATH}`,
HOME: home,
SHELL: "/bin/bash",
CAST_HOME: dest,
CAST_BIN: bindir,
CAST_NO_MODIFY_PATH: "1",
CAST_TEST_NPM_LOG: npmLog,
CAST_TEST_FAKECLI: fakeCli,
},
};
}
// A source tree the installer can build: the REPO'S OWN bin/cast (so the
// launcher and its layout verbs are the code under review), a package.json
// carrying the version, and a src/ marker.
function sourceTree(sb: Sandbox, version: string): string {
const src = join(sb.root, `src-${version.replace(/[^A-Za-z0-9.]/g, "_")}`);
mkdirSync(join(src, "bin"), { recursive: true });
mkdirSync(join(src, "src"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(src, "bin", "cast"));
writeFileSync(
join(src, "package.json"),
`${JSON.stringify({ name: "cast", version })}\n`,
);
writeFileSync(join(src, "src", "cli.ts"), "// fixture\n");
return src;
}
async function install(sb: Sandbox, extraEnv: Record<string, string>) {
return run("bash", [INSTALL_SH], { env: { ...sb.env, ...extraEnv } });
}
function currentTarget(sb: Sandbox): string {
return realpathSync(join(sb.dest, "current"));
}
describe("install.sh — the versioned layout", () => {
it("lands versions/<v>, points current and the PATH link through it, and the chain answers", async () => {
const sb = sandbox();
const src = sourceTree(sb, "0.5.0");
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
expect(stdout).toContain("done (local source");
// The layout: one tree per version, named by package.json's version.
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(
readFileSync(join(sb.dest, "versions/0.5.0/dist/cli.js"), "utf8"),
).toContain("pkg.version");
expect(
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),
).toBe(`local:${src}\n`);
// The chain: current -> versions/0.5.0, BINDIR/cast -> current/bin/cast.
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.5.0")),
);
expect(readlinkSync(join(sb.bindir, "cast"))).toBe(
join(sb.dest, "current/bin/cast"),
);
// And it ANSWERS, through the whole chain.
const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
env: sb.env,
});
expect(v.trim()).toBe(
`cast 0.5.0 (${realpathSync(join(sb.dest, "versions/0.5.0"))})`,
);
// The build ran: ci, build, prune — in that order.
expect(readFileSync(sb.npmLog, "utf8")).toBe(
"npm ci --silent\nnpm run build --silent\nnpm prune --omit=dev --silent\n",
);
});
it("re-running the same version is a converging no-op — nothing rebuilt, nothing touched", async () => {
const sb = sandbox();
const src = sourceTree(sb, "0.5.0");
await install(sb, { CAST_INSTALL_SOURCE: src });
const npmCallsAfterFirst = readFileSync(sb.npmLog, "utf8");
writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "survives\n");
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: src });
expect(stdout).toContain("cast 0.5.0 is already installed");
expect(stdout).toContain("nothing was built");
// No second build, and the installed tree was not replaced.
expect(readFileSync(sb.npmLog, "utf8")).toBe(npmCallsAfterFirst);
expect(readFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "utf8")).toBe(
"survives\n",
);
});
it("CAST_REINSTALL=1 replaces that version's tree — no partial overlays", async () => {
const sb = sandbox();
const src = sourceTree(sb, "0.5.0");
await install(sb, { CAST_INSTALL_SOURCE: src });
writeFileSync(join(sb.dest, "versions/0.5.0/SENTINEL"), "stale\n");
const { stdout } = await install(sb, {
CAST_INSTALL_SOURCE: src,
CAST_REINSTALL: "1",
});
expect(stdout).toContain("replacing the installed 0.5.0 tree");
// A replaced tree, not an overlay: the stale file is gone.
expect(existsSync(join(sb.dest, "versions/0.5.0/SENTINEL"))).toBe(false);
expect(existsSync(join(sb.dest, "versions/0.5.0/dist/cli.js"))).toBe(true);
});
it("a NEW version installs beside the old one and becomes the default", async () => {
const sb = sandbox();
await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") });
const { stdout } = await install(sb, {
CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0"),
});
expect(stdout).toContain(
"default version switched: 0.5.0 -> 0.6.0 ('cast use 0.5.0' switches back)",
);
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.6.0")),
);
const { stdout: v } = await run(join(sb.bindir, "cast"), ["--version"], {
env: sb.env,
});
expect(v).toContain("cast 0.6.0");
});
it("re-running an installed NON-default version never moves the default", async () => {
const sb = sandbox();
const old = sourceTree(sb, "0.5.0");
await install(sb, { CAST_INSTALL_SOURCE: old });
await install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.6.0") });
const { stdout } = await install(sb, { CAST_INSTALL_SOURCE: old });
expect(stdout).toContain(
"the default stays 0.6.0 — 'cast use 0.5.0' switches.",
);
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.6.0")),
);
});
it("migrates a pre-versioning flat install in place, preserving the tree", async () => {
const sb = sandbox();
// The OLD layout: the tree sits flat at $DEST, bin/cast directly under it.
mkdirSync(join(sb.dest, "bin"), { recursive: true });
mkdirSync(join(sb.dest, "dist"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
writeFileSync(
join(sb.dest, "package.json"),
`${JSON.stringify({ name: "cast", version: "0.4.0" })}\n`,
);
writeFileSync(
join(sb.dest, "SENTINEL"),
"the operator's tree, bit for bit\n",
);
const { stdout } = await install(sb, {
CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0"),
});
expect(stdout).toContain("found a pre-versioning flat install at");
expect(stdout).toContain("migrating it into the versioned layout");
// The flat tree moved — preserved, not rebuilt — and the new version
// installed beside it and took the default.
expect(readFileSync(join(sb.dest, "versions/0.4.0/SENTINEL"), "utf8")).toBe(
"the operator's tree, bit for bit\n",
);
expect(existsSync(join(sb.dest, "versions/0.5.0/bin/cast"))).toBe(true);
expect(currentTarget(sb)).toBe(
realpathSync(join(sb.dest, "versions/0.5.0")),
);
});
it("refuses a source whose package.json version is not a sane directory name", async () => {
const sb = sandbox();
const src = sourceTree(sb, "../evil");
await expect(
install(sb, { CAST_INSTALL_SOURCE: src }),
).rejects.toMatchObject({
stderr: expect.stringContaining("not a sane directory name: '../evil'"),
});
expect(existsSync(sb.dest)).toBe(false);
});
it("refuses to migrate a flat install whose version would escape versions/", async () => {
const sb = sandbox();
mkdirSync(join(sb.dest, "bin"), { recursive: true });
copyFileSync(REAL_BIN_CAST, join(sb.dest, "bin/cast"));
writeFileSync(
join(sb.dest, "package.json"),
`${JSON.stringify({ name: "cast", version: "../evil" })}\n`,
);
await expect(
install(sb, { CAST_INSTALL_SOURCE: sourceTree(sb, "0.5.0") }),
).rejects.toMatchObject({
stderr: expect.stringContaining("not a sane directory name"),
});
// Refused BEFORE anything moved: the flat tree is untouched.
expect(existsSync(join(sb.dest, "bin/cast"))).toBe(true);
});
it("downloads refs/heads/<ref> when no local source is given", async () => {
const sb = sandbox();
// A curl shim standing in for GitHub: logs every URL asked for, and
// serves the fixture tarball ONLY for the refs/heads URL — a branch has
// no release asset and no tag, which is exactly the shape CAST_REF=main
// meets in the wild. (The channels themselves — assets, refusals, the
// latest-release resolution — are test/release.test.ts's.)
sourceTree(sb, "0.5.0");
await run("tar", [
"-C",
sb.root,
"-czf",
join(sb.root, "src.tgz"),
"src-0.5.0",
]);
const stubs = join(sb.root, "stubs");
const curlLog = join(sb.root, "curl.log");
writeFileSync(curlLog, "");
writeFileSync(
join(stubs, "curl"),
`#!/usr/bin/env bash
out=""; url=""
for a in "$@"; do
case "$prev" in -o) out="$a" ;; esac
case "$a" in http*) url="$a" ;; esac
prev="$a"
done
printf '%s\\n' "$url" >> "${curlLog}"
case "$url" in
*/archive/refs/heads/*) cp "${join(sb.root, "src.tgz")}" "$out" ;;
*) exit 22 ;;
esac
`,
);
chmodSync(join(stubs, "curl"), 0o755);
const { stdout } = await install(sb, { CAST_REF: "dev-branch" });
// The channel's try-order, in full: the release asset, then refs/tags,
// then refs/heads — where a branch finally answers.
expect(readFileSync(curlLog, "utf8").trim().split("\n")).toEqual([
"https://github.com/heavy-duty/cast/releases/download/dev-branch/cast-dev-branch.tgz",
"https://github.com/heavy-duty/cast/archive/refs/tags/dev-branch.tar.gz",
"https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz",
]);
expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)");
expect(
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),
).toBe("heavy-duty/cast@dev-branch\n");
});
});
describe("the shared gates cannot drift", () => {
// install.sh and bin/cast each carry valid_version and pkg_version — the
// same trust boundary enforced in two places. A byte-identical diff is the
// rig-precedent guard that an edit to one cannot quietly miss the other.
function extractFunction(file: string, name: string): string {
const text = readFileSync(file, "utf8");
const start = text.indexOf(`${name}() {`);
expect(start, `${name}() not found in ${file}`).toBeGreaterThan(-1);
const end = text.indexOf("\n}", start);
return text.slice(start, end + 2);
}
for (const fn of ["valid_version", "pkg_version", "flip_current"]) {
it(`${fn}() is byte-identical between install.sh and bin/cast`, () => {
expect(extractFunction(INSTALL_SH, fn)).toBe(
extractFunction(REAL_BIN_CAST, fn),
);
});
}
});
describe("portability — cast runs on the operator's own machine, macOS included", () => {
// box#79/rig#36 could assume GNU userland and bash 4+ because boxes run
// Linux; cast cannot. These greps pin the two spellings that bit (or
// nearly bit) for real: `mv -T` (GNU-only — BSD/macOS mv has no -T, the
// flip now rides rename(2) via node) and `mapfile` (bash 4 — macOS ships
// bash 3.2). A reintroduction fails HERE, not on the first operator Mac.
for (const file of [INSTALL_SH, REAL_BIN_CAST]) {
const name = file.split("/").pop();
it(`${name} carries no GNU mv -T and no bash-4 mapfile`, () => {
const text = readFileSync(file, "utf8");
// "mv -T" as an invocation — the comments explaining WHY it is absent
// spell it "mv says … with -T", which this must not match.
expect(text).not.toMatch(/\bmv\s+-[A-Za-z]*T/);
expect(text).not.toMatch(/^\s*mapfile\b/m);
});
}
});