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. agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `npm run check`, `npm run build`, and 7. **Checks must be green**: `npm run check`, `npm run build`, and
`npm test` locally mirror what CI runs. `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 ## 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: 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. 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) 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 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 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 # cast installer — intended for: curl -fsSL .../install.sh | bash
# #
# Downloads the cast repo tarball, installs the tree under $DEST, builds it, # Three channels from one script (cast#96, the flow shared with box#83):
# and puts a `cast` symlink on PATH via $BINDIR. Re-run any time to upgrade.
# #
# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and # CAST_REF unset → the latest GitHub release's prebuilt asset
# needs node — it is an API client, never something a server installs. # (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}" REPO="${CAST_REPO:-heavy-duty/cast}"
REF="${CAST_REF:-main}" REF="${CAST_REF:-}"
DEST="${CAST_HOME:-$HOME/.local/share/cast}" DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}" 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 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 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 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="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
@ -43,27 +51,90 @@ TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; } cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT 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)" # fetch_ok <url> <outfile> — download, true/false. -f keeps a 404 an
log "downloading $URL" # error instead of saving GitHub's error page as a tarball.
curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \ fetch_ok() {
|| die "failed to download $URL" 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" log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \ tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive" || die "failed to extract archive"
# GitHub archives extract to a single top-level dir like cast-<ref>/ # Both shapes carry exactly ONE top-level directory (GitHub names its
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'cast-*' | head -n1)" # archives <repo>-<ref>; release.yml stages cast-<version>). Deriving that
[ -n "$EXTRACTED" ] || die "could not find extracted cast-* directory in archive" # name is guesswork — it broke for real at box's repo rename — so take the
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $REPO@$REF correct?" # 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 ------------------------------- # --- build (source channel only) ---------------------------------------------
log "building (npm ci && npm run build)" if [ "$PREBUILT" -eq 1 ]; then
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \ # 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" || die "build failed"
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies" ( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
fi
# --- atomically replace $DEST -------------------------------------------------- # --- atomically replace $DEST --------------------------------------------------
log "installing into $DEST" log "installing into $DEST"
@ -71,7 +142,11 @@ rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")" mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$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 ---------------------------------------------------------- # --- put cast on PATH ----------------------------------------------------------
mkdir -p "$BINDIR" mkdir -p "$BINDIR"
@ -133,4 +208,4 @@ else
log "this shell does not have it yet — open a new one, or: source $PROFILE" log "this shell does not have it yet — open a new one, or: source $PROFILE"
fi 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 #!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { parse as parseYaml } from "yaml"; import { parse as parseYaml } from "yaml";
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js"; 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 server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
cast --version # version + install root
--state <dir> the state checkout holding environments.yaml, secrets/ and --state <dir> the state checkout holding environments.yaml, secrets/ and
.coolify.env (default: $CAST_STATE, else the cwd) .coolify.env (default: $CAST_STATE, else the cwd)
@ -1299,12 +1301,30 @@ async function runProject(
return { status: "applied", mutated }; 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> { async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2); const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") { if (command === "-h" || command === "--help" || command === "help") {
console.log(USAGE); console.log(USAGE);
return 0; return 0;
} }
if (command === "-V" || command === "--version") {
console.log(formatVersion());
return 0;
}
if (command === "apply" || command === "diff") { if (command === "apply" || command === "diff") {
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
args: rest, 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);
});
});