feat: release flow — tagged releases with a prebuilt dist asset (#96)

The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.

- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
  feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
  relative to the compiled module so a source checkout and an installed
  prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
  must fail the assert loudly, not be pattern-skipped): asserts tag ==
  package.json version FIRST, extracts that version's changelog section
  (.github/scripts/release-notes.sh, shared with the tests; missing or
  empty refuses), builds once (npm ci && npm run build && npm prune
  --omit=dev), stages bin/ dist/ node_modules/ package.json as
  cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
  --verify-tag`. No tests here — ci.yml gated the merge commit, and the
  suite needs age.
- install.sh grows the three channels: default = the latest release's
  asset (tag resolved off the releases/latest redirect Location — no
  API, no token; failure dies loudly naming CAST_REF=main, never a
  silent fallback), CAST_REF=<tag> = pinned (asset first, source
  fallback), CAST_REF=main = dev build-from-source. npm is required
  only on the source path, and a prebuilt tree is sanity-checked
  (dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
  against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
  changelog, and REAL install.sh runs through all three channels with a
  stub curl and a poisoned npm — including the loud no-releases refusal
  with no $DEST side effects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 21:29:53 +00:00
parent 35604ebc0f
commit ced5c497c0
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 run build
- run: npm test - run: npm test
- name: installer is valid bash - 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 - name: labels state-machine tests
run: bash test/labels-reconcile.sh 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. agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `npm run check`, `npm run build`, and 7. **Checks must be green**: `npm run check`, `npm run build`, and
`npm test` locally mirror what CI runs. `npm test` locally mirror what CI runs.
8. **Feature PRs 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 ## 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 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 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: which is pure bash so it can run on a bare box — cast runs on **your** machine:
it is an API client, and a server should never install it. it is an API client, and a server should never install it.
@ -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 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 no-op (`CAST_REINSTALL=1` reinstalls that version's tree); a new version
version installs beside the old one and becomes the default — `cast use <old>` 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 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) The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root)
and, if that directory is not already on your `PATH`, appends it to your shell and, if that directory is not already on your `PATH`, appends it to your shell

View file

@ -3,9 +3,8 @@ set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash # cast installer — intended for: curl -fsSL .../install.sh | bash
# #
# Downloads the cast repo tarball, builds it, and installs it into the # Fetches a cast tree and installs it into the VERSIONED layout under $DEST
# VERSIONED layout under $DEST (box#79's layout, ported the way rig#36 # (box#79's layout, ported the way rig#36 ported it):
# ported it):
# #
# $DEST/versions/<version>/ one full tree per installed version # $DEST/versions/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default 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 # The version IS the tree's package.json version — cast's single source of
# truth (deliberately no separate VERSION file). # 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 # 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 # downloading — for CI and the test suite, so what lands is the code under
# review. # review.
@ -32,7 +50,7 @@ set -euo pipefail
# needs node — it is an API client, never something a server installs. # needs node — it is an API client, never something a server installs.
REPO="${CAST_REPO:-heavy-duty/cast}" REPO="${CAST_REPO:-heavy-duty/cast}"
REF="${CAST_REF:-main}" REF="${CAST_REF:-}" # empty = the latest release, resolved below
DEST="${CAST_HOME:-$HOME/.local/share/cast}" DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}" BINDIR="${CAST_BIN:-/usr/local/bin}"
@ -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 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 ----------------------------------------------------------- # --- prerequisites -----------------------------------------------------------
# curl only when something must be downloaded — a local CAST_INSTALL_SOURCE # 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. # 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 if [ -z "${CAST_INSTALL_SOURCE:-}" ]; then
command -v curl >/dev/null 2>&1 || die "curl is required but was not found." command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
fi fi
command -v tar >/dev/null 2>&1 || die "tar is required but was not found." command -v tar >/dev/null 2>&1 || die "tar is required but was not found."
command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found." command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found."
command -v npm >/dev/null 2>&1 || die "npm is required but was not found."
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))." [ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
@ -93,9 +130,25 @@ if ! command -v age >/dev/null 2>&1; then
warn " Fedora: sudo dnf install age | macOS: brew install age" warn " Fedora: sudo dnf install age | macOS: brew install age"
fi 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 if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
SRCDESC="local source $CAST_INSTALL_SOURCE" SRCDESC="local source $CAST_INSTALL_SOURCE"
else 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" SRCDESC="$REPO@$REF"
fi fi
@ -143,6 +196,7 @@ cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT trap cleanup EXIT
# --- acquire the tree -------------------------------------------------------- # --- acquire the tree --------------------------------------------------------
PREBUILT="" # set when the tree came from a release asset (already built)
if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
SRC="$CAST_INSTALL_SOURCE" SRC="$CAST_INSTALL_SOURCE"
INSTALLED_FROM="local:$SRC" 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" die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
fi fi
else else
INSTALLED_FROM="$REPO@$REF"
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing cast ($REPO@$REF)" log "installing cast ($REPO@$REF)"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \
|| die "failed to download $URL"
log "extracting archive" # A release's package is its PREBUILT asset (#96) — bin/, compiled dist/,
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \ # production node_modules/, package.json, built once by release.yml — so
|| die "failed to extract archive" # 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 # The source fallback, tag first (the pin must win over a branch that
# guesswork (it broke for real at box's repo rename). The tarball has # happens to share its name), branch second — which keeps CAST_REF=main
# exactly ONE top-level directory: take the directory, whatever it is # the dev channel. Source needs a build (build_tree below, the only
# called, and let the bin/cast check below judge whether it is the right # place npm is used).
# tree. 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)" EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
fi fi
[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC" [ -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" [ -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'" 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 set_exec() { # $1 = a cast tree: the executable bits install.sh owns
chmod +x "$1/bin/cast" chmod +x "$1/bin/cast"
if [ -d "$1/scripts" ]; then 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 # 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 # 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 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)" log "building (npm ci && npm run build)"
( cd "$1" && npm ci --silent && npm run build --silent ) \ ( cd "$1" && npm ci --silent && npm run build --silent ) \
|| die "build failed" || 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 () => { it("downloads refs/heads/<ref> when no local source is given", async () => {
const sb = sandbox(); const sb = sandbox();
// A curl shim standing in for GitHub: serves the fixture tarball and // A curl shim standing in for GitHub: logs every URL asked for, and
// logs the URL it was asked for. // 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"); sourceTree(sb, "0.5.0");
await run("tar", [ await run("tar", [
"-C", "-C",
@ -302,15 +305,22 @@ for a in "$@"; do
prev="$a" prev="$a"
done done
printf '%s\\n' "$url" >> "${curlLog}" 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); chmodSync(join(stubs, "curl"), 0o755);
const { stdout } = await install(sb, { CAST_REF: "dev-branch" }); 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", "https://github.com/heavy-duty/cast/archive/refs/heads/dev-branch.tar.gz",
); ]);
expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)"); expect(stdout).toContain("installing cast (heavy-duty/cast@dev-branch)");
expect( expect(
readFileSync(join(sb.dest, "versions/0.5.0/INSTALLED_FROM"), "utf8"), 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);
});
});