Merge pull request #101 from dan-claude-bot/feat/release-flow

feat: release flow — tagged releases with a prebuilt dist asset (#96)
This commit is contained in:
Daniel Marin 2026-07-19 00:53:03 +01:00 committed by GitHub
commit 525eac467d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 795 additions and 29 deletions

31
.github/scripts/release-notes.sh vendored Normal file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
# section of the changelog: every line between its '## <version> — <date>'
# header and the next '## '. This is what release.yml hands to
# 'gh release create', so the release notes are the curated prose we wrote,
# not the PR list GitHub would generate (#96; box#83's extraction). Fails
# loudly when the section is missing or empty — a tag without its changelog
# section is a release ritual skipped, and an empty release body would paper
# over it.
#
# A file of its own (not inlined in release.yml) so test/release.test.ts
# drives the same extraction against fixtures and the real CHANGELOG.md.
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
# $2 of a section header ('## 0.1.0 — 2026-07-18') is the bare version —
# compared WHOLE, so 0.1.0 can never match a 0.1.0-rc1 section (or vice
# versa), and no regex-escaping of dots. sed drops the blank padding under
# the header; the command substitution eats the trailing blanks.
notes="$(awk -v ver="$ver" '
/^## / { grab = ($2 == ver); next }
grab { print }
' "$changelog" | sed '/./,$!d')"
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#96)" >&2; exit 1; }
printf '%s\n' "$notes"

View file

@ -21,7 +21,7 @@ jobs:
- run: npm run build
- run: npm test
- name: installer is valid bash
run: bash -n install.sh bin/cast scripts/*.sh
run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh

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

@ -0,0 +1,68 @@
name: release
# The release publisher (#96; box#83's design), on a bare X.Y.Z tag push —
# no 'v' prefix, box's and rig's tag scheme. Two facts, then one act: the
# tag must name package.json's own version (a mismatch fails loudly and
# creates NOTHING — a wrong release is worse than a missing one), and the
# release body is that version's CHANGELOG.md section
# (.github/scripts/release-notes.sh, shared with test/release.test.ts) —
# the curated prose, not the generated PR list.
#
# Where cast differs from its siblings: the release carries a PREBUILT
# asset. box and rig are pure bash, so GitHub's source tarball for the tag
# IS their package; cast's source tarball is not runnable — it needs npm ci
# and tsc first. So the build happens ONCE, here, and the asset is the
# runnable tree: bin/, dist/, production node_modules/, package.json.
on:
push:
# Every tag, not a shape filter (box's and rig's precedent): a tag that
# mismatches package.json — a habitual v0.1.0, a typo — must fail the
# assert LOUDLY below, not be silently skipped by a pattern that didn't
# match.
tags: ["**"]
permissions:
contents: write # gh release create
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: the tag must name package.json's version
run: |
ver="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
- name: release notes — the version's own CHANGELOG.md section
# release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created.
run: |
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: build the prebuilt dist asset
# Build ONCE, in CI — the whole point of the asset (#96): the
# installer's release channels never run npm or tsc. Deliberately no
# check/tests here: ci.yml already gated the merge commit this tag
# names, and the test suite needs `age`, which this runner does not
# install. The staged tree is exactly what an install needs to run.
run: |
npm ci
npm run build
npm prune --omit=dev
mkdir -p "$RUNNER_TEMP/stage/cast-$GITHUB_REF_NAME"
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$GITHUB_REF_NAME/"
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME"
- name: create the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" --verify-tag \
--title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" \
"$RUNNER_TEMP/cast-$GITHUB_REF_NAME.tgz"

42
CHANGELOG.md Normal file
View file

@ -0,0 +1,42 @@
# Changelog
History before 0.1.0 lives in git — cast has said `0.1.0` in `package.json`
since its first commit, but grew its release surface (this file,
`cast --version`, tagged releases with a prebuilt asset) on the way to
actually cutting it, and this file starts there.
## Unreleased
### Added
- **Tagged releases with a prebuilt dist asset, and an installer that
installs them** (#96) — the cast half of the flow designed in
heavy-duty/box#83, plus the piece unique to cast: a **prebuilt asset**,
because cast is the one repo where the source tarball is *not* the
package. A release is a PR, then a tag: the `release: X.Y.Z` PR bumps
`package.json` (and `package-lock.json`) and stamps this file's Unreleased
section with version + date; the merge commit is tagged bare `X.Y.Z`
(box's tag scheme — no `v` prefix). `release.yml` turns the tag into the
GitHub release — after asserting tag == `package.json` version (a
mismatch fails loudly and creates nothing) — with that version's section
of this file as the body, extracted by the same
`.github/scripts/release-notes.sh` the test harness drives, and with the
runnable tree attached as `cast-X.Y.Z.tgz`: `bin/`, compiled `dist/`,
production `node_modules/`, `package.json`, built once in CI
(`npm ci && npm run build && npm prune --omit=dev`). `install.sh` now
defaults to the **latest release**: the tag is resolved by following the
`releases/latest` redirect and reading the `Location` header — no API, no
token — and the download is that release's asset, so **no `npm ci`, no
`tsc`, no devDependencies ever run on the operator's machine**. `CAST_REF`
picks the other two channels: a tag pins a release (its asset first,
source as the fallback for a ref that has none — `refs/tags` outranks a
same-named branch), a branch (`CAST_REF=main`) tracks the development
tree and is the one channel that still builds from source, the only place
`npm` is required. Until 0.1.0 is cut the default channel has nothing to
resolve and dies saying exactly that, naming `CAST_REF=main` as the way
to install today — it never falls back to main silently, because "I
installed the latest release" must not quietly mean "I installed whatever
main was that second". The channel only decides *which* tree arrives and
whether it is built here — whatever it fetched lands in the versioned
layout (`versions/<package.json version>`, `current` flipped atomically)
like any other install.

View file

@ -38,6 +38,37 @@ 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 land their changelog entry as part of the PR** (box's
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
that section becomes the release notes verbatim when a release is cut.
## Releasing
A release is a PR, then a tag ([#96](https://github.com/heavy-duty/cast/issues/96);
box#83's design):
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s
`version` (and `package-lock.json`; `npm install --package-lock-only`
keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as
`## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR.
2. Merge, tag the merge commit bare `X.Y.Z` (no `v` prefix — box's tag
scheme), push the tag. [release.yml](.github/workflows/release.yml)
takes it from there: it asserts tag == `package.json` version (a
mismatch fails loudly and creates nothing), extracts that version's
changelog section as the release body
([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh) —
a missing or empty section refuses the release), builds the package once
(`npm ci && npm run build && npm prune --omit=dev`), and attaches the
runnable tree — `bin/`, `dist/`, production `node_modules/`,
`package.json` — as `cast-X.Y.Z.tgz`. That asset is what the installer's
release channels download: the build happens once, in CI, never on an
operator's machine.
3. **Right after the release, a follow-up PR bumps `package.json` to
`X.Y.(Z+1)-dev`** (and `package-lock.json` with it) — box#90's step of
the family ritual. Installs are versioned by the tree's `package.json`
version, so a `CAST_REF=main` install between releases must land as
`versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must
not impersonate the release it merely descends from.
## Labels — who sets what

View file

@ -16,8 +16,34 @@ infrastructure. It reads what you point it at and stores nothing, ever.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/cast/main/install.sh | bash
```
That installs the **latest release**, and a cast release is a **prebuilt
asset**: the installer resolves the newest tag by following GitHub's
`releases/latest` redirect (no API, no token) and downloads that release's
`cast-<tag>.tgz``bin/`, compiled `dist/`, production `node_modules/`,
`package.json`, built once in CI — so **no `npm ci`, no `tsc`, no
devDependencies ever run on your machine**. Three channels from the same
script; `CAST_REF` picks
([#96](https://github.com/heavy-duty/cast/issues/96)):
```sh
curl -fsSL .../install.sh | bash # the latest release (prebuilt)
curl -fsSL .../install.sh | CAST_REF=0.1.0 bash # pinned to a release
curl -fsSL .../install.sh | CAST_REF=main bash # the development tree, built from source
```
A set ref tries its release asset first, then falls back to source —
`refs/tags` before `refs/heads`, so a tag outranks a branch of the same name
— and only the source path needs `npm`.
> **Transitional, until 0.1.0 is cut** (right after cast#96 lands): cast has
> no GitHub release yet, so the default channel has nothing to resolve — it
> **fails loudly** naming `CAST_REF=main` as the way to install today, and
> never silently falls back to main. Once 0.1.0 exists, the plain
> `curl | bash` above is the normal path.
Needs `node` >= 22.12 and [`age`](https://github.com/FiloSottile/age) (secrets
are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
are decrypted by shelling out to it); `npm` only if you install from source.
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.
@ -33,10 +59,13 @@ cast uninstall [<version>|--all] # remove one non-current version, or everythi
```
Re-running the installer with an already-installed version is a converging
no-op (`CAST_REINSTALL=1` rebuilds and replaces that version's tree); a new
version installs beside the old one and becomes the default — `cast use <old>`
no-op (`CAST_REINSTALL=1` reinstalls that version's tree); a new version
installs beside the old one and becomes the default — `cast use <old>`
switches back. A pre-versioning flat install is migrated in place on the next
run. `CAST_REF=<branch>` installs from another branch instead of `main`.
run. The channel only decides **which** tree arrives and whether it is built
here; every channel lands it the same way, in `versions/<its package.json
version>` — a prebuilt `0.1.0` asset and a `CAST_REF=main` source build sit
side by side like any two versions.
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

View file

@ -3,9 +3,8 @@ set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash
#
# Downloads the cast repo tarball, builds it, and installs it into the
# VERSIONED layout under $DEST (box#79's layout, ported the way rig#36
# ported it):
# Fetches a cast tree and installs it into the VERSIONED layout under $DEST
# (box#79's layout, ported the way rig#36 ported it):
#
# $DEST/versions/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
@ -24,6 +23,25 @@ set -euo pipefail
# The version IS the tree's package.json version — cast's single source of
# truth (deliberately no separate VERSION file).
#
# WHICH tree, and whether it is built here, is the channel's business — three
# channels from this one script (#96; box#83's design, plus the piece unique
# to cast: the PREBUILT release asset, because cast is the one repo where the
# source tarball is not the package):
#
# CAST_REF unset the latest RELEASE — the tag is resolved from the
# releases/latest redirect, the download is that
# release's prebuilt cast-<tag>.tgz asset (bin/,
# compiled dist/, production node_modules/): no npm,
# no tsc, no devDependencies on this machine
# CAST_REF=<tag> that release, pinned — its asset first, source as
# the fallback for a ref that has none
# CAST_REF=<branch> the development tree, built from source here —
# CAST_REF=main is the dev channel, and (with local
# source installs) the one place npm is required
#
# Whatever the channel fetched still lands the same way: in versions/<its
# package.json version>, with the current symlink flipped atomically.
#
# CAST_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
# downloading — for CI and the test suite, so what lands is the code under
# review.
@ -32,7 +50,7 @@ set -euo pipefail
# 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:-}" # empty = the latest release, resolved below
DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}"
@ -66,15 +84,34 @@ pkg_version() {
CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true
}
# --- the release channels (#96; box#83's design, near-verbatim) --------------
# resolve_latest_tag <owner/repo> — print the latest RELEASE tag, resolved by
# following the releases/latest redirect and reading the Location header
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
# rate-limit pain. A repo with no releases redirects to /releases — not to
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
# and the CALLER owns the loud story. test/release.test.ts drives the whole
# installer, this function included, against a stubbed curl.
resolve_latest_tag() {
local loc
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1
case "$loc" in
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
# --- prerequisites -----------------------------------------------------------
# curl only when something must be downloaded — a local CAST_INSTALL_SOURCE
# needs none, which is what lets the test suite drive REAL installs offline.
# npm is deliberately NOT required here: the release-asset channel installs a
# tree that was built once, in CI. build_tree checks for it, in the only
# paths that build (local source, and source downloads).
if [ -z "${CAST_INSTALL_SOURCE:-}" ]; then
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
fi
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))."
@ -93,9 +130,25 @@ if ! command -v age >/dev/null 2>&1; then
warn " Fedora: sudo dnf install age | macOS: brew install age"
fi
# --- pick the channel --------------------------------------------------------
# No CAST_REF → the latest release. While no release exists (cast cuts its
# first, 0.1.0, right after #96 lands), this channel must FAIL, loudly and
# with the way out — never silently fall back to main: "I installed the
# latest release" must not quietly mean "I installed whatever main was that
# second". Resolved before anything on disk is touched (the flat-install
# migration included), so a refusal here has zero side effects.
if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
SRCDESC="local source $CAST_INSTALL_SOURCE"
else
if [ -z "$REF" ]; then
log "resolving the latest release of $REPO"
if ! REF="$(resolve_latest_tag "$REPO")"; then
warn "could not resolve the latest release of $REPO — either no release exists yet, or GitHub was unreachable."
warn "(cast has no release until 0.1.0 is cut — heavy-duty/cast#96. Until then, install the development tree explicitly.)"
die "set CAST_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | CAST_REF=main bash"
fi
log "latest release: $REF"
fi
SRCDESC="$REPO@$REF"
fi
@ -143,6 +196,7 @@ cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
# --- acquire the tree --------------------------------------------------------
PREBUILT="" # set when the tree came from a release asset (already built)
if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
SRC="$CAST_INSTALL_SOURCE"
INSTALLED_FROM="local:$SRC"
@ -163,22 +217,51 @@ if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
fi
else
INSTALLED_FROM="$REPO@$REF"
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing cast ($REPO@$REF)"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \
|| die "failed to download $URL"
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# A release's package is its PREBUILT asset (#96) — bin/, compiled dist/,
# production node_modules/, package.json, built once by release.yml — so
# the asset is tried first for every ref. Only a ref the OPERATOR named may
# fall back to source: a resolved latest release without its asset is a
# broken release, not a reason to start compiling here.
ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz"
log "downloading $ASSET_URL"
if curl -fsSL "$ASSET_URL" -o "$TMPDIR/cast.tgz"; then
PREBUILT=1
INSTALLED_FROM="$REPO@$REF (release asset)"
log "extracting release asset"
tar -xzf "$TMPDIR/cast.tgz" -C "$TMPDIR" \
|| die "failed to extract $ASSET_URL"
else
[ -n "${CAST_REF:-}" ] \
|| die "release $REF has no cast-$REF.tgz asset (or GitHub was unreachable) — refusing to build the release from source. Report the broken release, or pick a ref yourself: CAST_REF=<tag> pins one, CAST_REF=main builds the development tree."
# GitHub names the archive's top dir <repo>-<ref> — deriving that name is
# guesswork (it broke for real at box's repo rename). The tarball has
# exactly ONE top-level directory: take the directory, whatever it is
# called, and let the bin/cast check below judge whether it is the right
# tree.
# The source fallback, tag first (the pin must win over a branch that
# happens to share its name), branch second — which keeps CAST_REF=main
# the dev channel. Source needs a build (build_tree below, the only
# place npm is used).
INSTALLED_FROM="$REPO@$REF"
got=""
for URL in "https://github.com/$REPO/archive/refs/tags/$REF.tar.gz" \
"https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"; do
log "no prebuilt asset for '$REF' — trying source: $URL"
if curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz"; then
got="$URL"
break
fi
done
[ -n "$got" ] || die "failed to download $REPO@$REF — no release asset, and '$REF' is neither a tag nor a branch."
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
fi
# GitHub names a source archive's top dir <repo>-<ref> and release.yml
# stages the asset's as cast-<tag> — deriving either name is guesswork (it
# broke for real at box's repo rename). Both tarball shapes have exactly
# ONE top-level directory: take the directory, whatever it is called, and
# let the bin/cast check below judge whether it is the right tree.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
fi
[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC"
@ -191,6 +274,14 @@ new_ver="$(pkg_version "$EXTRACTED")"
[ -n "$new_ver" ] || die "source has no package.json version — cannot install it as a version"
valid_version "$new_ver" || die "the source's package.json version is not a sane directory name: '$new_ver'"
# Sanity-check a prebuilt asset BEFORE anything lands in $DEST: a runnable
# tree carries the compiled dist/ and its production node_modules/. Refusing
# here leaves whatever is already installed exactly as it was.
if [ -n "$PREBUILT" ]; then
{ [ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ]; } \
|| die "the release asset is not a runnable cast tree (missing dist/ or node_modules/) — refusing to install it. Report the broken release, or build from source: CAST_REF=main."
fi
set_exec() { # $1 = a cast tree: the executable bits install.sh owns
chmod +x "$1/bin/cast"
if [ -d "$1/scripts" ]; then
@ -200,8 +291,16 @@ set_exec() { # $1 = a cast tree: the executable bits install.sh owns
# Build the tree IN PLACE, in the temp workspace — deps + tsc, then drop the
# dev deps. Landing in versions/ happens after, by rename: a half-built tree
# never sits where the version chain can resolve to it.
# never sits where the version chain can resolve to it. A PREBUILT release
# asset skips this whole step — that build already happened, once, in CI —
# which is also why npm is checked here and not with the prerequisites: the
# source paths are the only ones that need it.
build_tree() { # $1 = the tree to build
if [ -n "$PREBUILT" ]; then
log "prebuilt release asset — nothing to build here"
return 0
fi
command -v npm >/dev/null 2>&1 || die "npm is required to build cast from source (only the release-asset channel installs without it)."
log "building (npm ci && npm run build)"
( cd "$1" && npm ci --silent && npm run build --silent ) \
|| die "build failed"

View file

@ -279,8 +279,11 @@ describe("install.sh — the versioned layout", () => {
it("downloads refs/heads/<ref> when no local source is given", async () => {
const sb = sandbox();
// A curl shim standing in for GitHub: serves the fixture tarball and
// logs the URL it was asked for.
// A curl shim standing in for GitHub: logs every URL asked for, and
// serves the fixture tarball ONLY for the refs/heads URL — a branch has
// no release asset and no tag, which is exactly the shape CAST_REF=main
// meets in the wild. (The channels themselves — assets, refusals, the
// latest-release resolution — are test/release.test.ts's.)
sourceTree(sb, "0.5.0");
await run("tar", [
"-C",
@ -302,15 +305,22 @@ for a in "$@"; do
prev="$a"
done
printf '%s\\n' "$url" >> "${curlLog}"
cp "${join(sb.root, "src.tgz")}" "$out"
case "$url" in
*/archive/refs/heads/*) cp "${join(sb.root, "src.tgz")}" "$out" ;;
*) exit 22 ;;
esac
`,
);
chmodSync(join(stubs, "curl"), 0o755);
const { stdout } = await install(sb, { CAST_REF: "dev-branch" });
expect(readFileSync(curlLog, "utf8").trim()).toBe(
// The channel's try-order, in full: the release asset, then refs/tags,
// then refs/heads — where a branch finally answers.
expect(readFileSync(curlLog, "utf8").trim().split("\n")).toEqual([
"https://github.com/heavy-duty/cast/releases/download/dev-branch/cast-dev-branch.tgz",
"https://github.com/heavy-duty/cast/archive/refs/tags/dev-branch.tar.gz",
"https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz",
);
]);
expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)");
expect(
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"),

456
test/release.test.ts Normal file
View file

@ -0,0 +1,456 @@
import { execFileSync, spawn } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// The release flow (#96), proven offline. Two surfaces: the changelog-section
// extraction release.yml publishes (.github/scripts/release-notes.sh), and
// the installer's three channels — REAL install.sh runs against throwaway
// roots, with a stub curl on PATH standing in for GitHub and a POISONED npm
// proving the release channels never build. Nothing here touches the
// network. (`cast --version` itself is test/version-cli.test.ts's; the
// versioned LAYOUT every channel lands in is test/install-sh.test.ts's —
// here the layout is asserted only where a channel decides what fills it.)
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const NOTES = join(ROOT, ".github/scripts/release-notes.sh");
function run(
cmd: string,
args: string[],
env: Record<string, string> = {},
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...env },
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
// --- release-notes.sh — the extraction release.yml publishes ----------------
// A fixture changelog carrying every boundary: an Unreleased section that
// must never leak into a release, adjacent versions, a version that prefixes
// another (0.7.0 vs 0.7.0-rc1), and a stamped-but-empty section that must
// refuse.
const FIXTURE = `# Changelog
Intro prose that belongs to no section.
## Unreleased
- **Not yet released** must never appear in a release body.
## 0.7.0 2026-07-20
### Added
- **The seven-oh entry** prose for 0.7.0, and only 0.7.0.
## 0.7.0-rc1 2026-07-19
- **The rc entry** must not ride along with 0.7.0.
## 0.6.0 2026-07-18
- **The six-oh entry** the previous release's prose.
## 0.5.0 2026-07-15
`;
describe("release-notes.sh", () => {
const work = mkdtempSync(join(tmpdir(), "cast-relnotes-"));
const fix = join(work, "CHANGELOG.md");
writeFileSync(fix, FIXTURE);
const notes = (ver: string, file = fix) => run("bash", [NOTES, ver, file]);
it("prints the asked-for version's section, subheaders included", async () => {
const r = await notes("0.7.0");
expect(r.code).toBe(0);
expect(r.output).toContain("The seven-oh entry");
expect(r.output).toContain("### Added");
});
it("stops at the next section and never prints a header", async () => {
const r = await notes("0.7.0");
expect(r.output).not.toContain("The rc entry");
expect(r.output).not.toContain("six-oh");
expect(r.output).not.toMatch(/^## /m);
});
it("never leaks Unreleased into a release body", async () => {
const r = await notes("0.7.0");
expect(r.output).not.toContain("Not yet released");
});
it("matches the version WHOLE — 0.7.0-rc1 is its own section", async () => {
const r = await notes("0.7.0-rc1");
expect(r.code).toBe(0);
expect(r.output).toContain("The rc entry");
expect(r.output).not.toContain("seven-oh");
});
it("an adjacent older version still resolves", async () => {
const r = await notes("0.6.0");
expect(r.code).toBe(0);
expect(r.output).toContain("six-oh");
});
it("a missing version refuses by name, citing the ritual", async () => {
const r = await notes("9.9.9");
expect(r.code).toBe(1);
expect(r.output).toContain("no section for '9.9.9'");
expect(r.output).toContain("#96");
});
it("a stamped-but-EMPTY section refuses", async () => {
const r = await notes("0.5.0");
expect(r.code).toBe(1);
expect(r.output).toContain("no section for '0.5.0'");
});
it("no version argument is a usage error", async () => {
const r = await run("bash", [NOTES]);
expect(r.code).toBe(2);
expect(r.output).toContain("usage:");
});
it("a missing changelog refuses by path", async () => {
const r = await notes("1.0.0", join(work, "nope.md"));
expect(r.code).toBe(1);
expect(r.output).toContain("no such file");
});
// The REAL changelog: its Unreleased section must extract non-empty with
// the exact tool release.yml runs — the guard against header-format drift.
it("the real CHANGELOG.md's Unreleased section extracts", async () => {
const r = await notes("Unreleased", join(ROOT, "CHANGELOG.md"));
expect(r.code).toBe(0);
expect(r.output).toContain("#96");
});
});
// --- release.yml — the wiring, pinned --------------------------------------
// The workflow itself only runs on a tag push upstream, so its load-bearing
// pieces are pinned here, fail-closed (the house discipline: the labels
// harness greps its workflow the same way).
describe("release.yml", () => {
const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8");
it("triggers on EVERY tag — a mismatch must fail loudly, not be pattern-skipped", () => {
expect(RY).toContain('tags: ["**"]');
});
it("asserts tag == package.json version, and the assert precedes the create", () => {
expect(RY).toContain('require("./package.json").version');
expect(RY).toContain("creating nothing");
expect(RY.indexOf("creating nothing")).toBeLessThan(
RY.indexOf('gh release create "$GITHUB_REF_NAME"'),
);
});
it("the body comes from the shared extraction script", () => {
expect(RY).toContain(".github/scripts/release-notes.sh");
});
it("the release is bound to the pushed tag (--verify-tag)", () => {
expect(RY).toContain("--verify-tag");
});
it("builds the prod-only tree once and attaches it as the asset", () => {
expect(RY).toContain("npm prune --omit=dev");
expect(RY).toContain("cp -R bin dist node_modules package.json");
expect(RY).toContain("cast-$GITHUB_REF_NAME.tgz");
});
it("runs no tests — ci.yml gated the merge commit already", () => {
expect(RY).not.toContain("npm test");
expect(RY).not.toContain("npm run check");
});
});
// --- the installer's three channels, driven for real ------------------------
// Full install.sh runs against throwaway roots. The curl on PATH is a stub
// scripted via env (CURL_*); the npm on PATH is POISONED (exits 97) unless a
// test opts into the stub build — so any release-channel install that
// touches npm fails its assertion by failing the install.
const STUB = mkdtempSync(join(tmpdir(), "cast-stub-"));
writeFileSync(
join(STUB, "curl"),
`#!/usr/bin/env bash
# Stub curl never the network. Scripted via env:
# CURL_FAIL_ALL nonempty -> every call exits 6 (network down)
# CURL_REDIRECT what -w %{redirect_url} answers (the HEAD probe)
# CURL_SERVE_SUBSTR substring a download URL must carry to succeed
# CURL_TARBALL copied to -o's target on a successful download
# CURL_LOG every URL asked for, one per line, appended
url="" out="" probe=0
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-w) probe=1; shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
if [ -n "\${CURL_LOG:-}" ]; then printf '%s\\n' "$url" >> "$CURL_LOG"; fi
if [ -n "\${CURL_FAIL_ALL:-}" ]; then exit 6; fi
if [ "$probe" -eq 1 ]; then printf '%s' "\${CURL_REDIRECT:-}"; exit 0; fi
case "$url" in
*"\${CURL_SERVE_SUBSTR:-/__nothing_succeeds__/}"*)
cp "\${CURL_TARBALL:?}" "\${out:?}"; exit 0 ;;
*) exit 22 ;;
esac
`,
);
writeFileSync(
join(STUB, "npm"),
`#!/usr/bin/env bash
# Poisoned npm: the release-asset channels must NEVER build (#96). A test
# that legitimately builds from source sets NPM_EXIT=0; every invocation is
# logged so order can be asserted.
if [ -n "\${NPM_LOG:-}" ]; then printf '%s\\n' "$*" >> "$NPM_LOG"; fi
exit "\${NPM_EXIT:-97}"
`,
);
chmodSync(join(STUB, "curl"), 0o755);
chmodSync(join(STUB, "npm"), 0o755);
// A fabricated tree shaped like release.yml's staging (one top-level
// cast-<ref>/ dir — the same shape GitHub's source tarballs have), tarred.
// Version 9.9.9 so nothing collides with the tree under test.
function makeTarball(
work: string,
ref: string,
opts: { dist?: boolean; nodeModules?: boolean } = {},
): string {
const top = join(work, `cast-${ref}`);
mkdirSync(join(top, "bin"), { recursive: true });
cpSync(join(ROOT, "bin/cast"), join(top, "bin/cast"));
chmodSync(join(top, "bin/cast"), 0o755);
if (opts.dist !== false) {
mkdirSync(join(top, "dist"), { recursive: true });
writeFileSync(join(top, "dist/cli.js"), `console.log("cast 9.9.9");\n`);
}
if (opts.nodeModules !== false) {
mkdirSync(join(top, "node_modules"), { recursive: true });
writeFileSync(join(top, "node_modules/.package-lock.json"), "{}");
}
writeFileSync(
join(top, "package.json"),
JSON.stringify({ name: "cast", version: "9.9.9" }),
);
const tgz = join(work, `cast-${ref}.tgz`);
execFileSync("tar", ["-C", work, "-czf", tgz, `cast-${ref}`]);
return tgz;
}
type Install = {
code: number;
output: string;
dest: string;
bin: string;
curlLog: string[];
npmLog: string[];
};
async function runInstall(
env: Record<string, string>,
opts: { preexistingDest?: boolean } = {},
): Promise<Install> {
const work = mkdtempSync(join(tmpdir(), "cast-inst-"));
const dest = join(work, "dest");
const bin = join(work, "bin");
const curlLog = join(work, "curl.log");
const npmLog = join(work, "npm.log");
if (opts.preexistingDest) {
mkdirSync(dest, { recursive: true });
writeFileSync(join(dest, "MARKER"), "the previous install\n");
}
mkdirSync(join(work, "home"), { recursive: true });
const r = await run("bash", [join(ROOT, "install.sh")], {
PATH: `${STUB}:${process.env.PATH}`,
HOME: join(work, "home"),
CAST_HOME: dest,
CAST_BIN: bin,
CAST_NO_MODIFY_PATH: "1",
CURL_LOG: curlLog,
NPM_LOG: npmLog,
...env,
});
const lines = (f: string) =>
existsSync(f) ? readFileSync(f, "utf8").split("\n").filter(Boolean) : [];
return {
code: r.code,
output: r.output,
dest,
bin,
curlLog: lines(curlLog),
npmLog: lines(npmLog),
};
}
describe("install.sh — the three channels", () => {
const work = mkdtempSync(join(tmpdir(), "cast-tarballs-"));
const asset = makeTarball(work, "9.9.9");
const mainSrc = makeTarball(work, "main");
const brokenAsset = makeTarball(join(work, "broken"), "9.9.9", {
dist: false,
});
it("default channel: resolves the latest release and installs its PREBUILT asset — npm never runs", async () => {
const r = await runInstall({
CURL_REDIRECT: "https://github.com/heavy-duty/cast/releases/tag/9.9.9",
CURL_SERVE_SUBSTR: "releases/download/9.9.9/cast-9.9.9.tgz",
CURL_TARBALL: asset,
});
expect(r.output).toContain("latest release: 9.9.9");
expect(r.code).toBe(0);
// The download was the asset — never a source tarball...
expect(r.curlLog.some((u) => u.includes("releases/download/"))).toBe(true);
expect(r.curlLog.some((u) => u.includes("archive/"))).toBe(false);
// ...and the poisoned npm was never touched.
expect(r.npmLog).toEqual([]);
// The channel decided WHAT arrived; the versioned layout decided WHERE:
// the prebuilt tree landed in versions/<its package.json version>, with
// current flipped to it and the PATH link pointing through the chain.
expect(existsSync(join(r.dest, "versions/9.9.9/dist/cli.js"))).toBe(true);
expect(realpathSync(join(r.dest, "current"))).toBe(
realpathSync(join(r.dest, "versions/9.9.9")),
);
expect(readlinkSync(join(r.bin, "cast"))).toBe(
join(r.dest, "current/bin/cast"),
);
expect(
readFileSync(join(r.dest, "versions/9.9.9/INSTALLED_FROM"), "utf8"),
).toBe("heavy-duty/cast@9.9.9 (release asset)\n");
// The installed tree runs, through the whole chain — with zero build
// steps on this machine.
const v = await run(join(r.bin, "cast"), []);
expect(v.output.trim()).toBe("cast 9.9.9");
});
it("default channel, no releases yet: dies LOUDLY naming CAST_REF=main, installs nothing", async () => {
// A repo with no releases redirects releases/latest to /releases —
// GitHub's real shape (measured; rig pinned the same fact).
const r = await runInstall({
CURL_REDIRECT: "https://github.com/heavy-duty/cast/releases",
CURL_SERVE_SUBSTR: "archive/refs/heads/main",
CURL_TARBALL: mainSrc,
});
expect(r.code).toBe(1);
expect(r.output).toContain("no release");
expect(r.output).toContain("CAST_REF=main");
// The stub would have happily served main — a silent fallback would
// succeed here and FAIL this test. Nothing was downloaded or created.
expect(r.curlLog.filter((u) => !u.includes("releases/latest"))).toEqual([]);
expect(existsSync(r.dest)).toBe(false);
});
it("default channel: a resolved release with a missing asset refuses — no source fallback", async () => {
const r = await runInstall({
CURL_REDIRECT: "https://github.com/heavy-duty/cast/releases/tag/9.9.9",
// Nothing served: the asset 404s, and so would the source tarballs.
});
expect(r.code).toBe(1);
expect(r.output).toContain("no cast-9.9.9.tgz asset");
expect(r.curlLog.some((u) => u.includes("archive/"))).toBe(false);
expect(existsSync(r.dest)).toBe(false);
});
it("pinned channel: CAST_REF=<tag> installs that release's asset, resolves nothing, builds nothing", async () => {
const r = await runInstall({
CAST_REF: "9.9.9",
CURL_SERVE_SUBSTR: "releases/download/9.9.9/cast-9.9.9.tgz",
CURL_TARBALL: asset,
});
expect(r.code).toBe(0);
expect(r.curlLog.some((u) => u.includes("releases/latest"))).toBe(false);
expect(r.npmLog).toEqual([]);
expect(existsSync(join(r.dest, "versions/9.9.9/dist/cli.js"))).toBe(true);
});
it("pinned channel: a ref without an asset falls back to source — refs/tags first, then the build", async () => {
const r = await runInstall({
CAST_REF: "9.9.9",
CURL_SERVE_SUBSTR: "archive/refs/tags/9.9.9.tar.gz",
CURL_TARBALL: asset,
NPM_EXIT: "0",
});
expect(r.code).toBe(0);
// Asset first, tag second — and the build ran, in order.
expect(r.curlLog[0]).toContain("releases/download/9.9.9/cast-9.9.9.tgz");
expect(r.curlLog[1]).toContain("archive/refs/tags/9.9.9.tar.gz");
expect(r.npmLog[0]).toContain("ci");
expect(r.npmLog[1]).toContain("run build");
expect(r.npmLog[2]).toContain("prune");
// The source-built tree lands by the same rule as everything else:
// versions/<its package.json version>.
expect(existsSync(join(r.dest, "versions/9.9.9/bin/cast"))).toBe(true);
});
it("dev channel: CAST_REF=main tries asset, tag, then branch — and builds from source", async () => {
const r = await runInstall({
CAST_REF: "main",
CURL_SERVE_SUBSTR: "archive/refs/heads/main.tar.gz",
CURL_TARBALL: mainSrc,
NPM_EXIT: "0",
});
expect(r.code).toBe(0);
expect(r.curlLog[0]).toContain("releases/download/main/cast-main.tgz");
expect(r.curlLog[1]).toContain("archive/refs/tags/main.tar.gz");
expect(r.curlLog[2]).toContain("archive/refs/heads/main.tar.gz");
expect(r.npmLog.length).toBe(3);
// The version dir is named by the TREE's package.json (9.9.9 in this
// fixture), never by the ref that fetched it — main's tree between
// releases must say so in its own version.
expect(existsSync(join(r.dest, "versions/9.9.9/bin/cast"))).toBe(true);
});
it("a ref that is neither a release, a tag nor a branch dies naming all three tries", async () => {
const r = await runInstall({ CAST_REF: "no-such-ref", NPM_EXIT: "0" });
expect(r.code).toBe(1);
expect(r.output).toContain("neither a tag nor a branch");
});
it("a broken asset (no dist/) refuses BEFORE touching an existing install", async () => {
const r = await runInstall(
{
CAST_REF: "9.9.9",
CURL_SERVE_SUBSTR: "releases/download/9.9.9/cast-9.9.9.tgz",
CURL_TARBALL: brokenAsset,
},
{ preexistingDest: true },
);
expect(r.code).toBe(1);
expect(r.output).toContain("not a runnable cast tree");
// The sanity check ran before anything landed in $DEST: whatever was
// already there survives, untouched.
expect(existsSync(join(r.dest, "MARKER"))).toBe(true);
expect(existsSync(join(r.dest, "versions"))).toBe(false);
});
});