From ced5c497c060fcdc386d96823aca7aac62b80e95 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:29:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20release=20flow=20=E2=80=94=20tagged=20r?= =?UTF-8?q?eleases=20with=20a=20prebuilt=20dist=20asset=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= = 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 --- .github/scripts/release-notes.sh | 31 +++ .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 68 +++++ CHANGELOG.md | 42 +++ CONTRIBUTING.md | 31 +++ README.md | 37 ++- install.sh | 137 ++++++++-- test/install-sh.test.ts | 20 +- test/release.test.ts | 456 +++++++++++++++++++++++++++++++ 9 files changed, 795 insertions(+), 29 deletions(-) create mode 100644 .github/scripts/release-notes.sh create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 test/release.test.ts diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh new file mode 100644 index 0000000..e4f3dce --- /dev/null +++ b/.github/scripts/release-notes.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# release-notes.sh [] — print exactly 's +# section of the changelog: every line between its '## ' +# 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 []" >&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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3199403..91e357c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f23f78 --- /dev/null +++ b/.github/workflows/release.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f35fbb3 --- /dev/null +++ b/CHANGELOG.md @@ -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/`, `current` flipped atomically) + like any other install. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c95550..f166b6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index ba422ea..81ab177 100644 --- a/README.md +++ b/README.md @@ -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-.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 [|--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 ` +no-op (`CAST_REINSTALL=1` reinstalls that version's tree); a new version +installs beside the old one and becomes the default — `cast use ` switches back. A pre-versioning flat install is migrated in place on the next -run. `CAST_REF=` 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/` — 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 diff --git a/install.sh b/install.sh index d4b5c6f..98ce3e1 100644 --- a/install.sh +++ b/install.sh @@ -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// one full tree per installed version # $DEST/current -> versions/ 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-.tgz asset (bin/, +# compiled dist/, production node_modules/): no npm, +# no tsc, no devDependencies on this machine +# CAST_REF= that release, pinned — its asset first, source as +# the fallback for a ref that has none +# CAST_REF= 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/, with the current symlink flipped atomically. +# # CAST_INSTALL_SOURCE= 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 — 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/ — 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= pins one, CAST_REF=main builds the development tree." - # GitHub names the archive's top dir - — 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 - and release.yml + # stages the asset's as cast- — 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" diff --git a/test/install-sh.test.ts b/test/install-sh.test.ts index 4397795..54731e3 100644 --- a/test/install-sh.test.ts +++ b/test/install-sh.test.ts @@ -279,8 +279,11 @@ describe("install.sh — the versioned layout", () => { it("downloads refs/heads/ 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"), diff --git a/test/release.test.ts b/test/release.test.ts new file mode 100644 index 0000000..341a823 --- /dev/null +++ b/test/release.test.ts @@ -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 = {}, +): 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-/ 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, + opts: { preexistingDest?: boolean } = {}, +): Promise { + 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/, 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= 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/. + 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); + }); +});