175 lines
8.2 KiB
Bash
175 lines
8.2 KiB
Bash
#!/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
|
||
# 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
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
all | lint) mode="$arg" ;;
|
||
--brief) brief=1 ;;
|
||
*) echo "usage: scripts/check.sh [lint] [--brief]" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
|
||
export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below
|
||
TESTCOMPOSE=backend/docker-compose.test.yml
|
||
failed=()
|
||
timings=()
|
||
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)" "$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
|
||
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
|
||
|
||
echo ">> lane times:"
|
||
printf '%s\n' "${timings[@]}"
|
||
echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total"
|
||
if [ ${#failed[@]} -eq 0 ]; then
|
||
echo ">> gate ($mode): green ✓"
|
||
exit 0
|
||
fi
|
||
echo ">> gate ($mode): RED — ${failed[*]}" >&2
|
||
exit 1
|