cast/.github/workflows/ci.yml
dan-claude-bot 1c38ef8988 fix: lint every tracked shell script, and prove the set is complete
Filed as cast's record of heavy-duty/box#116: a `shopt -s globstar;
files=(bin/* **/*.sh)` sweep never descends into `.github/`, because globs
do not match dot-prefixed names without `dotglob`. cast has no such sweep —
it has no shellcheck step at all. Its only shell gate was

    bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh

a syntax check over a hand-maintained list. The reported symptom holds
(release-notes.sh and labels-reconcile.sh ship unlinted) but so does every
other script here, and `bash -n` parses without linting: it would not catch
a quoting or unset-variable bug in any of them.

.github/scripts/shellcheck-all.sh now runs `shellcheck -x` over the tracked
tree, from CI and from `npm run check:shell`. The file list comes from
`git ls-files`, not a glob. `dotglob` was measured and does work today —
cast's dependency tree ships zero `.sh` files, so sweeping after `npm ci`
pulls in nothing — but that is a property of somebody else's package tree,
re-decided by every install. `git ls-files` does not depend on it.
Extensionless scripts are matched by shebang, which covers bin/cast without
naming it.

It carries a class check in box#112's shape: the sweep asserts its own list
covers `git ls-files '*.sh'` and fails naming the strays otherwise. Verified
by swapping the derivation for the buggy globstar glob, which reports
exactly the two .github/scripts files.

All eight scripts pass as they stood; the three findings were intentional
($PATH written literally into a profile, advice text in backticks) or a
false positive, and are annotated in place. No behavior changes.

Refs #118
2026-07-21 12:30:52 +00:00

104 lines
5 KiB
YAML

name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# fetch-depth: 0, for the changelog-monotonic step below and only
# for it. That check is about a DIFF — which release headings the
# merge base had — so it needs the base branch's history present,
# and the default depth-1 checkout has none of it. An explicit
# `git fetch origin <base>` would be narrower, but it has to be
# right on both event types and on fork PRs, and getting it subtly
# wrong degrades to a SKIP (a guard that silently stops guarding —
# the exact failure this repo keeps refusing). Full history on a
# tree this size costs a second; the STRICT flag below turns any
# remaining skip red rather than green.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
# the secrets tests round-trip a real age identity
- run: sudo apt-get update && sudo apt-get install -y age
- run: npm ci
- run: npm run check
- run: npm run build
- run: npm test
- name: installer is valid bash
run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
# `bash -n` above is a syntax check on a hand-maintained list; it is not
# a linter and it does not notice a script it was never told about.
# This sweep derives its list from git and asserts the list covers every
# tracked *.sh, so a new script cannot go unlinted quietly (#118).
- name: shellcheck — every tracked shell script
run: bash .github/scripts/shellcheck-all.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
# ...and no SHIPPED release heading was deleted or DUPLICATED (#133;
# box#122's guard, box#143's ordering fix). Its own step so that when it
# goes red the log names the invariant that broke — and a DIFFERENT
# invariant from the arming rule npm test carries: arming is a fact
# about this tree, monotonicity is a fact about this tree versus its
# merge base. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
#
# NOT pull-request-only, and that is the #133 fix at the workflow level.
# The two halves have different vacuity: DELETION is vacuous on a push
# to main (the merge base IS HEAD), but DUPLICATION is vacuous on no
# tree at all, so gating the whole script on `pull_request` left a
# duplicate that reached main by any other route unasserted forever.
#
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
# push event `github.base_ref` is EMPTY, so the argument would collapse
# to a bare `origin/`, which does not resolve — and STRICT=1 correctly
# promotes that to a hard failure, turning every push to main red. With
# the fallback it resolves to the pushed branch, whose merge base with
# HEAD is HEAD or its parent: containment passes vacuously, exactly as
# the old `if` intended, while uniqueness now runs on every push.
- name: no shipped changelog heading was deleted or duplicated
env:
CHANGELOG_MONOTONIC_STRICT: "1"
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
# this checkout, so CI proves the installer under review (the versioned
# layout, the current symlink, the PATH chain, the uninstall's absence
# assert), not a hand-built imitation of it. Box's CI installs box the
# same way. This is the one place the real npm ci + tsc build path runs
# end to end; the vitest installer tests cover the layout semantics
# offline with a shimmed npm.
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: install via install.sh, from this checkout
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
# assert what landed: the layout, the chain, and that it answers
readlink -f "$HOME/.local/bin/cast" | grep '/versions/'
"$HOME/.local/bin/cast" --version
"$HOME/.local/bin/cast" versions
- name: converging no-op — a re-run changes nothing and builds nothing
run: |
CAST_NO_MODIFY_PATH=1 CAST_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh \
| tee /tmp/rerun.log
grep -q 'already installed' /tmp/rerun.log
- name: uninstall --all — ends with the absence assert
run: |
CAST_YES=1 "$HOME/.local/bin/cast" uninstall --all
test ! -e "$HOME/.local/share/cast"
test ! -e "$HOME/.local/bin/cast"
test ! -L "$HOME/.local/bin/cast"