feat: versioned installations — the box#79 layout, ported the way rig#36 ported it #100
9 changed files with 706 additions and 23 deletions
74
.github/workflows/release.yml
vendored
Normal file
74
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
# A release is a PR, then a tag (cast#96, the flow shared with box#83):
|
||||||
|
# the `release: X.Y.Z` PR bumps package.json and stamps the CHANGELOG's
|
||||||
|
# Unreleased section; merging and pushing the bare `X.Y.Z` tag lands here.
|
||||||
|
# This workflow is where cast differs from its siblings: box/rig are pure
|
||||||
|
# bash, so the source tarball IS the package — cast compiles, so the build
|
||||||
|
# happens ONCE, here, and the release carries a prebuilt `cast-X.Y.Z.tgz`
|
||||||
|
# the installer can drop in without npm ci or tsc on the operator's machine.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Bare X.Y.Z tags (the family scheme — box's 0.6.0 set the precedent,
|
||||||
|
# no `v` prefix). The glob is loose; the assert step below is the gate.
|
||||||
|
tags: ["[0-9]*.*.*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: assert tag == package.json version
|
||||||
|
# Fail loudly, create nothing: a tag that contradicts package.json
|
||||||
|
# would mint a release whose `cast --version` disagrees with its
|
||||||
|
# own name. The mismatch is a ritual error — retag, don't patch.
|
||||||
|
run: |
|
||||||
|
version="$(node -p 'require("./package.json").version')"
|
||||||
|
if [ "$GITHUB_REF_NAME" != "$version" ]; then
|
||||||
|
echo "tag '$GITHUB_REF_NAME' != package.json version '$version' — refusing to release" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: extract the release notes from CHANGELOG.md
|
||||||
|
# The release body is the curated section we wrote, never the
|
||||||
|
# auto-generated PR list. Missing/empty section fails the release —
|
||||||
|
# before the tag has minted anything.
|
||||||
|
run: bash scripts/changelog-section.sh "$GITHUB_REF_NAME" CHANGELOG.md > /tmp/release-notes.md
|
||||||
|
|
||||||
|
- name: build the package, once
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
|
||||||
|
- name: assemble cast-${{ github.ref_name }}.tgz
|
||||||
|
# The runnable tree and nothing else: bin/, dist/, production
|
||||||
|
# node_modules/, package.json. Pruned AFTER the tests so what ships
|
||||||
|
# is the tree that passed. Top-level dir named like a GitHub
|
||||||
|
# archive's, so the installer handles both shapes identically.
|
||||||
|
run: |
|
||||||
|
npm prune --omit=dev
|
||||||
|
stage="$(mktemp -d)/cast-$GITHUB_REF_NAME"
|
||||||
|
mkdir -p "$stage"
|
||||||
|
cp -R bin dist node_modules package.json "$stage/"
|
||||||
|
tar -C "$(dirname "$stage")" -czf "cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME"
|
||||||
|
tar -tzf "cast-$GITHUB_REF_NAME.tgz" | head -5
|
||||||
|
|
||||||
|
- name: create the GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "$GITHUB_REF_NAME" "cast-$GITHUB_REF_NAME.tgz" \
|
||||||
|
--verify-tag \
|
||||||
|
--title "cast $GITHUB_REF_NAME" \
|
||||||
|
--notes-file /tmp/release-notes.md
|
||||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
History before versioning lives in git. Feature PRs land their entry in
|
||||||
|
`## Unreleased` as part of the PR; a release PR stamps that section with
|
||||||
|
the version and date (see cast#96 — the release flow shared with
|
||||||
|
heavy-duty/box#83).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Versioned installs: tagged releases with a prebuilt dist asset** (#96) —
|
||||||
|
cast now has a release surface. `cast --version` prints the version from
|
||||||
|
`package.json` (the single source of truth — no separate `VERSION` file)
|
||||||
|
plus the install root. On a bare `X.Y.Z` tag push, `release.yml` asserts
|
||||||
|
the tag matches `package.json`, builds once in CI (`npm ci`, `npm run
|
||||||
|
build`, `npm test`, `npm prune --omit=dev`), tars the runnable tree into
|
||||||
|
`cast-X.Y.Z.tgz`, and creates the GitHub release with that version's
|
||||||
|
changelog section as the body and the tarball attached. The installer now
|
||||||
|
defaults to the **latest release asset** — resolved via the
|
||||||
|
`releases/latest` redirect, no API, no token — so a default install
|
||||||
|
compiles nothing on the operator's machine and answers "what cast is
|
||||||
|
this?" with a version. `CAST_REF=X.Y.Z` pins (uses that tag's asset when
|
||||||
|
it exists), `CAST_REF=main` stays the dev channel: build-from-source,
|
||||||
|
exactly the old path.
|
||||||
|
|
@ -38,6 +38,21 @@ labels tell you where everything is without opening anything.
|
||||||
agreement is the author's judgment, so the author makes the request.
|
agreement is the author's judgment, so the author makes the request.
|
||||||
7. **Checks must be green**: `npm run check`, `npm run build`, and
|
7. **Checks must be green**: `npm run check`, `npm run build`, and
|
||||||
`npm test` locally mirror what CI runs.
|
`npm test` locally mirror what CI runs.
|
||||||
|
8. **Feature PRs carry their changelog entry.** Add what changed to
|
||||||
|
`CHANGELOG.md`'s `## Unreleased` section as part of the PR — release
|
||||||
|
notes are written when the change lands, not reconstructed at release
|
||||||
|
time.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
A release is a PR, then a tag (cast#96; the flow box#83 anchors for the
|
||||||
|
family). A `release: X.Y.Z` PR bumps `package.json` and stamps the
|
||||||
|
`## Unreleased` section with the version and date. Merge it, tag the merge
|
||||||
|
commit bare `X.Y.Z` (no `v` prefix), push the tag —
|
||||||
|
[release.yml](.github/workflows/release.yml) asserts the tag matches
|
||||||
|
`package.json`, builds `cast-X.Y.Z.tgz` once in CI, and creates the GitHub
|
||||||
|
release with that section as the body and the tarball attached. The
|
||||||
|
installer's default channel serves that asset.
|
||||||
|
|
||||||
## Labels — who sets what
|
## Labels — who sets what
|
||||||
|
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -21,6 +21,17 @@ are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
|
||||||
which is pure bash so it can run on a bare box — cast runs on **your** machine:
|
which is pure bash so it can run on a bare box — cast runs on **your** machine:
|
||||||
it is an API client, and a server should never install it.
|
it is an API client, and a server should never install it.
|
||||||
|
|
||||||
|
By default that installs the **latest release**: a prebuilt `cast-X.Y.Z.tgz`
|
||||||
|
asset built once in CI — nothing compiles on your machine, and
|
||||||
|
`cast --version` names exactly what you got. `CAST_REF` selects the other
|
||||||
|
channels:
|
||||||
|
|
||||||
|
| | channel | what happens |
|
||||||
|
|---|---|---|
|
||||||
|
| unset | latest release | the newest tag's prebuilt asset |
|
||||||
|
| `CAST_REF=0.1.0` | pinned | that release's prebuilt asset |
|
||||||
|
| `CAST_REF=main` | dev | that ref's source tarball, built here (`npm ci` + `tsc` — needs `npm`) |
|
||||||
|
|
||||||
The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root)
|
The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root)
|
||||||
and, if that directory is not already on your `PATH`, appends it to your shell
|
and, if that directory is not already on your `PATH`, appends it to your shell
|
||||||
profile — `.zshrc`, `.bashrc`/`.bash_profile`, or `config.fish`, whichever your
|
profile — `.zshrc`, `.bashrc`/`.bash_profile`, or `config.fish`, whichever your
|
||||||
|
|
|
||||||
109
install.sh
109
install.sh
|
|
@ -3,14 +3,23 @@ set -euo pipefail
|
||||||
|
|
||||||
# cast installer — intended for: curl -fsSL .../install.sh | bash
|
# cast installer — intended for: curl -fsSL .../install.sh | bash
|
||||||
#
|
#
|
||||||
# Downloads the cast repo tarball, installs the tree under $DEST, builds it,
|
# Three channels from one script (cast#96, the flow shared with box#83):
|
||||||
# and puts a `cast` symlink on PATH via $BINDIR. Re-run any time to upgrade.
|
|
||||||
#
|
#
|
||||||
# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and
|
# CAST_REF unset → the latest GitHub release's prebuilt asset
|
||||||
# needs node — it is an API client, never something a server installs.
|
# (cast-X.Y.Z.tgz). No npm ci, no tsc, no
|
||||||
|
# devDependencies on this machine — the build
|
||||||
|
# happened once, in CI, on the tag.
|
||||||
|
# CAST_REF=X.Y.Z → that release's asset, when one exists — a pin.
|
||||||
|
# CAST_REF=<branch> → build from source: the repo tarball for that
|
||||||
|
# ref (tags tried before branches), npm ci + tsc
|
||||||
|
# here. CAST_REF=main is the dev channel.
|
||||||
|
#
|
||||||
|
# Re-run any time to upgrade. Unlike rig (pure bash, runs on bare boxes),
|
||||||
|
# cast runs on YOUR machine and needs node — it is an API client, never
|
||||||
|
# something a server installs.
|
||||||
|
|
||||||
REPO="${CAST_REPO:-heavy-duty/cast}"
|
REPO="${CAST_REPO:-heavy-duty/cast}"
|
||||||
REF="${CAST_REF:-main}"
|
REF="${CAST_REF:-}"
|
||||||
DEST="${CAST_HOME:-$HOME/.local/share/cast}"
|
DEST="${CAST_HOME:-$HOME/.local/share/cast}"
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
BINDIR="${CAST_BIN:-/usr/local/bin}"
|
BINDIR="${CAST_BIN:-/usr/local/bin}"
|
||||||
|
|
@ -26,7 +35,6 @@ die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
|
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
|
||||||
command -v tar >/dev/null 2>&1 || die "tar is required but was not found."
|
command -v tar >/dev/null 2>&1 || die "tar is required but was not found."
|
||||||
command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found."
|
command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found."
|
||||||
command -v npm >/dev/null 2>&1 || die "npm is required but was not found."
|
|
||||||
|
|
||||||
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
||||||
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
|
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
|
||||||
|
|
@ -43,27 +51,90 @@ TMPDIR="$(mktemp -d)"
|
||||||
cleanup() { rm -rf "$TMPDIR"; }
|
cleanup() { rm -rf "$TMPDIR"; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
# Resolve the latest release tag by following the releases/latest redirect
|
||||||
|
# and reading where it landed — no API, no token, no rate-limit pain
|
||||||
|
# (box#83's trick). GitHub answers .../releases/tag/<TAG>; anything else
|
||||||
|
# (a repo with no releases redirects nowhere useful) is a loud failure.
|
||||||
|
resolve_latest_tag() {
|
||||||
|
local landed
|
||||||
|
landed="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || return 1
|
||||||
|
case "$landed" in
|
||||||
|
*/releases/tag/*) printf '%s\n' "${landed##*/releases/tag/}" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
log "installing cast ($REPO@$REF)"
|
# fetch_ok <url> <outfile> — download, true/false. -f keeps a 404 an
|
||||||
|
# error instead of saving GitHub's error page as a tarball.
|
||||||
|
fetch_ok() {
|
||||||
|
curl -fsSL "$1" -o "$2" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- acquire the tree --------------------------------------------------------
|
||||||
|
# PREBUILT=1 means the tarball is a CI-built runnable tree (bin/, dist/,
|
||||||
|
# production node_modules/, package.json) — nothing to compile here.
|
||||||
|
PREBUILT=0
|
||||||
|
SRCDESC=""
|
||||||
|
|
||||||
|
if [ -z "$REF" ]; then
|
||||||
|
TAG="$(resolve_latest_tag)" \
|
||||||
|
|| die "could not resolve the latest release of $REPO — no releases yet, or no network. CAST_REF=main installs from source."
|
||||||
|
URL="https://github.com/$REPO/releases/download/$TAG/cast-$TAG.tgz"
|
||||||
|
log "installing cast $TAG (latest release of $REPO)"
|
||||||
log "downloading $URL"
|
log "downloading $URL"
|
||||||
curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \
|
fetch_ok "$URL" "$TMPDIR/cast.tar.gz" \
|
||||||
|| die "failed to download $URL"
|
|| die "failed to download the $TAG release asset: $URL"
|
||||||
|
PREBUILT=1
|
||||||
|
SRCDESC="$REPO@$TAG (release asset)"
|
||||||
|
else
|
||||||
|
# A pinned tag that has a release asset gets the asset — same bits as the
|
||||||
|
# default channel, just older. Everything else (a branch, a tag from
|
||||||
|
# before releases carried assets) falls back to build-from-source.
|
||||||
|
ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz"
|
||||||
|
if fetch_ok "$ASSET_URL" "$TMPDIR/cast.tar.gz"; then
|
||||||
|
log "installing cast $REF (pinned release asset)"
|
||||||
|
PREBUILT=1
|
||||||
|
SRCDESC="$REPO@$REF (release asset)"
|
||||||
|
else
|
||||||
|
log "no release asset for '$REF' — building from source"
|
||||||
|
for kind in tags heads; do
|
||||||
|
URL="https://github.com/$REPO/archive/refs/$kind/$REF.tar.gz"
|
||||||
|
if fetch_ok "$URL" "$TMPDIR/cast.tar.gz"; then
|
||||||
|
SRCDESC="$REPO@$REF (source, refs/$kind)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
SRCDESC=""
|
||||||
|
done
|
||||||
|
[ -n "$SRCDESC" ] || die "no tag or branch named '$REF' in $REPO (tried the release asset, refs/tags and refs/heads)"
|
||||||
|
log "downloaded from refs — $SRCDESC"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log "extracting archive"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|
||||||
|| die "failed to extract archive"
|
|| die "failed to extract archive"
|
||||||
|
|
||||||
# GitHub archives extract to a single top-level dir like cast-<ref>/
|
# Both shapes carry exactly ONE top-level directory (GitHub names its
|
||||||
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'cast-*' | head -n1)"
|
# archives <repo>-<ref>; release.yml stages cast-<version>). Deriving that
|
||||||
[ -n "$EXTRACTED" ] || die "could not find extracted cast-* directory in archive"
|
# name is guesswork — it broke for real at box's repo rename — so take the
|
||||||
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $REPO@$REF correct?"
|
# single directory, whatever it is called, and judge the tree by its content.
|
||||||
|
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
|
[ -n "$EXTRACTED" ] || die "could not find the cast tree in the archive"
|
||||||
|
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $SRCDESC correct?"
|
||||||
|
|
||||||
# --- build (deps + tsc), then drop the dev deps -------------------------------
|
# --- build (source channel only) ---------------------------------------------
|
||||||
|
if [ "$PREBUILT" -eq 1 ]; then
|
||||||
|
# Verify the shape before touching $DEST: a prebuilt tree that cannot run
|
||||||
|
# is better refused here than discovered at `cast apply` time.
|
||||||
|
[ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ] \
|
||||||
|
|| die "the release asset is not a runnable tree (missing dist/ or node_modules/) — broken release? CAST_REF=main installs from source"
|
||||||
|
else
|
||||||
|
command -v npm >/dev/null 2>&1 || die "npm is required to build from source (CAST_REF=$REF) but was not found."
|
||||||
log "building (npm ci && npm run build)"
|
log "building (npm ci && npm run build)"
|
||||||
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \
|
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \
|
||||||
|| die "build failed"
|
|| die "build failed"
|
||||||
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
|
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- atomically replace $DEST --------------------------------------------------
|
# --- atomically replace $DEST --------------------------------------------------
|
||||||
log "installing into $DEST"
|
log "installing into $DEST"
|
||||||
|
|
@ -71,7 +142,11 @@ rm -rf "$DEST"
|
||||||
mkdir -p "$(dirname "$DEST")"
|
mkdir -p "$(dirname "$DEST")"
|
||||||
mv "$EXTRACTED" "$DEST"
|
mv "$EXTRACTED" "$DEST"
|
||||||
|
|
||||||
chmod +x "$DEST/bin/cast" "$DEST"/scripts/*.sh
|
chmod +x "$DEST/bin/cast"
|
||||||
|
# Source installs carry scripts/; the release asset deliberately does not.
|
||||||
|
if [ -d "$DEST/scripts" ]; then
|
||||||
|
find "$DEST/scripts" -name '*.sh' -exec chmod +x {} +
|
||||||
|
fi
|
||||||
|
|
||||||
# --- put cast on PATH ----------------------------------------------------------
|
# --- put cast on PATH ----------------------------------------------------------
|
||||||
mkdir -p "$BINDIR"
|
mkdir -p "$BINDIR"
|
||||||
|
|
@ -133,4 +208,4 @@ else
|
||||||
log "this shell does not have it yet — open a new one, or: source $PROFILE"
|
log "this shell does not have it yet — open a new one, or: source $PROFILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "done — try: cast --help"
|
log "done ($SRCDESC) — try: cast --help"
|
||||||
|
|
|
||||||
43
scripts/changelog-section.sh
Normal file
43
scripts/changelog-section.sh
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# changelog-section.sh <version> [changelog-file]
|
||||||
|
#
|
||||||
|
# Print the body of CHANGELOG.md's `## <version>` section — everything
|
||||||
|
# between that heading and the next `## ` heading (or EOF), with the
|
||||||
|
# leading/trailing blank lines trimmed. release.yml uses this as the
|
||||||
|
# GitHub release body, so the notes are the curated prose we actually
|
||||||
|
# wrote, never an auto-generated PR list (cast#96 / box#83).
|
||||||
|
#
|
||||||
|
# Fails loudly when the section is missing or empty: a release with no
|
||||||
|
# written history is a release that skipped the changelog discipline,
|
||||||
|
# and the tag push is exactly the moment to catch that — before a
|
||||||
|
# release object exists.
|
||||||
|
|
||||||
|
version="${1:-}"
|
||||||
|
file="${2:-CHANGELOG.md}"
|
||||||
|
|
||||||
|
[ -n "$version" ] || { echo "usage: changelog-section.sh <version> [changelog-file]" >&2; exit 2; }
|
||||||
|
[ -f "$file" ] || { echo "changelog-section: no such file: $file" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The heading is `## <version>` optionally followed by more (a date stamp:
|
||||||
|
# `## 0.1.0 — 2026-07-18`). Match on the version as the second word so the
|
||||||
|
# stamp's format never becomes load-bearing here.
|
||||||
|
section="$(awk -v ver="$version" '
|
||||||
|
/^## / { if (found) exit; if ($2 == ver) { found = 1; next } }
|
||||||
|
found { print }
|
||||||
|
END { exit found ? 0 : 3 }
|
||||||
|
' "$file")" || {
|
||||||
|
echo "changelog-section: no \"## $version\" section in $file" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trim leading blank lines; command substitution already ate the trailing ones.
|
||||||
|
section="$(printf '%s\n' "$section" | sed '/./,$!d')"
|
||||||
|
|
||||||
|
[ -n "$section" ] || {
|
||||||
|
echo "changelog-section: the \"## $version\" section in $file is empty" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '%s\n' "$section"
|
||||||
22
src/cli.ts
22
src/cli.ts
|
|
@ -1,7 +1,8 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
||||||
|
|
@ -125,6 +126,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
||||||
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
|
||||||
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||||
cast team [--env <env>]
|
cast team [--env <env>]
|
||||||
|
cast --version # version + install root
|
||||||
|
|
||||||
--state <dir> the state checkout holding environments.yaml, secrets/ and
|
--state <dir> the state checkout holding environments.yaml, secrets/ and
|
||||||
.coolify.env (default: $CAST_STATE, else the cwd)
|
.coolify.env (default: $CAST_STATE, else the cwd)
|
||||||
|
|
@ -1299,12 +1301,30 @@ async function runProject(
|
||||||
return { status: "applied", mutated };
|
return { status: "applied", mutated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The version lives in package.json — the tree's single source of truth
|
||||||
|
// (cast#96, deliberately no separate VERSION file). dist/cli.js sits one
|
||||||
|
// level below it in a source checkout and in an installed release asset
|
||||||
|
// alike, so resolving from import.meta.url answers for both without
|
||||||
|
// caring how this tree got here. The install root rides along in the
|
||||||
|
// output (the family's shape — rig prints its ROOT too) because "which
|
||||||
|
// cast is this" and "where does it run from" are the same question when
|
||||||
|
// several trees exist on one machine.
|
||||||
|
function formatVersion(): string {
|
||||||
|
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
||||||
|
const version: unknown = JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
||||||
|
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
const [command, ...rest] = process.argv.slice(2);
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
if (command === "-h" || command === "--help" || command === "help") {
|
if (command === "-h" || command === "--help" || command === "help") {
|
||||||
console.log(USAGE);
|
console.log(USAGE);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (command === "-V" || command === "--version") {
|
||||||
|
console.log(formatVersion());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (command === "apply" || command === "diff") {
|
if (command === "apply" || command === "diff") {
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
args: rest,
|
args: rest,
|
||||||
|
|
|
||||||
299
test/install-sh.test.ts
Normal file
299
test/install-sh.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
// These tests run the REAL install.sh — not a reimplementation of its
|
||||||
|
// logic — with curl and npm replaced by PATH shims, so every channel
|
||||||
|
// (latest asset, pinned asset, build-from-source) is exercised offline.
|
||||||
|
// The shims record what was requested; the assertions read the wire log
|
||||||
|
// and the resulting tree, the same way rig's cli.sh proves its installer.
|
||||||
|
|
||||||
|
const INSTALL_SH = join(process.cwd(), "install.sh");
|
||||||
|
|
||||||
|
// curl shim: answers from CAST_TEST_* env vars, appends every URL to
|
||||||
|
// CAST_TEST_CURL_LOG. Exit 22 is curl's own "-f saw an HTTP error".
|
||||||
|
const CURL_SHIM = `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
out=""; url=""
|
||||||
|
args=("$@")
|
||||||
|
i=0
|
||||||
|
while [ $i -lt \${#args[@]} ]; do
|
||||||
|
a="\${args[$i]}"
|
||||||
|
case "$a" in
|
||||||
|
-o) i=$((i+1)); out="\${args[$i]}" ;;
|
||||||
|
-w) i=$((i+1)) ;;
|
||||||
|
http*) url="$a" ;;
|
||||||
|
esac
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
printf '%s\\n' "$url" >> "$CAST_TEST_CURL_LOG"
|
||||||
|
case "$url" in
|
||||||
|
*/releases/latest)
|
||||||
|
[ -n "\${CAST_TEST_LATEST:-}" ] || exit 22
|
||||||
|
printf '%s' "$CAST_TEST_LATEST"
|
||||||
|
;;
|
||||||
|
*/releases/download/*)
|
||||||
|
if [ -n "\${CAST_TEST_ASSET_FILE:-}" ] && [ "$url" = "\${CAST_TEST_ASSET_URL:-}" ]; then
|
||||||
|
cp "$CAST_TEST_ASSET_FILE" "$out"
|
||||||
|
else
|
||||||
|
exit 22
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/archive/refs/tags/*)
|
||||||
|
if [ -n "\${CAST_TEST_TAGS_TARBALL:-}" ]; then cp "$CAST_TEST_TAGS_TARBALL" "$out"; else exit 22; fi
|
||||||
|
;;
|
||||||
|
*/archive/refs/heads/*)
|
||||||
|
if [ -n "\${CAST_TEST_HEADS_TARBALL:-}" ]; then cp "$CAST_TEST_HEADS_TARBALL" "$out"; else exit 22; fi
|
||||||
|
;;
|
||||||
|
*) exit 22 ;;
|
||||||
|
esac
|
||||||
|
`;
|
||||||
|
|
||||||
|
// npm shim: logs every invocation; 'run build' produces dist/cli.js so a
|
||||||
|
// source install ends up runnable. The prebuilt channels get a POISONED
|
||||||
|
// npm instead — if the installer touches npm at all on an asset install,
|
||||||
|
// the install fails and so does the test.
|
||||||
|
const NPM_SHIM = `#!/usr/bin/env bash
|
||||||
|
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
|
||||||
|
case "\${1:-}" in
|
||||||
|
ci) exit 0 ;;
|
||||||
|
run) mkdir -p dist && printf '// built by npm shim\\n' > dist/cli.js ;;
|
||||||
|
prune) exit 0 ;;
|
||||||
|
esac
|
||||||
|
`;
|
||||||
|
|
||||||
|
const POISONED_NPM = `#!/usr/bin/env bash
|
||||||
|
printf 'npm %s\\n' "$*" >> "$CAST_TEST_NPM_LOG"
|
||||||
|
exit 97
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Sandbox = {
|
||||||
|
root: string;
|
||||||
|
stubs: string;
|
||||||
|
dest: string;
|
||||||
|
bindir: string;
|
||||||
|
curlLog: string;
|
||||||
|
npmLog: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sandbox(opts: { poisonNpm: boolean }): Sandbox {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "cast-install-"));
|
||||||
|
const stubs = join(root, "stubs");
|
||||||
|
const home = join(root, "home");
|
||||||
|
const dest = join(root, "cast-home");
|
||||||
|
const bindir = join(root, "bin");
|
||||||
|
mkdirSync(stubs);
|
||||||
|
mkdirSync(home);
|
||||||
|
const curlLog = join(root, "curl.log");
|
||||||
|
const npmLog = join(root, "npm.log");
|
||||||
|
writeFileSync(curlLog, "");
|
||||||
|
writeFileSync(npmLog, "");
|
||||||
|
writeFileSync(join(stubs, "curl"), CURL_SHIM);
|
||||||
|
writeFileSync(join(stubs, "npm"), opts.poisonNpm ? POISONED_NPM : NPM_SHIM);
|
||||||
|
chmodSync(join(stubs, "curl"), 0o755);
|
||||||
|
chmodSync(join(stubs, "npm"), 0o755);
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
stubs,
|
||||||
|
dest,
|
||||||
|
bindir,
|
||||||
|
curlLog,
|
||||||
|
npmLog,
|
||||||
|
env: {
|
||||||
|
PATH: `${stubs}:${process.env.PATH}`,
|
||||||
|
HOME: home,
|
||||||
|
SHELL: "/bin/bash",
|
||||||
|
CAST_HOME: dest,
|
||||||
|
CAST_BIN: bindir,
|
||||||
|
CAST_NO_MODIFY_PATH: "1",
|
||||||
|
CAST_TEST_CURL_LOG: curlLog,
|
||||||
|
CAST_TEST_NPM_LOG: npmLog,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a .tgz fixture with a single top-level dir, like both real shapes.
|
||||||
|
async function makeTarball(
|
||||||
|
root: string,
|
||||||
|
topdir: string,
|
||||||
|
files: Record<string, string>,
|
||||||
|
): Promise<string> {
|
||||||
|
const stage = join(root, "fixtures", topdir);
|
||||||
|
for (const [rel, content] of Object.entries(files)) {
|
||||||
|
const abs = join(stage, rel);
|
||||||
|
mkdirSync(join(abs, ".."), { recursive: true });
|
||||||
|
writeFileSync(abs, content);
|
||||||
|
}
|
||||||
|
const tgz = join(root, "fixtures", `${topdir}.tgz`);
|
||||||
|
await run("tar", ["-C", join(root, "fixtures"), "-czf", tgz, topdir]);
|
||||||
|
return tgz;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREBUILT_FILES = {
|
||||||
|
"bin/cast": "#!/usr/bin/env bash\necho fake-cast\n",
|
||||||
|
"dist/cli.js": "// prebuilt in CI\n",
|
||||||
|
"node_modules/yaml/package.json": "{}",
|
||||||
|
"package.json": '{ "name": "cast", "version": "0.2.0" }\n',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_FILES = {
|
||||||
|
"bin/cast": "#!/usr/bin/env bash\necho fake-cast\n",
|
||||||
|
"package.json": '{ "name": "cast", "version": "0.3.0-dev" }\n',
|
||||||
|
"src/cli.ts": "// source only — dist/ does not exist until npm run build\n",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runInstaller(sb: Sandbox, extraEnv: Record<string, string>) {
|
||||||
|
return run("bash", [INSTALL_SH], {
|
||||||
|
env: { ...sb.env, ...extraEnv },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("install.sh — default channel (latest release asset)", () => {
|
||||||
|
it("resolves the latest tag via the redirect and installs the prebuilt tree without npm", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: true });
|
||||||
|
const asset = await makeTarball(sb.root, "cast-0.2.0", PREBUILT_FILES);
|
||||||
|
const { stdout } = await runInstaller(sb, {
|
||||||
|
CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases/tag/0.2.0",
|
||||||
|
CAST_TEST_ASSET_URL:
|
||||||
|
"https://github.com/heavy-duty/cast/releases/download/0.2.0/cast-0.2.0.tgz",
|
||||||
|
CAST_TEST_ASSET_FILE: asset,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain(
|
||||||
|
"installing cast 0.2.0 (latest release of heavy-duty/cast)",
|
||||||
|
);
|
||||||
|
// The tree landed, prebuilt: dist/ came from the tarball, not a build.
|
||||||
|
expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain(
|
||||||
|
"prebuilt in CI",
|
||||||
|
);
|
||||||
|
expect(existsSync(join(sb.dest, "node_modules/yaml/package.json"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(readlinkSync(join(sb.bindir, "cast"))).toBe(
|
||||||
|
join(sb.dest, "bin/cast"),
|
||||||
|
);
|
||||||
|
// npm is poisoned — a single invocation would have failed the install.
|
||||||
|
expect(readFileSync(sb.npmLog, "utf8")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dies loudly when there is no release to resolve, pointing at CAST_REF=main", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: true });
|
||||||
|
await expect(runInstaller(sb, {})).rejects.toMatchObject({
|
||||||
|
stderr: expect.stringContaining("CAST_REF=main installs from source"),
|
||||||
|
});
|
||||||
|
expect(existsSync(sb.dest)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dies when the redirect lands somewhere that is not a tag page", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: true });
|
||||||
|
await expect(
|
||||||
|
runInstaller(sb, {
|
||||||
|
CAST_TEST_LATEST: "https://github.com/heavy-duty/cast/releases",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
stderr: expect.stringContaining("could not resolve the latest release"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install.sh — pinned channel (CAST_REF=X.Y.Z)", () => {
|
||||||
|
it("uses that tag's release asset and never falls through to a source build", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: true });
|
||||||
|
const asset = await makeTarball(sb.root, "cast-0.1.0", {
|
||||||
|
...PREBUILT_FILES,
|
||||||
|
"package.json": '{ "name": "cast", "version": "0.1.0" }\n',
|
||||||
|
});
|
||||||
|
const { stdout } = await runInstaller(sb, {
|
||||||
|
CAST_REF: "0.1.0",
|
||||||
|
CAST_TEST_ASSET_URL:
|
||||||
|
"https://github.com/heavy-duty/cast/releases/download/0.1.0/cast-0.1.0.tgz",
|
||||||
|
CAST_TEST_ASSET_FILE: asset,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("installing cast 0.1.0 (pinned release asset)");
|
||||||
|
const urls = readFileSync(sb.curlLog, "utf8");
|
||||||
|
// No latest-resolution, no archive fallbacks — the pin answered.
|
||||||
|
expect(urls).not.toContain("/releases/latest");
|
||||||
|
expect(urls).not.toContain("/archive/refs/");
|
||||||
|
expect(readFileSync(sb.npmLog, "utf8")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a prebuilt asset that is not a runnable tree, leaving the old install alone", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: true });
|
||||||
|
// An asset missing dist/ — a broken release.
|
||||||
|
const asset = await makeTarball(sb.root, "cast-0.4.0", {
|
||||||
|
"bin/cast": "#!/usr/bin/env bash\n",
|
||||||
|
"package.json": "{}",
|
||||||
|
});
|
||||||
|
// A previous install that must survive the refused upgrade.
|
||||||
|
mkdirSync(join(sb.dest, "bin"), { recursive: true });
|
||||||
|
writeFileSync(join(sb.dest, "bin/cast"), "#!/usr/bin/env bash\necho old\n");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runInstaller(sb, {
|
||||||
|
CAST_REF: "0.4.0",
|
||||||
|
CAST_TEST_ASSET_URL:
|
||||||
|
"https://github.com/heavy-duty/cast/releases/download/0.4.0/cast-0.4.0.tgz",
|
||||||
|
CAST_TEST_ASSET_FILE: asset,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
stderr: expect.stringContaining("not a runnable tree"),
|
||||||
|
});
|
||||||
|
// The shape check fired BEFORE rm -rf $DEST — the old tree survives.
|
||||||
|
expect(readFileSync(join(sb.dest, "bin/cast"), "utf8")).toContain(
|
||||||
|
"echo old",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install.sh — dev channel (CAST_REF=<branch>)", () => {
|
||||||
|
it("falls back asset → refs/tags → refs/heads and builds from source", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: false });
|
||||||
|
const src = await makeTarball(sb.root, "cast-main", SOURCE_FILES);
|
||||||
|
const { stdout } = await runInstaller(sb, {
|
||||||
|
CAST_REF: "main",
|
||||||
|
CAST_TEST_HEADS_TARBALL: src,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain(
|
||||||
|
"no release asset for 'main' — building from source",
|
||||||
|
);
|
||||||
|
const urls = readFileSync(sb.curlLog, "utf8").trim().split("\n");
|
||||||
|
expect(urls).toEqual([
|
||||||
|
"https://github.com/heavy-duty/cast/releases/download/main/cast-main.tgz",
|
||||||
|
"https://github.com/heavy-duty/cast/archive/refs/tags/main.tar.gz",
|
||||||
|
"https://github.com/heavy-duty/cast/archive/refs/heads/main.tar.gz",
|
||||||
|
]);
|
||||||
|
// The build ran here — ci, build, prune — and produced the dist tree.
|
||||||
|
const npm = readFileSync(sb.npmLog, "utf8");
|
||||||
|
expect(npm).toContain("npm ci");
|
||||||
|
expect(npm).toContain("npm run build");
|
||||||
|
expect(npm).toContain("npm prune --omit=dev");
|
||||||
|
expect(readFileSync(join(sb.dest, "dist/cli.js"), "utf8")).toContain(
|
||||||
|
"built by npm shim",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dies when the ref exists nowhere (asset, tags, heads all miss)", async () => {
|
||||||
|
const sb = sandbox({ poisonNpm: true });
|
||||||
|
await expect(
|
||||||
|
runInstaller(sb, { CAST_REF: "no-such-ref" }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
stderr: expect.stringContaining("no tag or branch named 'no-such-ref'"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
121
test/release-tooling.test.ts
Normal file
121
test/release-tooling.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
// The release surface has two moving parts this file can prove without a
|
||||||
|
// tag push: `cast --version` (what an operator asks an installed tree) and
|
||||||
|
// scripts/changelog-section.sh (what release.yml publishes as the release
|
||||||
|
// body). Both are exercised for real — the built CLI, the actual script —
|
||||||
|
// not reimplemented in the test.
|
||||||
|
|
||||||
|
describe("cast --version", () => {
|
||||||
|
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const flag of ["--version", "-V"]) {
|
||||||
|
it(`${flag} prints the package.json version and the install root`, async () => {
|
||||||
|
const { stdout } = await run("node", ["dist/cli.js", flag]);
|
||||||
|
// The version comes from package.json — the single source of truth
|
||||||
|
// (cast#96) — and the root rides along, rig-style, because "which
|
||||||
|
// cast" and "where from" are the same question.
|
||||||
|
expect(stdout.trim()).toBe(`cast ${pkg.version} (${process.cwd()})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("exits 0 and prints nothing to stderr", async () => {
|
||||||
|
const { stderr } = await run("node", ["dist/cli.js", "--version"]);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scripts/changelog-section.sh", () => {
|
||||||
|
const SCRIPT = join(process.cwd(), "scripts", "changelog-section.sh");
|
||||||
|
|
||||||
|
const CHANGELOG = `# Changelog
|
||||||
|
|
||||||
|
Intro prose that must never leak into a release body.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- something still cooking
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-07-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **the second thing** (#96) — with detail.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- a fix note
|
||||||
|
|
||||||
|
## 0.1.0 — 2026-07-01
|
||||||
|
|
||||||
|
- the first thing
|
||||||
|
`;
|
||||||
|
|
||||||
|
function withChangelog(content: string): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cast-changelog-"));
|
||||||
|
const file = join(dir, "CHANGELOG.md");
|
||||||
|
writeFileSync(file, content);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("prints exactly one version's section, trimmed", async () => {
|
||||||
|
const file = withChangelog(CHANGELOG);
|
||||||
|
const { stdout } = await run("bash", [SCRIPT, "0.2.0", file]);
|
||||||
|
expect(stdout).toBe(
|
||||||
|
"### Added\n\n- **the second thing** (#96) — with detail.\n\n### Fixed\n\n- a fix note\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bleed into the next section for the last version either", async () => {
|
||||||
|
const file = withChangelog(CHANGELOG);
|
||||||
|
const { stdout } = await run("bash", [SCRIPT, "0.1.0", file]);
|
||||||
|
expect(stdout).toBe("- the first thing\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never serves Unreleased content for a version that is absent", async () => {
|
||||||
|
const file = withChangelog(CHANGELOG);
|
||||||
|
// 0.3.0 has no section — the release must fail loudly, not ship the
|
||||||
|
// Unreleased notes (or an empty body) under a version's name.
|
||||||
|
await expect(run("bash", [SCRIPT, "0.3.0", file])).rejects.toMatchObject({
|
||||||
|
code: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty section", async () => {
|
||||||
|
const file = withChangelog(
|
||||||
|
"# Changelog\n\n## 0.9.0 — 2026-01-01\n\n## 0.8.0\n\n- old\n",
|
||||||
|
);
|
||||||
|
await expect(run("bash", [SCRIPT, "0.9.0", file])).rejects.toMatchObject({
|
||||||
|
code: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a missing file and a missing argument", async () => {
|
||||||
|
await expect(
|
||||||
|
run("bash", [SCRIPT, "0.1.0", "/nonexistent/CHANGELOG.md"]),
|
||||||
|
).rejects.toMatchObject({ code: 1 });
|
||||||
|
await expect(run("bash", [SCRIPT])).rejects.toMatchObject({ code: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the real CHANGELOG's Unreleased section (the format stays parseable)", async () => {
|
||||||
|
// Guard against the repo's own changelog drifting away from the shape
|
||||||
|
// this script parses — that drift would only surface on a tag push.
|
||||||
|
const { stdout } = await run("bash", [
|
||||||
|
SCRIPT,
|
||||||
|
"Unreleased",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
]);
|
||||||
|
expect(stdout.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue