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>
This commit is contained in:
dan-claude-bot 2026-07-18 20:51:15 +00:00
parent fc3c341b6b
commit d992f1833d
9 changed files with 706 additions and 23 deletions

74
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: release
# A release is a PR, then a tag (cast#96, the flow shared with box#83):
# the `release: X.Y.Z` PR bumps package.json and stamps the CHANGELOG's
# Unreleased section; merging and pushing the bare `X.Y.Z` tag lands here.
# This workflow is where cast differs from its siblings: box/rig are pure
# bash, so the source tarball IS the package — cast compiles, so the build
# happens ONCE, here, and the release carries a prebuilt `cast-X.Y.Z.tgz`
# the installer can drop in without npm ci or tsc on the operator's machine.
on:
push:
# Bare X.Y.Z tags (the family scheme — box's 0.6.0 set the precedent,
# no `v` prefix). The glob is loose; the assert step below is the gate.
tags: ["[0-9]*.*.*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: assert tag == package.json version
# Fail loudly, create nothing: a tag that contradicts package.json
# would mint a release whose `cast --version` disagrees with its
# own name. The mismatch is a ritual error — retag, don't patch.
run: |
version="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$version" ]; then
echo "tag '$GITHUB_REF_NAME' != package.json version '$version' — refusing to release" >&2
exit 1
fi
- name: extract the release notes from CHANGELOG.md
# The release body is the curated section we wrote, never the
# auto-generated PR list. Missing/empty section fails the release —
# before the tag has minted anything.
run: bash scripts/changelog-section.sh "$GITHUB_REF_NAME" CHANGELOG.md > /tmp/release-notes.md
- name: build the package, once
run: |
npm ci
npm run check
npm run build
npm test
- name: assemble cast-${{ github.ref_name }}.tgz
# The runnable tree and nothing else: bin/, dist/, production
# node_modules/, package.json. Pruned AFTER the tests so what ships
# is the tree that passed. Top-level dir named like a GitHub
# archive's, so the installer handles both shapes identically.
run: |
npm prune --omit=dev
stage="$(mktemp -d)/cast-$GITHUB_REF_NAME"
mkdir -p "$stage"
cp -R bin dist node_modules package.json "$stage/"
tar -C "$(dirname "$stage")" -czf "cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME"
tar -tzf "cast-$GITHUB_REF_NAME.tgz" | head -5
- name: create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" "cast-$GITHUB_REF_NAME.tgz" \
--verify-tag \
--title "cast $GITHUB_REF_NAME" \
--notes-file /tmp/release-notes.md

25
CHANGELOG.md Normal file
View file

@ -0,0 +1,25 @@
# Changelog
History before versioning lives in git. Feature PRs land their entry in
`## Unreleased` as part of the PR; a release PR stamps that section with
the version and date (see cast#96 — the release flow shared with
heavy-duty/box#83).
## Unreleased
### Added
- **Versioned installs: tagged releases with a prebuilt dist asset** (#96) —
cast now has a release surface. `cast --version` prints the version from
`package.json` (the single source of truth — no separate `VERSION` file)
plus the install root. On a bare `X.Y.Z` tag push, `release.yml` asserts
the tag matches `package.json`, builds once in CI (`npm ci`, `npm run
build`, `npm test`, `npm prune --omit=dev`), tars the runnable tree into
`cast-X.Y.Z.tgz`, and creates the GitHub release with that version's
changelog section as the body and the tarball attached. The installer now
defaults to the **latest release asset** — resolved via the
`releases/latest` redirect, no API, no token — so a default install
compiles nothing on the operator's machine and answers "what cast is
this?" with a version. `CAST_REF=X.Y.Z` pins (uses that tag's asset when
it exists), `CAST_REF=main` stays the dev channel: build-from-source,
exactly the old path.

View file

@ -38,6 +38,21 @@ labels tell you where everything is without opening anything.
agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `npm run check`, `npm run build`, and
`npm test` locally mirror what CI runs.
8. **Feature PRs carry their changelog entry.** Add what changed to
`CHANGELOG.md`'s `## Unreleased` section as part of the PR — release
notes are written when the change lands, not reconstructed at release
time.
## Releasing
A release is a PR, then a tag (cast#96; the flow box#83 anchors for the
family). A `release: X.Y.Z` PR bumps `package.json` and stamps the
`## Unreleased` section with the version and date. Merge it, tag the merge
commit bare `X.Y.Z` (no `v` prefix), push the tag —
[release.yml](.github/workflows/release.yml) asserts the tag matches
`package.json`, builds `cast-X.Y.Z.tgz` once in CI, and creates the GitHub
release with that section as the body and the tarball attached. The
installer's default channel serves that asset.
## Labels — who sets what

View file

@ -21,6 +21,17 @@ are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
which is pure bash so it can run on a bare box — cast runs on **your** machine:
it is an API client, and a server should never install it.
By default that installs the **latest release**: a prebuilt `cast-X.Y.Z.tgz`
asset built once in CI — nothing compiles on your machine, and
`cast --version` names exactly what you got. `CAST_REF` selects the other
channels:
| | channel | what happens |
|---|---|---|
| unset | latest release | the newest tag's prebuilt asset |
| `CAST_REF=0.1.0` | pinned | that release's prebuilt asset |
| `CAST_REF=main` | dev | that ref's source tarball, built here (`npm ci` + `tsc` — needs `npm`) |
The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root)
and, if that directory is not already on your `PATH`, appends it to your shell
profile — `.zshrc`, `.bashrc`/`.bash_profile`, or `config.fish`, whichever your

View file

@ -3,14 +3,23 @@ set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash
#
# Downloads the cast repo tarball, installs the tree under $DEST, builds it,
# and puts a `cast` symlink on PATH via $BINDIR. Re-run any time to upgrade.
# Three channels from one script (cast#96, the flow shared with box#83):
#
# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and
# needs node — it is an API client, never something a server installs.
# CAST_REF unset → the latest GitHub release's prebuilt asset
# (cast-X.Y.Z.tgz). No npm ci, no tsc, no
# devDependencies on this machine — the build
# happened once, in CI, on the tag.
# CAST_REF=X.Y.Z → that release's asset, when one exists — a pin.
# CAST_REF=<branch> → build from source: the repo tarball for that
# ref (tags tried before branches), npm ci + tsc
# here. CAST_REF=main is the dev channel.
#
# Re-run any time to upgrade. Unlike rig (pure bash, runs on bare boxes),
# cast runs on YOUR machine and needs node — it is an API client, never
# something a server installs.
REPO="${CAST_REPO:-heavy-duty/cast}"
REF="${CAST_REF:-main}"
REF="${CAST_REF:-}"
DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}"
@ -26,7 +35,6 @@ die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
command -v tar >/dev/null 2>&1 || die "tar is required but was not found."
command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found."
command -v npm >/dev/null 2>&1 || die "npm is required but was not found."
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
@ -43,27 +51,90 @@ TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
# Resolve the latest release tag by following the releases/latest redirect
# and reading where it landed — no API, no token, no rate-limit pain
# (box#83's trick). GitHub answers .../releases/tag/<TAG>; anything else
# (a repo with no releases redirects nowhere useful) is a loud failure.
resolve_latest_tag() {
local landed
landed="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || return 1
case "$landed" in
*/releases/tag/*) printf '%s\n' "${landed##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
log "installing cast ($REPO@$REF)"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \
|| die "failed to download $URL"
# fetch_ok <url> <outfile> — download, true/false. -f keeps a 404 an
# error instead of saving GitHub's error page as a tarball.
fetch_ok() {
curl -fsSL "$1" -o "$2" 2>/dev/null
}
# --- acquire the tree --------------------------------------------------------
# PREBUILT=1 means the tarball is a CI-built runnable tree (bin/, dist/,
# production node_modules/, package.json) — nothing to compile here.
PREBUILT=0
SRCDESC=""
if [ -z "$REF" ]; then
TAG="$(resolve_latest_tag)" \
|| die "could not resolve the latest release of $REPO — no releases yet, or no network. CAST_REF=main installs from source."
URL="https://github.com/$REPO/releases/download/$TAG/cast-$TAG.tgz"
log "installing cast $TAG (latest release of $REPO)"
log "downloading $URL"
fetch_ok "$URL" "$TMPDIR/cast.tar.gz" \
|| die "failed to download the $TAG release asset: $URL"
PREBUILT=1
SRCDESC="$REPO@$TAG (release asset)"
else
# A pinned tag that has a release asset gets the asset — same bits as the
# default channel, just older. Everything else (a branch, a tag from
# before releases carried assets) falls back to build-from-source.
ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz"
if fetch_ok "$ASSET_URL" "$TMPDIR/cast.tar.gz"; then
log "installing cast $REF (pinned release asset)"
PREBUILT=1
SRCDESC="$REPO@$REF (release asset)"
else
log "no release asset for '$REF' — building from source"
for kind in tags heads; do
URL="https://github.com/$REPO/archive/refs/$kind/$REF.tar.gz"
if fetch_ok "$URL" "$TMPDIR/cast.tar.gz"; then
SRCDESC="$REPO@$REF (source, refs/$kind)"
break
fi
SRCDESC=""
done
[ -n "$SRCDESC" ] || die "no tag or branch named '$REF' in $REPO (tried the release asset, refs/tags and refs/heads)"
log "downloaded from refs — $SRCDESC"
fi
fi
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# GitHub archives extract to a single top-level dir like cast-<ref>/
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'cast-*' | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find extracted cast-* directory in archive"
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $REPO@$REF correct?"
# Both shapes carry exactly ONE top-level directory (GitHub names its
# archives <repo>-<ref>; release.yml stages cast-<version>). Deriving that
# name is guesswork — it broke for real at box's repo rename — so take the
# single directory, whatever it is called, and judge the tree by its content.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find the cast tree in the archive"
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $SRCDESC correct?"
# --- build (deps + tsc), then drop the dev deps -------------------------------
log "building (npm ci && npm run build)"
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \
|| die "build failed"
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
# --- build (source channel only) ---------------------------------------------
if [ "$PREBUILT" -eq 1 ]; then
# Verify the shape before touching $DEST: a prebuilt tree that cannot run
# is better refused here than discovered at `cast apply` time.
[ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ] \
|| die "the release asset is not a runnable tree (missing dist/ or node_modules/) — broken release? CAST_REF=main installs from source"
else
command -v npm >/dev/null 2>&1 || die "npm is required to build from source (CAST_REF=$REF) but was not found."
log "building (npm ci && npm run build)"
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \
|| die "build failed"
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
fi
# --- atomically replace $DEST --------------------------------------------------
log "installing into $DEST"
@ -71,7 +142,11 @@ rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
chmod +x "$DEST/bin/cast" "$DEST"/scripts/*.sh
chmod +x "$DEST/bin/cast"
# Source installs carry scripts/; the release asset deliberately does not.
if [ -d "$DEST/scripts" ]; then
find "$DEST/scripts" -name '*.sh' -exec chmod +x {} +
fi
# --- put cast on PATH ----------------------------------------------------------
mkdir -p "$BINDIR"
@ -133,4 +208,4 @@ else
log "this shell does not have it yet — open a new one, or: source $PROFILE"
fi
log "done — try: cast --help"
log "done ($SRCDESC) — try: cast --help"

View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-section.sh <version> [changelog-file]
#
# Print the body of CHANGELOG.md's `## <version>` section — everything
# between that heading and the next `## ` heading (or EOF), with the
# leading/trailing blank lines trimmed. release.yml uses this as the
# GitHub release body, so the notes are the curated prose we actually
# wrote, never an auto-generated PR list (cast#96 / box#83).
#
# Fails loudly when the section is missing or empty: a release with no
# written history is a release that skipped the changelog discipline,
# and the tag push is exactly the moment to catch that — before a
# release object exists.
version="${1:-}"
file="${2:-CHANGELOG.md}"
[ -n "$version" ] || { echo "usage: changelog-section.sh <version> [changelog-file]" >&2; exit 2; }
[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; }
# The heading is `## <version>` optionally followed by more (a date stamp:
# `## 0.1.0 — 2026-07-18`). Match on the version as the second word so the
# stamp's format never becomes load-bearing here.
section="$(awk -v ver="$version" '
/^## / { if (found) exit; if ($2 == ver) { found = 1; next } }
found { print }
END { exit found ? 0 : 3 }
' "$file")" || {
echo "changelog-section: no \"## $version\" section in $file" >&2
exit 1
}
# Trim leading blank lines; command substitution already ate the trailing ones.
section="$(printf '%s\n' "$section" | sed '/./,$!d')"
[ -n "$section" ] || {
echo "changelog-section: the \"## $version\" section in $file is empty" >&2
exit 1
}
printf '%s\n' "$section"

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { parse as parseYaml } from "yaml";
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
@ -125,6 +126,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>]
cast --version # version + install root
--state <dir> the state checkout holding environments.yaml, secrets/ and
.coolify.env (default: $CAST_STATE, else the cwd)
@ -1299,12 +1301,30 @@ async function runProject(
return { status: "applied", mutated };
}
// The version lives in package.json — the tree's single source of truth
// (cast#96, deliberately no separate VERSION file). dist/cli.js sits one
// level below it in a source checkout and in an installed release asset
// alike, so resolving from import.meta.url answers for both without
// caring how this tree got here. The install root rides along in the
// output (the family's shape — rig prints its ROOT too) because "which
// cast is this" and "where does it run from" are the same question when
// several trees exist on one machine.
function formatVersion(): string {
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version;
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
}
async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") {
console.log(USAGE);
return 0;
}
if (command === "-V" || command === "--version") {
console.log(formatVersion());
return 0;
}
if (command === "apply" || command === "diff") {
const { values, positionals } = parseArgs({
args: rest,

299
test/install-sh.test.ts Normal file
View file

@ -0,0 +1,299 @@
import { execFile } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
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 run the REAL install.sh — not a reimplementation of its
// logic — with curl and npm replaced by PATH shims, so every channel
// (latest asset, pinned asset, build-from-source) is exercised offline.
// The shims record what was requested; the assertions read the wire log
// and the resulting tree, the same way rig's cli.sh proves its installer.
const INSTALL_SH = join(process.cwd(), "install.sh");
// curl shim: answers from CAST_TEST_* env vars, appends every URL to
// CAST_TEST_CURL_LOG. Exit 22 is curl's own "-f saw an HTTP error".
const CURL_SHIM = `#!/usr/bin/env bash
set -euo pipefail
out=""; url=""
args=("$@")
i=0
while [ $i -lt \${#args[@]} ]; do
a="\${args[$i]}"
case "$a" in
-o) i=$((i+1)); out="\${args[$i]}" ;;
-w) i=$((i+1)) ;;
http*) url="$a" ;;
esac
i=$((i+1))
done
printf '%s\\n' "$url" >> "$CAST_TEST_CURL_LOG"
case "$url" in
*/releases/latest)
[ -n "\${CAST_TEST_LATEST:-}" ] || exit 22
printf '%s' "$CAST_TEST_LATEST"
;;
*/releases/download/*)
if [ -n "\${CAST_TEST_ASSET_FILE:-}" ] && [ "$url" = "\${CAST_TEST_ASSET_URL:-}" ]; then
cp "$CAST_TEST_ASSET_FILE" "$out"
else
exit 22
fi
;;
*/archive/refs/tags/*)
if [ -n "\${CAST_TEST_TAGS_TARBALL:-}" ]; then cp "$CAST_TEST_TAGS_TARBALL" "$out"; else exit 22; fi
;;
*/archive/refs/heads/*)
if [ -n "\${CAST_TEST_HEADS_TARBALL:-}" ]; then cp "$CAST_TEST_HEADS_TARBALL" "$out"; else exit 22; fi
;;
*) exit 22 ;;
esac
`;
// npm shim: logs every invocation; 'run build' produces dist/cli.js so a
// source install ends up runnable. The prebuilt channels get a POISONED
// npm instead — if the installer touches npm at all on an asset install,
// the install fails and so does the test.
const NPM_SHIM = `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
case "\${1:-}" in
ci) exit 0 ;;
run) mkdir -p dist && printf '// built by npm shim\\n' > dist/cli.js ;;
prune) exit 0 ;;
esac
`;
const POISONED_NPM = `#!/usr/bin/env bash
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
exit 97
`;
type Sandbox = {
root: string;
stubs: string;
dest: string;
bindir: string;
curlLog: string;
npmLog: string;
env: Record<string, string>;
};
function sandbox(opts: { poisonNpm: boolean }): 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 curlLog = join(root, "curl.log");
const npmLog = join(root, "npm.log");
writeFileSync(curlLog, "");
writeFileSync(npmLog, "");
writeFileSync(join(stubs, "curl"), CURL_SHIM);
writeFileSync(join(stubs, "npm"), opts.poisonNpm ? POISONED_NPM : NPM_SHIM);
chmodSync(join(stubs, "curl"), 0o755);
chmodSync(join(stubs, "npm"), 0o755);
return {
root,
stubs,
dest,
bindir,
curlLog,
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_CURL_LOG: curlLog,
CAST_TEST_NPM_LOG: npmLog,
},
};
}
// Build a .tgz fixture with a single top-level dir, like both real shapes.
async function makeTarball(
root: string,
topdir: string,
files: Record<string, string>,
): Promise<string> {
const stage = join(root, "fixtures", topdir);
for (const [rel, content] of Object.entries(files)) {
const abs = join(stage, rel);
mkdirSync(join(abs, ".."), { recursive: true });
writeFileSync(abs, content);
}
const tgz = join(root, "fixtures", `${topdir}.tgz`);
await run("tar", ["-C", join(root, "fixtures"), "-czf", tgz, topdir]);
return tgz;
}
const PREBUILT_FILES = {
"bin/cast": "#!/usr/bin/env bash\necho fake-cast\n",
"dist/cli.js": "// prebuilt in CI\n",
"node_modules/yaml/package.json": "{}",
"package.json": '{ "name": "cast", "version": "0.2.0" }\n',
};
const SOURCE_FILES = {
"bin/cast": "#!/usr/bin/env bash\necho fake-cast\n",
"package.json": '{ "name": "cast", "version": "0.3.0-dev" }\n',
"src/cli.ts": "// source only — dist/ does not exist until npm run build\n",
};
async function runInstaller(sb: Sandbox, extraEnv: Record<string, string>) {
return run("bash", [INSTALL_SH], {
env: { ...sb.env, ...extraEnv },
});
}
describe("install.sh — default channel (latest release asset)", () => {
it("resolves the latest tag via the redirect and installs the prebuilt tree without npm", async () => {
const sb = sandbox({ poisonNpm: true });
const asset = await makeTarball(sb.root, "cast-0.2.0", PREBUILT_FILES);
const { stdout } = await runInstaller(sb, {
CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases/tag/0.2.0",
CAST_TEST_ASSET_URL:
"https://github.com/heavy-duty/cast/releases/download/0.2.0/cast-0.2.0.tgz",
CAST_TEST_ASSET_FILE: asset,
});
expect(stdout).toContain(
"installing cast 0.2.0 (latest release of heavy-duty/cast)",
);
// The tree landed, prebuilt: dist/ came from the tarball, not a build.
expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain(
"prebuilt in CI",
);
expect(existsSync(join(sb.dest, "node_modules/yaml/package.json"))).toBe(
true,
);
expect(readlinkSync(join(sb.bindir, "cast"))).toBe(
join(sb.dest, "bin/cast"),
);
// npm is poisoned — a single invocation would have failed the install.
expect(readFileSync(sb.npmLog, "utf8")).toBe("");
});
it("dies loudly when there is no release to resolve, pointing at CAST_REF=main", async () => {
const sb = sandbox({ poisonNpm: true });
await expect(runInstaller(sb, {})).rejects.toMatchObject({
stderr: expect.stringContaining("CAST_REF=main installs from source"),
});
expect(existsSync(sb.dest)).toBe(false);
});
it("dies when the redirect lands somewhere that is not a tag page", async () => {
const sb = sandbox({ poisonNpm: true });
await expect(
runInstaller(sb, {
CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases",
}),
).rejects.toMatchObject({
stderr: expect.stringContaining("could not resolve the latest release"),
});
});
});
describe("install.sh — pinned channel (CAST_REF=X.Y.Z)", () => {
it("uses that tag's release asset and never falls through to a source build", async () => {
const sb = sandbox({ poisonNpm: true });
const asset = await makeTarball(sb.root, "cast-0.1.0", {
...PREBUILT_FILES,
"package.json": '{ "name": "cast", "version": "0.1.0" }\n',
});
const { stdout } = await runInstaller(sb, {
CAST_REF: "0.1.0",
CAST_TEST_ASSET_URL:
"https://github.com/heavy-duty/cast/releases/download/0.1.0/cast-0.1.0.tgz",
CAST_TEST_ASSET_FILE: asset,
});
expect(stdout).toContain("installing cast 0.1.0 (pinned release asset)");
const urls = readFileSync(sb.curlLog, "utf8");
// No latest-resolution, no archive fallbacks — the pin answered.
expect(urls).not.toContain("/releases/latest");
expect(urls).not.toContain("/archive/refs/");
expect(readFileSync(sb.npmLog, "utf8")).toBe("");
});
it("refuses a prebuilt asset that is not a runnable tree, leaving the old install alone", async () => {
const sb = sandbox({ poisonNpm: true });
// An asset missing dist/ — a broken release.
const asset = await makeTarball(sb.root, "cast-0.4.0", {
"bin/cast": "#!/usr/bin/env bash\n",
"package.json": "{}",
});
// A previous install that must survive the refused upgrade.
mkdirSync(join(sb.dest, "bin"), { recursive: true });
writeFileSync(join(sb.dest, "bin/cast"), "#!/usr/bin/env bash\necho old\n");
await expect(
runInstaller(sb, {
CAST_REF: "0.4.0",
CAST_TEST_ASSET_URL:
"https://github.com/heavy-duty/cast/releases/download/0.4.0/cast-0.4.0.tgz",
CAST_TEST_ASSET_FILE: asset,
}),
).rejects.toMatchObject({
stderr: expect.stringContaining("not a runnable tree"),
});
// The shape check fired BEFORE rm -rf $DEST — the old tree survives.
expect(readFileSync(join(sb.dest, "bin/cast"), "utf8")).toContain(
"echo old",
);
});
});
describe("install.sh — dev channel (CAST_REF=<branch>)", () => {
it("falls back asset → refs/tags → refs/heads and builds from source", async () => {
const sb = sandbox({ poisonNpm: false });
const src = await makeTarball(sb.root, "cast-main", SOURCE_FILES);
const { stdout } = await runInstaller(sb, {
CAST_REF: "main",
CAST_TEST_HEADS_TARBALL: src,
});
expect(stdout).toContain(
"no release asset for 'main' — building from source",
);
const urls = readFileSync(sb.curlLog, "utf8").trim().split("\n");
expect(urls).toEqual([
"https://github.com/heavy-duty/cast/releases/download/main/cast-main.tgz",
"https://github.com/heavy-duty/cast/archive/refs/tags/main.tar.gz",
"https://github.com/heavy-duty/cast/archive/refs/heads/main.tar.gz",
]);
// The build ran here — ci, build, prune — and produced the dist tree.
const npm = readFileSync(sb.npmLog, "utf8");
expect(npm).toContain("npm ci");
expect(npm).toContain("npm run build");
expect(npm).toContain("npm prune --omit=dev");
expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain(
"built by npm shim",
);
});
it("dies when the ref exists nowhere (asset, tags, heads all miss)", async () => {
const sb = sandbox({ poisonNpm: true });
await expect(
runInstaller(sb, { CAST_REF: "no-such-ref" }),
).rejects.toMatchObject({
stderr: expect.stringContaining("no tag or branch named 'no-such-ref'"),
});
});
});

View file

@ -0,0 +1,121 @@
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);
});
});