cast/test/release-tooling.test.ts
dan-claude-bot d992f1833d feat: versioned installs — tagged releases with a prebuilt dist asset (#96)
cast gets the family's release flow (box#83's shape), plus the piece
unique to cast: because cast compiles, the source tarball is not the
package — so release.yml builds ONCE in CI and attaches cast-X.Y.Z.tgz,
and the installer's default channel extracts that asset instead of
running npm ci + tsc on the operator's machine.

- cast --version: package.json is the single source of truth (no VERSION
  file); prints the install root too, rig-style.
- CHANGELOG.md with Unreleased; release notes are the curated section
  (scripts/changelog-section.sh), never the auto-generated PR list.
- release.yml on a bare X.Y.Z tag: assert tag == package.json version,
  check + build + test, prune, tar the runnable tree, gh release create.
- install.sh channels: unset → latest release asset (resolved via the
  releases/latest redirect — no API, no token); CAST_REF=X.Y.Z → that
  tag's asset; CAST_REF=<branch> → build-from-source, the old path.
- Tests drive the REAL install.sh offline via curl/npm PATH shims (all
  three channels, plus the broken-asset and no-release refusals), and
  the real changelog-section.sh against fixture changelogs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:51:15 +00:00

121 lines
3.9 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, 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);
// The release surface has two moving parts this file can prove without a
// tag push: `cast --version` (what an operator asks an installed tree) and
// scripts/changelog-section.sh (what release.yml publishes as the release
// body). Both are exercised for real — the built CLI, the actual script —
// not reimplemented in the test.
describe("cast --version", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
version: string;
};
for (const flag of ["--version", "-V"]) {
it(`${flag} prints the package.json version and the install root`, async () => {
const { stdout } = await run("node", ["dist/cli.js", flag]);
// The version comes from package.json — the single source of truth
// (cast#96) — and the root rides along, rig-style, because "which
// cast" and "where from" are the same question.
expect(stdout.trim()).toBe(`cast ${pkg.version} (${process.cwd()})`);
});
}
it("exits 0 and prints nothing to stderr", async () => {
const { stderr } = await run("node", ["dist/cli.js", "--version"]);
expect(stderr).toBe("");
});
});
describe("scripts/changelog-section.sh", () => {
const SCRIPT = join(process.cwd(), "scripts", "changelog-section.sh");
const CHANGELOG = `# Changelog
Intro prose that must never leak into a release body.
## Unreleased
### Added
- something still cooking
## 0.2.0 — 2026-07-18
### Added
- **the second thing** (#96) — with detail.
### Fixed
- a fix note
## 0.1.0 — 2026-07-01
- the first thing
`;
function withChangelog(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "cast-changelog-"));
const file = join(dir, "CHANGELOG.md");
writeFileSync(file, content);
return file;
}
it("prints exactly one version's section, trimmed", async () => {
const file = withChangelog(CHANGELOG);
const { stdout } = await run("bash", [SCRIPT, "0.2.0", file]);
expect(stdout).toBe(
"### Added\n\n- **the second thing** (#96) — with detail.\n\n### Fixed\n\n- a fix note\n",
);
});
it("does not bleed into the next section for the last version either", async () => {
const file = withChangelog(CHANGELOG);
const { stdout } = await run("bash", [SCRIPT, "0.1.0", file]);
expect(stdout).toBe("- the first thing\n");
});
it("never serves Unreleased content for a version that is absent", async () => {
const file = withChangelog(CHANGELOG);
// 0.3.0 has no section — the release must fail loudly, not ship the
// Unreleased notes (or an empty body) under a version's name.
await expect(run("bash", [SCRIPT, "0.3.0", file])).rejects.toMatchObject({
code: 1,
});
});
it("refuses an empty section", async () => {
const file = withChangelog(
"# Changelog\n\n## 0.9.0 — 2026-01-01\n\n## 0.8.0\n\n- old\n",
);
await expect(run("bash", [SCRIPT, "0.9.0", file])).rejects.toMatchObject({
code: 1,
});
});
it("refuses a missing file and a missing argument", async () => {
await expect(
run("bash", [SCRIPT, "0.1.0", "/nonexistent/CHANGELOG.md"]),
).rejects.toMatchObject({ code: 1 });
await expect(run("bash", [SCRIPT])).rejects.toMatchObject({ code: 2 });
});
it("finds the real CHANGELOG's Unreleased section (the format stays parseable)", async () => {
// Guard against the repo's own changelog drifting away from the shape
// this script parses — that drift would only surface on a tag push.
const { stdout } = await run("bash", [
SCRIPT,
"Unreleased",
"CHANGELOG.md",
]);
expect(stdout.length).toBeGreaterThan(0);
});
});