Files
siftlode/scripts/check.sh
T
peter 926e5e9899 chore(gate): check.sh can list its lanes and run one of them
--list prints the lane names, --only "<lane>" runs that one lane, so a runner can schedule the lanes itself and a red lane can be re-run on its own. An --only run reports that lane, never the gate, and an unknown name exits 2 instead of passing with nothing run; the contract test holds both.
2026-10-16 02:53:30 +02:00

197 lines
9.2 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# The gate — every lane, in one place, run from the repo root (since CI went: the lab runner
# duplicated what this already ran before every release, and one CI run starved the git host of
# memory until it stopped answering). The release dispatcher calls this; so can anyone with Docker
# and the pinned toolchain. `frontend/src/lib/gates.contract.test.ts` holds its lines, so a lane
# dropped or narrowed here fails a test like any other gate change.
#
# bash scripts/check.sh every lane (what a release requires, before e2e and the CVE scan)
# bash scripts/check.sh lint the static lanes only: no tests, no build, no audits
# … --brief (either mode) a lane's output only when it fails, and only its first
# error lines — for an agent, whose context the full log would flood
# … --list the lane names of that mode, one per line — runs nothing
# … --only "<lane>" that one lane (an exact name from --list): a re-run of a red lane, or a
# runner that schedules lanes itself. Its green is that lane's, never "the
# gate"; an unknown name exits 2 rather than passing with nothing run
# GATE_TIMES_FILE=<file> … also append each lane's time as a TSV row (UTC, mode, lane, seconds,
# ok|red, HEAD's tree) — to compare runs; every run prints the times anyway
#
# Each lane runs even when an earlier one failed, and the summary names every red one: a gate that
# stops at the first failure hides the second until the first is fixed. The backend lanes run in the
# test image (backend/Dockerfile.test): requirements.lock will not install on Windows (uvloop), and a
# type checker or auditor without the real packages reports nonsense.
set -u
cd "$(dirname "$0")/.." || exit 2
mode=all
brief=0
list=0
only=""
usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only "<lane>"]' >&2; exit 2; }
while [ $# -gt 0 ]; do
case "$1" in
all | lint) mode="$1" ;;
--brief) brief=1 ;;
--list) list=1 ;;
--only) [ $# -ge 2 ] || usage; only="$2"; shift ;;
*) usage ;;
esac
shift
done
[ "$list" = 1 ] && [ -n "$only" ] && usage
export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below
TESTCOMPOSE=backend/docker-compose.test.yml
failed=()
timings=()
ran=0
ms() { date +%s%3N; }
secs() { awk -v t="$1" 'BEGIN { printf "%.1f", t / 1000 }'; }
# How long each lane took: a line after the lane, a table at the end, and — when GATE_TIMES_FILE names a
# file — one tab-separated row per lane appended to it (UTC time, mode, lane, seconds, ok|red, tree), so
# runs can be compared. The script writes no file unless asked to.
took=""
timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it must add to $timings)
took="$(secs $(($(ms) - $3)))"
timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')")
if [ -n "${GATE_TIMES_FILE:-}" ]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \
"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE"
fi
return 0
}
lane() { # lane <name> <command…> — run it, remember a failure, never stop
local name="$1" t0 result=ok
shift
if [ "$list" = 1 ]; then echo "$name"; return 0; fi
[ -n "$only" ] && [ "$name" != "$only" ] && return 0
ran=$((ran + 1))
t0="$(ms)"
if [ "$brief" = 0 ]; then
echo ">> $name…"
"$@" || { failed+=("$name"); result=red; }
timed "$name" "$result" "$t0"
echo "<< $name: $took s"
return 0
fi
local out
out="$(mktemp)"
if "$@" >"$out" 2>&1; then
timed "$name" ok "$t0"
echo ">> $name: ok ($took s)"
else
failed+=("$name")
timed "$name" red "$t0"
echo ">> $name: FAILED ($took s)"
brief_excerpt "$out"
fi
rm -f "$out"
}
# What broke, in a log's own words. A short log is shown whole: a keyword filter keeps ruff's
# summary line and drops the rule and file above it, and eslint's file path sits on a line of its
# own above its errors. A long one shows its first 20 error-looking lines, each with the line before
# it and the two after (knip names the file on the line after "Unused files"); a long log with none
# of them shows its tail, which is where a summary sits. The test database's own log is left out:
# the DB lane provokes constraint errors on purpose, and its `ERROR:` lines filled the excerpt ahead
# of the failing test.
brief_excerpt() {
local log hits
log="$(grep -v -E '^testdb-[0-9]+ ' "$1")"
if [ "$(printf '%s\n' "$log" | wc -l)" -le 60 ]; then
printf '%s\n' "$log"
return
fi
hits="$(printf '%s\n' "$log" |
grep -E -i -B 1 -A 2 -m 20 'error|fail|✗|×|warn|would reformat|unused|vulnerab|found [0-9]')"
if [ -n "$hits" ]; then printf '%s\n' "$hits"; else printf '%s\n' "$log" | tail -n 20; fi
}
# --- static lanes ------------------------------------------------------------------------------------
# Host ruff, held to the pin in requirements-dev.txt: a newer ruff silently changes what "formatted"
# means. `--config backend/ruff.toml` on both: it makes that file the WHOLE configuration (ruff then
# ignores every other config it would discover) and gives scripts/ the backend's rule set.
ruff_pinned() {
local pin host
pin="$(sed -n 's/^ruff==\([0-9.]*\).*/\1/p' backend/requirements-dev.txt)"
host="$(python -m ruff --version 2>/dev/null | awk '{print $2}')"
[ -n "$pin" ] && [ "$host" = "$pin" ] && return 0
echo "ruff ${host:-missing} on the host, ${pin:-nothing} pinned — python -m pip install ruff==$pin" >&2
return 1
}
ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; }
ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; }
# New errors only: the pre-existing ones are in backend/.basedpyright/baseline.json. basedpyright
# TIGHTENS that file by itself when errors go, and the tree is bind-mounted read-write, so a green run
# can leave it rewritten: that fails here until it is committed, or the next run's stack reads stale.
basedpyright() {
local before
before="$(git hash-object backend/.basedpyright/baseline.json)"
docker compose -f "$TESTCOMPOSE" run --rm --no-deps --build test \
python -m basedpyright --project pyrightconfig.json || return 1
if [ "$(git hash-object backend/.basedpyright/baseline.json)" != "$before" ]; then
echo "this run rewrote backend/.basedpyright/baseline.json (errors were fixed) — commit it" >&2
return 1
fi
git diff HEAD --quiet -- backend/.basedpyright/baseline.json && return 0
echo "backend/.basedpyright/baseline.json is uncommitted — commit it" >&2
return 1
}
frontend() { (cd frontend && "$@"); }
gate_t0="$(ms)"
lane "ruff" ruff_check
lane "ruff format --check" ruff_format
lane "basedpyright (new errors only)" basedpyright
# every frontend lane judges the HOST node_modules: this proves it is the tree the lock pins
lane "lock check (frontend/node_modules == package-lock.json)" node scripts/check-node-modules.mjs
lane "tsc (src + configs + e2e)" frontend npm run --silent typecheck
lane "eslint (warnings fail too)" frontend npm run --silent lint
lane "prettier --check" frontend npm run --silent format:check
lane "knip (unused files, exports, dependencies)" frontend npm run --silent knip
# --- tests, build, audits --------------------------------------------------------------------------
pytest_lane() { # the full suite against a throwaway Postgres; --pull keeps the base image current
docker compose -f "$TESTCOMPOSE" build --pull -q test || return 1
local rc=0
docker compose -f "$TESTCOMPOSE" up --build --abort-on-container-exit --exit-code-from test || rc=1
docker compose -f "$TESTCOMPOSE" down -v >/dev/null 2>&1
return $rc
}
# Every pinned backend package against the advisory databases, at any severity — the lock is a full
# `pip freeze`, so it is audited as written, without a resolver. An advisory with no fix is answered
# with `--ignore-vuln <id>` here and a row in docs/gates.md (the register test reads this line).
pip_audit() {
docker compose -f "$TESTCOMPOSE" run --rm --no-deps test \
pip-audit -r requirements.lock --no-deps --disable-pip
}
if [ "$mode" = all ]; then
lane "vitest" frontend npm run --silent test
# the production bundle, and devOnlyGuard, which fails it when dev-only code reaches it
lane "vite build" frontend npx vite build --logLevel warn
lane "pytest (pure-logic + DB lane)" pytest_lane
lane "pip-audit (backend lock)" pip_audit
# what ships to the browser, at high or above; the dev toolchain is burned down by hand
lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high
fi
[ "$list" = 1 ] && exit 0
if [ -n "$only" ] && [ "$ran" = 0 ]; then
echo "no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list" >&2
exit 2
fi
scope="gate ($mode)"
[ -n "$only" ] && scope="lane '$only' only (not the gate)"
echo ">> lane times:"
printf '%s\n' "${timings[@]}"
echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total"
if [ ${#failed[@]} -eq 0 ]; then
echo ">> $scope: green ✓"
exit 0
fi
echo ">> $scope: RED — ${failed[*]}" >&2
exit 1