feat: release flow — tagged releases with a prebuilt dist asset (#96) #101
9 changed files with 795 additions and 29 deletions
31
.github/scripts/release-notes.sh
vendored
Normal file
31
.github/scripts/release-notes.sh
vendored
Normal 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"
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
68
.github/workflows/release.yml
vendored
Normal 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
42
CHANGELOG.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
37
README.md
37
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-<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
|
||||
|
|
|
|||
137
install.sh
137
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/<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"
|
||||
|
|
|
|||
|
|
@ -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
456
test/release.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue