diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eb28601 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffda61a --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c95550..6852819 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 3b8565e..ab88fde 100644 --- a/README.md +++ b/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 diff --git a/install.sh b/install.sh index 7ff8a7e..bb532c5 100644 --- a/install.sh +++ b/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= → 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/; 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 — 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-/ -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 -; release.yml stages cast-). 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" diff --git a/scripts/changelog-section.sh b/scripts/changelog-section.sh new file mode 100644 index 0000000..21d1e97 --- /dev/null +++ b/scripts/changelog-section.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-section.sh [changelog-file] +# +# Print the body of CHANGELOG.md's `## ` 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 [changelog-file]" >&2; exit 2; } +[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; } + +# The heading is `## ` 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" diff --git a/src/cli.ts b/src/cli.ts index 2a04a02..5c7a122 100644 --- a/src/cli.ts +++ b/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 / --env [--path ] [-- cast server add --ip --key --env [--user root] [--port 22] cast smoke / --env [--project ] [--environment ] cast team [--env ] + cast --version # version + install root --state 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 { 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, diff --git a/test/install-sh.test.ts b/test/install-sh.test.ts new file mode 100644 index 0000000..a87e0f0 --- /dev/null +++ b/test/install-sh.test.ts @@ -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; +}; + +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, +): Promise { + 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) { + 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=)", () => { + 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'"), + }); + }); +}); diff --git a/test/release-tooling.test.ts b/test/release-tooling.test.ts new file mode 100644 index 0000000..5738b6f --- /dev/null +++ b/test/release-tooling.test.ts @@ -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); + }); +});