Files
improvise/scripts/checkers.sh
T
Edward Langley cb6866b2c6 fix(scripts): one shell and one environment for every checker (improvise-f9qf)
`scripts/pr-reviews-test.sh` passed 19/19 locally and 2/19 in CI on the same
commit, because a multi-assignment `local` is not left-to-right on bash 5
while macOS's bash 3.2 tolerates it. The obvious half of the fix is pinning
the shell. The half that decides the design is not.

.envrc DOES already put a nix bash 5.3.15 on PATH, which is CI's semantics --
and it is not enough. `pkgs.mkShell` exports `name`, `out`, `outputs`,
`system`, `shell`, `builder`, `stdenv`, `phases` and about twenty more
generically-named variables that CI does not have, so a script reading one by
accident under `set -u` is SILENT in the dev shell and fatal in CI. That is
exactly what happened: the broken clause read `$name`, and in the dev shell
`$name` was `nix-shell-env`. Measured at 051f541^ on one machine:

  macOS /bin/bash 3.2                             19 passed, 0 failed
  direnv exec . ./scripts/pr-reviews-test.sh      19 passed, 0 failed
  the same, with `name` unset                      2 passed, 17 failed

Two reasons for green, one of them the correct interpreter. A dev shell is a
third environment, not a reproduction of CI.

A nix shebang was measured and declined, on availability rather than speed:
warm, `nix shell nixpkgs#bash --command true` is 61.8ms against an ambient
5.1ms, and `nix-shell -p bash --run true` -- the manpage form -- is 675.2ms,
132x, inside git's merge machinery for beads-merge-driver.sh. But only the
`kb` job installs nix; build, test, wasm, js, harness-lint and browser have
none, so a nix shebang needs an installer step on three more jobs to reach a
bash those runners already ship. zsh is not a shebang change and the runner
does not have it either. Both declined in context/code/tooling.md section 5.

What lands instead pins interpreter and environment together, in one place:
scripts/checkers.sh is the one entry point for every check_the_checker, used
verbatim by CI and by the local gate. It pins a bash >= 5 (ambient first, nix
only when the ambient one is older, exit 3 when neither), scrubs the
environment to an allowlist, and refuses any shell shebang in scripts/ that
is not `#!/usr/bin/env bash`. The pinned bash goes on the front of PATH so
inner invocations get it too -- which is where the fault actually lived.

The second undetected split is closed with it: the four `#!/bin/sh` and one
`#!/usr/bin/env sh` scripts are now bash, and the six places that ran a
script through an explicit `sh` -- overriding the callee's own shebang, which
is the same disease -- now invoke it directly.

Verification, injected against the real script: restoring pr-reviews'
multi-assignment `local` leaves `sh scripts/pr-reviews-test.sh` at 22/22 and
`direnv exec . ./scripts/pr-reviews-test.sh` at 22/22, while
`./scripts/checkers.sh pr-reviews` reports 2 passed, 20 failed, exit 1 --
matching the 2/19 CI actually reported. scripts/checkers-test.sh is twelve
cases, each injecting rather than asserting; the pair that carries it runs
one fixture both ways, so the `set -u` case is a test of the runner and not
of `set -u`.

The old local gate was `for t in scripts/*-test.sh; do sh "$t"; done`, which
overrides every shebang with /bin/sh -- so it ran every suite under bash 3.2
in sh-mode regardless of what the file said. It is replaced by
`./scripts/checkers.sh`.

Workspace suite unchanged at 1689 passing over 38 binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:14 -07:00

155 lines
5.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# The one entry point for every check_the_checker in scripts/. CI calls THIS,
# and so does the local gate, so a suite cannot be green under one shell and
# red under the other. Reasoning: context/code/tooling.md section 3.
#
# It does three things a bare `./scripts/X-test.sh` does not:
#
# 1. pins a bash >= 5, taken from nix when the ambient one is older, so the
# macOS system bash 3.2 can never be the shell a suite passes under;
# 2. scrubs the environment down to an allowlist, because `nix develop`
# exports `name`, `out`, `system`, `shell`, `builder` and two dozen more
# generically-named variables that CI does not have -- a `set -u` fault
# reading one of them is SILENT inside the dev shell and fatal in CI;
# 3. refuses a shell script in scripts/ whose shebang is not
# `#!/usr/bin/env bash`, so the sh/bash split cannot come back.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat >&2 <<'EOF'
usage: checkers.sh [--list] [--self-test] [name ...]
name a suite to run, spelled without the -test.sh suffix
(e.g. `pr-reviews` for scripts/pr-reviews-test.sh).
With no name, every suite in scripts/ runs.
--list print the suite names and exit
--self-test run scripts/checkers-test.sh, which injects the faults this
runner exists to catch
exit 0 every suite passed
exit 1 a suite failed, or a shebang is not `#!/usr/bin/env bash`
exit 3 no bash >= 5 could be found and nix could not supply one
exit 4 usage error
THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same
pipeline or `;` list -- a trailing `echo "exit=$?"` takes the status.
EOF
}
# The allowlist. A variable not named here does not reach a suite, which is the
# whole point: the dev shell's `name` masked improvise-f9qf's fault for a day.
# CHECKERS_KEEP adds names, space-separated, for a caller that needs one.
ALLOW="HOME TMPDIR TMP TEMP TERM LANG LC_ALL USER LOGNAME
CI GITHUB_ACTIONS GITHUB_TOKEN GH_TOKEN
NIX_PATH NIX_REMOTE NIX_SSL_CERT_FILE SSL_CERT_FILE
XDG_CACHE_HOME XDG_CONFIG_HOME XDG_DATA_HOME"
# Prints the absolute path of a bash >= 5, or nothing. The ambient one is tried
# first so a machine that already has a modern bash pays no nix startup at all.
find_modern_bash() {
local candidate major
candidate="$(command -v bash || true)"
if [ -n "$candidate" ]; then
major="$("$candidate" -c 'echo "${BASH_VERSINFO[0]}"' 2>/dev/null || echo 0)"
if [ "$major" -ge 5 ] 2>/dev/null; then echo "$candidate"; return 0; fi
fi
command -v nix >/dev/null 2>&1 || return 0
nix shell nixpkgs#bash --command bash -c 'command -v bash' 2>/dev/null || true
}
# Every shell script in scripts/ says `#!/usr/bin/env bash` and nothing else.
# `#!/bin/sh` is dash in CI and bash 3.2 in sh-mode on macOS: a second platform
# split, undetected for the same reason the first one was.
check_shebangs() {
local bad=0 f first
for f in "$HERE"/*; do
[ -f "$f" ] || continue
first="$(head -1 "$f")"
case "$first" in
'#!'*sh|'#!'*sh\ *)
case "$first" in
'#!/usr/bin/env bash') ;;
*) printf 'checkers: %s has shebang %s, wanted #!/usr/bin/env bash\n' \
"${f##*/}" "$first" >&2; bad=1 ;;
esac ;;
esac
done
return $bad
}
# A check_the_checker is named after the thing it checks, so `X-test.sh` is a
# suite only when `X` or `X.sh` is here too. That excludes `node-test.sh`,
# which is a test RUNNER whose own checker is `node-test-test.sh`.
suites() {
local f n
for f in "$HERE"/*-test.sh; do
[ -f "$f" ] || continue
n="${f##*/}"; n="${n%-test.sh}"
if [ -f "$HERE/$n" ] || [ -f "$HERE/$n.sh" ]; then printf '%s\n' "$n"; fi
done
}
# $1 the pinned bash, $2 the suite file. The suite runs with the pinned bash
# FIRST on PATH, so a script it invokes through its own `#!/usr/bin/env bash`
# -- or through `sh` -- gets the pinned one too, not the system 3.2.
run_suite() {
local pinned="$1" file="$2"
local bindir; bindir="$(dirname "$pinned")"
local -a env_args=(-i)
local v
for v in $ALLOW ${CHECKERS_KEEP:-}; do
if [ -n "${!v+set}" ]; then env_args+=("$v=${!v}"); fi
done
env_args+=("PATH=$bindir:${PATH:-/usr/bin:/bin}")
env "${env_args[@]}" "$pinned" "$file"
}
main() {
local self_test=0
local -a names=()
while [ $# -gt 0 ]; do
case "$1" in
--list) suites; return 0 ;;
--self-test) self_test=1 ;;
-h|--help) usage; return 4 ;;
-*) printf 'checkers: unknown option %s\n' "$1" >&2; usage; return 4 ;;
*) names+=("$1") ;;
esac
shift
done
local pinned; pinned="$(find_modern_bash)"
if [ -z "$pinned" ]; then
echo 'checkers: no bash >= 5 on PATH and nix cannot supply one.' >&2
echo ' Enter the dev shell (direnv allow / nix develop) and retry.' >&2
return 3
fi
printf 'checkers: bash %s at %s\n' \
"$("$pinned" -c 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"')" "$pinned"
local rc=0
check_shebangs || rc=1
if [ "$self_test" = 1 ]; then
names=(checkers)
elif [ ${#names[@]} -eq 0 ]; then
while IFS= read -r line; do names+=("$line"); done < <(suites)
fi
local n file
for n in ${names[@]+"${names[@]}"}; do
file="$HERE/$n-test.sh"
if [ ! -f "$file" ]; then
printf 'checkers: no such suite: %s\n' "$n" >&2
rc=4; continue
fi
printf '\n=== %s ===\n' "$n"
run_suite "$pinned" "$file" || rc=1
done
return $rc
}
main "$@"