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:
parent
fc3c341b6b
commit
d992f1833d
9 changed files with 706 additions and 23 deletions
74
.github/workflows/release.yml
vendored
Normal file
74
.github/workflows/release.yml
vendored
Normal 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
25
CHANGELOG.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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
|
||||
|
|
|
|||
119
install.sh
119
install.sh
|
|
@ -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"
|
||||
|
|
|
|||
43
scripts/changelog-section.sh
Normal file
43
scripts/changelog-section.sh
Normal 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"
|
||||
22
src/cli.ts
22
src/cli.ts
|
|
@ -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
299
test/install-sh.test.ts
Normal 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'"),
|
||||
});
|
||||
});
|
||||
});
|
||||
121
test/release-tooling.test.ts
Normal file
121
test/release-tooling.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue