build: the gate is scripts/check.sh, and CI goes

The Gitea Actions gate repeated, after the merge, what a release already runs
before it, and its runner shared the git host's container: one run there
starved Gitea of memory until it stopped answering. The lanes now live in
scripts/check.sh, run the same by the release dispatcher and by hand, and the
gate-contract test holds its lines (the ruff and pip-audit checks read it
instead of the workflow). The two audits only CI ran join the gate: pip-audit
over the lock (pinned in the test image, blocking at any severity) and npm
audit of the production dependencies. The engine contracts drop the CI
containers; the register records that pushes have no server-side gate.
This commit is contained in:
2026-10-04 06:20:58 +02:00
parent 0528b35f2d
commit cc414de952
14 changed files with 234 additions and 253 deletions
+13 -15
View File
@@ -105,7 +105,7 @@ function restrictedEntries(entry: Linter.RuleEntry | undefined): Set<string> {
// the lines that run the lanes. Any change fails until the snapshot is regenerated, so a narrowing
// arrives as a diff of `gates.snapshot.json` in review instead of slipping past a detector. (Three
// review rounds kept finding shapes the detector this replaced did not model — a `basePath` global
// ignore, a widened glob filed under a narrow row, a substring check on a CI line. A comparison
// ignore, a widened glob filed under a narrow row, a substring check on a gate line. A comparison
// against everything has no shape to miss.)
//
// Regenerate: GATE_SNAPSHOT=update npx vitest run src/lib/gates.contract.test.ts
@@ -285,7 +285,7 @@ function meaningful(path: string): string[] {
});
}
const lanes = () => ({
".gitea/workflows/check.yml": meaningful(".gitea/workflows/check.yml"),
"scripts/check.sh": meaningful("scripts/check.sh"),
"backend/docker-compose.test.yml": meaningful("backend/docker-compose.test.yml"),
"backend/Dockerfile.test": meaningful("backend/Dockerfile.test"),
"frontend/package.json scripts": (
@@ -485,19 +485,17 @@ describe("the gate register (docs/gates.md)", () => {
});
// Every ruff lane passes `--config backend/ruff.toml`, which makes ruff ignore any other config
// file it would discover (measured: a nested `ruff.toml` selecting the `D` rules changed nothing).
// So that flag is what makes this file the whole configuration. The snapshot holds every CI line;
// these two are spelled out here because the reason is (round 3: a substring check let an extra
// `--config "lint.extend-ignore=…"` override through).
it("runs every CI ruff step on backend/ruff.toml and nothing else", () => {
const steps = read(".gitea/workflows/check.yml")
// So that flag is what makes this file the whole configuration. The snapshot holds every line of
// the gate script; these two are spelled out because the reason is (round 3: a substring check let
// an extra `--config "lint.extend-ignore=…"` override through).
it("runs both ruff lanes on backend/ruff.toml and nothing else", () => {
const lanes = read("scripts/check.sh")
.split("\n")
.filter((l) => /\bruff\b/.test(l) && !l.trim().startsWith("#"))
.filter((l) => /python -m ruff (check|format)/.test(l))
.map((l) => l.trim());
expect(steps).toEqual([
"- name: ruff",
"run: python -m ruff check --config backend/ruff.toml backend scripts",
"- name: ruff format --check",
"run: python -m ruff format --check --config backend/ruff.toml backend scripts",
expect(lanes).toEqual([
"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; }",
]);
});
@@ -577,9 +575,9 @@ describe("the gate register (docs/gates.md)", () => {
expires: /\n\s*expired_at:\s*\d{4}-\d{2}-\d{2}/.test(chunk),
}));
expect(entries.filter((e) => !e.statement || !e.expires).map((e) => e.id)).toEqual([]);
const pipIgnores = read(".gitea/workflows/check.yml")
const pipIgnores = read("scripts/check.sh")
.split("\n")
.filter((l) => !l.trim().startsWith("#")) // the step's comment explains the flag; it is not one
.filter((l) => !l.trim().startsWith("#")) // the lane's comment explains the flag; it is not one
.flatMap((l) => [...l.matchAll(/--ignore-vuln[ =](\S+)/g)].map((m) => m[1]!));
const found = [...entries.map((e) => e.id), ...pipIgnores].map((id): [string, string] => [
"accepted",
+71 -62
View File
@@ -2222,68 +2222,6 @@
}
},
"lanes": {
".gitea/workflows/check.yml": [
"name: gate",
"on:",
" push:",
" branches: [\"**\"]",
" pull_request:",
"jobs:",
" frontend:",
" runs-on: ubuntu-latest",
" container: node:24-bookworm",
" defaults:",
" run:",
" working-directory: frontend",
" steps:",
" - uses: actions/checkout@v4",
" - run: npm ci",
" - run: npm run typecheck",
" - run: npm run lint",
" - run: npm run format:check",
" - run: npm run knip",
" - run: npm run test",
" - run: npx vite build",
" - run: npm audit --omit=dev --audit-level=high",
" backend:",
" runs-on: ubuntu-latest",
" container: nikolaik/python-nodejs:python3.12-nodejs24",
" services:",
" postgres:",
" image: postgres:16-alpine",
" env:",
" POSTGRES_USER: siftlode",
" POSTGRES_PASSWORD: siftlode",
" POSTGRES_DB: siftlode_test",
" options: >-",
" --health-cmd \"pg_isready -U siftlode -d siftlode_test\"",
" --health-interval 2s",
" --health-timeout 3s",
" --health-retries 30",
" env:",
" DATABASE_URL: postgresql+psycopg://siftlode:siftlode@postgres:5432/siftlode_test",
" SIFTLODE_TEST_REQUIRE_DB: \"1\"",
" steps:",
" - uses: actions/checkout@v4",
" - run: apt-get update && apt-get install -y --no-install-recommends ffmpeg postgresql-client",
" - name: Wait for Postgres",
" run: until pg_isready -h postgres -U siftlode -d siftlode_test; do sleep 1; done",
" - run: pip install --no-cache-dir -r backend/requirements.lock -r backend/requirements-dev.txt",
" - name: ruff",
" run: python -m ruff check --config backend/ruff.toml backend scripts",
" - name: ruff format --check",
" run: python -m ruff format --check --config backend/ruff.toml backend scripts",
" - name: basedpyright",
" run: python -m basedpyright --project pyrightconfig.json",
" working-directory: backend",
" - name: basedpyright baseline is committed",
" run: git diff --exit-code -- backend/.basedpyright/baseline.json",
" - name: pytest",
" run: python -m pytest",
" working-directory: backend",
" - name: pip-audit",
" run: pip install --no-cache-dir pip-audit && pip-audit -r backend/requirements.lock"
],
"backend/Dockerfile.test": [
"FROM python:3.12-slim",
"WORKDIR /app",
@@ -2366,6 +2304,77 @@
" },",
" },",
"});"
],
"scripts/check.sh": [
"set -u",
"cd \"$(dirname \"$0\")/..\" || exit 2",
"mode=\"${1:-all}\"",
"case \"$mode\" in all | lint) ;; *) echo \"usage: scripts/check.sh [lint]\" >&2; exit 2 ;; esac",
"export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below",
"TESTCOMPOSE=backend/docker-compose.test.yml",
"failed=()",
"lane() { # lane <name> <command…> — run it, remember a failure, never stop",
" local name=\"$1\"",
" shift",
" echo \">> $name…\"",
" \"$@\" || failed+=(\"$name\")",
"}",
"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; }",
"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 && \"$@\"); }",
"lane \"ruff\" ruff_check",
"lane \"ruff format --check\" ruff_format",
"lane \"basedpyright (new errors only)\" basedpyright",
"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, and unused by what ships)\" frontend npm run --silent knip",
"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",
"}",
"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 (+ coverage report)\" frontend npm run --silent test -- --coverage",
" lane \"vite build\" frontend npx vite build --logLevel warn",
" lane \"pytest (pure-logic + DB lane)\" pytest_lane",
" lane \"pip-audit (backend lock)\" pip_audit",
" lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high",
"fi",
"if [ ${#failed[@]} -eq 0 ]; then",
" echo \">> gate ($mode): green ✓\"",
" exit 0",
"fi",
"echo \">> gate ($mode): RED — ${failed[*]}\" >&2",
"exit 1"
]
}
}
@@ -48,18 +48,6 @@ it("builds the shipped bundle on the pinned engine", () => {
).toBe(pinned);
});
it("runs the server gate on the pinned engine", () => {
// The frontend job only — the backend job's node runs the checkout action, never our code, so it is
// deliberately not part of this contract (see the note in check.yml).
expect(
majorIn(
readRoot(".gitea/workflows/check.yml"),
/^\s*container: node:(\d+)-bookworm$/m,
"check.yml",
),
).toBe(pinned);
});
it("declares the pinned engine to npm and to tsc", () => {
expect(pkg.engines?.node).toBe(`${pinned}.x`);
// Types a major ahead of the runtime make tsc green over APIs the shipped engine does not have —
+2 -14
View File
@@ -11,12 +11,12 @@ import { expect, it } from "vitest";
//
// It lives in the FRONTEND suite for one boring reason: this is the only runner that can see the
// whole repository. The backend suite runs inside the test image, which mounts `backend/` alone, so
// it cannot read the repo-root Dockerfile or the workflow. It holds the other half of the chain —
// it cannot read the repo-root Dockerfile. It holds the other half of the chain —
// including "the interpreter running these tests IS the pinned one", which only it can assert:
//
// backend/tests/test_python_engine_contract.py : running interpreter == pyrightconfig == ruff.toml
// here : .python-version == pyrightconfig == ruff.toml
// == Dockerfile == Dockerfile.test == check.yml
// == Dockerfile == Dockerfile.test
//
// To move the version: edit `.python-version`, then follow the failures in both files.
const readRoot = (rel: string) => readFileSync(new URL(`../../../${rel}`, import.meta.url), "utf8");
@@ -55,18 +55,6 @@ it("tests on the pinned version", () => {
).toBe(pinned);
});
it("runs the server gate on the pinned version", () => {
// The backend job's container. Its NODE is deliberately outside the Node contract (it only runs
// the checkout action), but its PYTHON runs our code and our tests, so it is inside this one.
expect(
versionIn(
readRoot(".gitea/workflows/check.yml"),
/^\s*container: nikolaik\/python-nodejs:python(\d+)\.(\d+)-nodejs\d+$/m,
"check.yml",
),
).toBe(pinned);
});
it("type-checks against the pinned version", () => {
// A type checker aimed at another version green-lights APIs the shipped interpreter does not have
// — the same defect as the image split, moved to check time.