Skip to content

Lift the two fixtures that had nothing left to decide (#376) #1010

Lift the two fixtures that had nothing left to decide (#376)

Lift the two fixtures that had nothing left to decide (#376) #1010

Workflow file for this run

name: CI
on:
push:
branches: [main]
# Docs/config-only changes don't exercise lint/typecheck/test. Skipping
# them turns a 14m+ wall-clock into 0s for chore PRs (e.g. .env.example
# one-liners, ADR/roadmap updates). Mixed PRs (any source file changed)
# still trigger the full pipeline — `paths-ignore` only fires when EVERY
# changed path matches one of these patterns. If you add a new path here,
# mirror it under `pull_request` below.
# Do NOT add `docs-site/**` or `**/*.md` here. A backend test pins the
# crosswalk disclaimer the API serves to the copy quoted in the SBOM upload
# guide; skipping CI on doc-only changes would let the guide be reworded
# out of sync with the payload without anything failing.
paths-ignore:
- "docs/**"
- "*.md"
- ".env.example"
- "CLAUDE.md"
- "ROADMAP.md"
- "SECURITY.md"
- "LICENSE"
- ".gitignore"
# No paths-ignore on pull_request, deliberately. Twelve of the thirteen
# required checks live in this workflow, and a workflow that never starts
# never reports: a pull request touching only root markdown sat with twelve
# checks pending forever and the only way past it was `gh pr merge --admin`.
# That habit does not distinguish "the filter skipped these" from "these
# failed", and it waives the checks that DID run (gitleaks, CodeQL) along
# with the ones that did not. The saving was a handful of runs a month on
# chore pull requests; the cost was a standing bypass of every gate.
# The push trigger below keeps its filter — nothing is waiting on it there.
pull_request:
# Nightly run — the slow gate (e2e Playwright matrix) sits behind a
# `github.event_name == 'schedule' || workflow_dispatch` guard so PRs
# stay fast. The same schedule walks the lint/test/SAST path again so
# any drift on main between PR runs surfaces here too.
schedule:
- cron: "0 4 * * *"
workflow_dispatch: {}
# Least-privilege default — every job here only needs to check out the
# repo. Image-scan additionally pulls cached Trivy DB layers from ghcr,
# and e2e pulls docker images; both are covered by `packages: read`
# added at the job level where needed (or by anonymous pulls when the
# packages are public).
permissions:
contents: read
# Cancel in-progress runs of the same ref so the latest push wins.
#
# `github.event_name` is part of the key on purpose. Without it the nightly
# schedule run and the push-to-main runs share a group (both resolve to
# refs/heads/main) and any merge cancels the nightly mid-flight. That is
# exactly what happened on 2026-08-12: the 04:00 run was killed four minutes
# in by a push, and since the e2e matrix is nightly-only (see the `e2e` job
# below) the day's only Playwright coverage never ran. Scheduled runs now get
# their own group and are left alone; push and pull_request keep the
# latest-wins behaviour that makes them cheap.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------------------
# 1) Lint — ruff (backend) + eslint (frontend)
# -------------------------------------------------------------------------
lint:
name: lint (${{ matrix.target }})
runs-on: ubuntu-22.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
target: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Setup Python
if: matrix.target == 'backend'
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: apps/backend/requirements-dev.txt
- name: Install backend dev deps
if: matrix.target == 'backend'
working-directory: apps/backend
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run ruff
if: matrix.target == 'backend'
working-directory: apps/backend
run: ruff check .
# Level 4 triage tooling (tools/ai-review). Its workflow cannot run
# end-to-end until an ANTHROPIC_API_KEY secret exists, so everything on
# either side of the network call is checked here instead: report
# parsing, the caps, the untrusted-data fencing in the prompt, comment
# rendering, and the failure paths that must stay quiet rather than
# fail a pull request. Standard library only — no install step, and it
# runs in about a second.
#
# It lives in the existing lint job rather than a job of its own for
# the reason the license-header steps give below: a new job is a new
# check, and docs-only pull requests already stall on checks that skip.
- name: Run ai-review selftest
if: matrix.target == 'backend'
run: python tools/ai-review/selftest.py
# tools/mutate, same reasoning and the same job: standard library only,
# under a second. It is a development aid rather than a gate on the
# source, so what CI checks is that the tool still refuses what it
# promises to refuse. A mutation helper that silently stopped noticing
# unapplied edits would hand out green runs as evidence, which is the
# failure it exists to prevent.
- name: Run mutate selftest
if: matrix.target == 'backend'
run: python tools/mutate/selftest.py
- name: Setup Node
if: matrix.target == 'frontend'
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name: Install frontend deps
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm ci --no-audit --no-fund
- name: Run eslint
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm run lint
# chore A1 — i18n drift gate. Runs i18next-parser into a temp dir and
# compares against the committed src/locales/. Fails on any missing or
# stale key, or any EN ↔ KO parity gap. This guarantees that every
# `t('ns:key')` call site has a matching translation in BOTH locales
# — CLAUDE.md "EN/KO 번역 동시 반영" enforced at PR time.
- name: Run i18n drift check
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm run i18n:check
# Design-token ratchet. Colour discipline was the one design rule no
# machine enforced, and it decayed accordingly — 260 bypasses (raw
# hex + Tailwind palette classes) had accumulated across 44 files by
# the time this gate was written. The per-file baseline in
# scripts/token-lint-baseline.json can only go down: new bypasses
# fail, and paid-down debt must be re-recorded so the budget cannot
# be quietly re-spent. Token contract: docs-site/docs/reference/
# design-system.md ("Status surfaces" / "Risk tint foregrounds").
- name: Run design-token lint
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm run token:lint
# Error-copy ratchet, same shape as the token one. The backend answers
# RFC 7807 with an always-English `detail` and `title`, and reading one
# straight into the UI is a single short expression, so it spread to 47
# call sites while the translation keys beside them went unused. A
# Korean session met English on every failed request. The per-file
# baseline in scripts/problem-detail-baseline.json can only go down.
- name: Run error-copy lint
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm run problem:lint
# Korean translation-ese, on the KO guides AND the app's own Korean
# strings. The linter existed and a PostToolUse hook ran it on edit,
# but nothing checked it at merge time — and its scope was the guides
# only, so a sentence in English word order shipped to the login
# screen while the linter reported a clean run on the docs beside it.
# `--all`, not `--changed`: checkout is shallow here, so the diff the
# changed-mode needs is not in the clone and the linter would fall
# back to an empty file list — a gate that reports success because it
# examined nothing, which this repository has shipped once already.
# The whole corpus is 82 files and takes under a second. The linter can
# hold existing debt in tools/ko-style/baseline.json, but that file does
# not exist and should not be recreated: the corpus is at zero findings,
# so every S1/S2 hit is new debt. S3 findings are advisory
# (reported, not blocking) because "reads like a translation" is a
# judgement a regex can point at but not settle.
- name: Run Korean style lint
if: matrix.target == 'frontend'
run: node tools/ko-style/lint.mjs --all --fail-on S2
# House style forbids the em dash in anything we author, comments
# included. Self-review missed it for eight consecutive units of work
# and 46 lines shipped, so the check moved to a machine: it reads one
# character better than an author re-reading their own paragraph.
#
# Diff-based, not a ratchet. The rule is about what gets ADDED, and a
# baseline would have to list 1,318 files and 11,271 existing lines
# while still saying nothing about the line in front of you. That makes
# the base ref load-bearing: this checkout is shallow, so it is fetched
# first, and the linter exits non-zero rather than reporting success on
# a diff it could not compute. On a push there is no PR base, so the
# step compares against the default branch instead.
- name: Fetch the base branch for the em-dash diff
if: matrix.target == 'frontend'
run: |
base="${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}"
# Deepen rather than fetch a fixed depth. A depth-50 fetch of the
# base leaves the base and the branch under test with no commit in
# common, so `git merge-base` fails even though both refs exist,
# which is how this step failed the first time it ran. The history
# here is short (the repository was recreated), so unshallowing is
# cheap; the fallback covers a clone that is already complete.
git fetch --no-tags --unshallow origin "$base" \
|| git fetch --no-tags --depth=500 origin "$base"
- name: Run em-dash lint
if: matrix.target == 'frontend'
run: |
base="${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}"
node tools/em-dash/selftest.mjs
node tools/em-dash/lint.mjs --base "origin/$base"
# SPDX headers on first-party source. Apache-2.0 only RECOMMENDS per-file
# headers, but this product detects licenses per file — scanning our own
# repo with our own scanner and getting NOASSERTION on every source file is
# a statement about the scanner. It also separates first-party code from
# the vendored BomLens files sitting in the same directories, and covers
# the operator-facing artifacts (scripts/, actions/, charts/, the compose
# files) that a user can receive one at a time.
#
# Runs under the frontend target because Node is already set up there, and
# once (not per-target) because the linter checks BOTH trees itself. It is
# a step in the existing `lint` job rather than a job of its own: a new job
# means a new required check, and docs-only PRs already stall on checks
# that skip (see the branch-protection note in CONTRIBUTING.md).
#
# `--all`, not `--changed`: the checkout is shallow, so changed-mode has no
# merge-base and would examine nothing while reporting success — the same
# trap the ko-style gate above documents.
#
# The selftest runs first. An exclusion glob that silently matches nothing
# would stamp our copyright onto third-party files and still report clean,
# which is the one failure here that is worse than a red build.
- name: Run license-header selftest
if: matrix.target == 'frontend'
run: node tools/license-header/selftest.mjs
- name: Run license-header gate
if: matrix.target == 'frontend'
run: node tools/license-header/lint.mjs --all
# Every documented tag reference must name a tag that exists. The repo
# has been recreated twice and each recreation dropped the tags before
# it, leaving the CI-integration and install guides pinned to v0.10.0:
# the copy-paste quickstart failed to resolve and the curl install 404ed.
# The selftest runs first for the same reason the license-header one
# does: a pattern that silently matches nothing reports clean.
- name: Run release-refs selftest
if: matrix.target == 'frontend'
run: node tools/release-refs/selftest.mjs
- name: Run release-refs gate
if: matrix.target == 'frontend'
run: node tools/release-refs/lint.mjs
# -------------------------------------------------------------------------
# 1b) Shellcheck — install/backup/restore/upgrade/release wrappers.
#
# Chore E (PR #38) added this gate. The wrappers are part of the operator
# surface (run on a fresh Ubuntu/Rocky host as `bash scripts/install.sh
# --no-prompt`), so a regression here is a release-day blocker.
#
# We hard-fail on `--severity=warning` (i.e. error + warning). Info-level
# findings (mainly SC1091 "not following sourced file" on `. ./.env`)
# remain visible in the log for triage but don't block the PR — the
# `.env` file is generated at install-time and never committed, so the
# static analyser can't follow it. The two affected lines already carry
# `# shellcheck disable=SC1091` as documentation of the choice.
# -------------------------------------------------------------------------
shellcheck:
name: shellcheck (scripts)
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Show shellcheck version
run: shellcheck --version
- name: Run shellcheck (severity=warning)
run: shellcheck --severity=warning scripts/*.sh deploy/hetzner/*.sh
# -------------------------------------------------------------------------
# 1c) Postgres init L1 role contract — the ONLY gate that actually boots a
# real postgres:17.2-alpine on a fresh volume and mounts
# scripts/postgres-init.sh as /docker-entrypoint-initdb.d/
# 10-trustedoss-app-role.sh, the exact path + trigger the operator
# install.sh L1 (role-separation) deploy uses.
#
# WHY THIS EXISTS (regression guard for #466): the L1 first-boot path is
# invisible to every other job. The backend test legs use postgres only
# as a service container (init scripts never run); release-gate runs
# single-role dev mode (POSTGRES_APP_PASSWORD unset → the script skips).
# #466 shipped a psql `:'app_user'` interpolation *inside* a `DO $$ … $$`
# block, which psql does not substitute → `syntax error` → the
# trustedoss_app role was never created → every L1 install.sh deploy
# failed backend auth at startup. No CI test caught it. This job does.
#
# Pure `docker run` (no docker-compose — irrelevant here). The stock
# image trusts loopback, so the SCRAM login assertion connects over the
# container's own eth0 IP to force real password auth (
# verified recipe).
# -------------------------------------------------------------------------
postgres-init-l1:
name: postgres-init L1 role contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Run L1 role provisioning contract (3 scenarios)
run: |
set -euo pipefail
image="postgres:17.2-alpine"
init_script="$(pwd)/scripts/postgres-init.sh"
# The exact password from the task spec: contains @ : ' \ and other
# special chars, so this doubles as an injection / quote-safety guard.
app_pw='p@ss:with'\''quote\and\special'
wrong_pw='definitely-not-the-right-password'
cleanup() { docker rm -f pg_l1 pg_legacy >/dev/null 2>&1 || true; }
trap cleanup EXIT
cleanup
# Wait until postgres serves over TCP on its OWN eth0 IP. During the
# first-boot init phase the entrypoint runs a socket-only temporary
# server (listen_addresses=''), so a successful eth0 TCP probe proves
# the init scripts completed AND the real server is up. If the
# container exits during init (a failed init script aborts the
# entrypoint) we fail fast and dump logs. Sets the global SERVING_IP.
SERVING_IP=""
wait_serving() {
local name="$1" deadline state ip
deadline=$(( $(date +%s) + 120 ))
while :; do
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)
if [ "$state" = "exited" ] || [ "$state" = "dead" ]; then
echo "::error::$name exited during init"
docker logs "$name" || true
return 1
fi
ip=$(docker exec "$name" hostname -i 2>/dev/null | awk '{print $1}' || true)
if [ -n "$ip" ] && docker exec "$name" pg_isready -h "$ip" -U trustedoss -d trustedoss >/dev/null 2>&1; then
SERVING_IP="$ip"
return 0
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "::error::$name never served over TCP within deadline"
docker logs "$name" || true
return 1
fi
sleep 2
done
}
# Poll `docker logs` until a marker line appears. The image entrypoint
# is a shell whose stdout is block-buffered when piped to the docker
# log driver, so an init-script echo can land in `docker logs` a beat
# AFTER the final "ready to accept connections" line that flips the
# TCP gate above. This poll closes that flush race for log-content
# assertions (a single grep would flake). Negative "no syntax error"
# checks run only after the corresponding positive marker is seen, so
# they assert against fully-flushed init output.
wait_log() {
local name="$1" pattern="$2" deadline
deadline=$(( $(date +%s) + 30 ))
while :; do
if docker logs "$name" 2>&1 | grep -qi "$pattern"; then
return 0
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "::error::$name: log marker never appeared: $pattern"
docker logs "$name" || true
return 1
fi
sleep 1
done
}
echo "::group::Scenario 1 — L1 role-separation mode (regression guard #466)"
docker run -d --name pg_l1 \
-e POSTGRES_USER=trustedoss \
-e POSTGRES_PASSWORD=trustedoss \
-e POSTGRES_DB=trustedoss \
-e POSTGRES_APP_USER=trustedoss_app \
-e POSTGRES_APP_PASSWORD="$app_pw" \
-v "$init_script":/docker-entrypoint-initdb.d/10-trustedoss-app-role.sh:ro \
"$image" >/dev/null
wait_serving pg_l1
eth0="$SERVING_IP"
echo "postgres serving on eth0 $eth0"
# Ensure the init script's output has flushed before the negative
# syntax-error assertion (otherwise "no syntax error" could pass
# trivially against a not-yet-flushed log).
wait_log pg_l1 "provisioned trustedoss_app"
# (a) init completed with no syntax error (the #466 symptom)
if docker logs pg_l1 2>&1 | grep -i "syntax error"; then
echo "::error::init log contains 'syntax error' (regression of #466)"
exit 1
fi
echo "(a) OK — no syntax error in init log"
# (b) role exists with LOGIN + INHERIT
row=$(docker exec pg_l1 psql -U trustedoss -d trustedoss -tAc \
"SELECT rolname, rolcanlogin, rolinherit FROM pg_roles WHERE rolname='trustedoss_app'")
echo "(b) pg_roles row=[$row]"
if [ "$row" != "trustedoss_app|t|t" ]; then
echo "::error::trustedoss_app role missing or wrong attributes (want trustedoss_app|t|t)"
exit 1
fi
echo "(b) OK — role present, canlogin+inherit"
# (c) real SCRAM login with the special-char password over eth0 TCP
got=$(docker exec -e PGPASSWORD="$app_pw" pg_l1 \
psql -h "$eth0" -U trustedoss_app -d trustedoss -tAc "SELECT 1")
echo "(c) SELECT 1 => [$got]"
if [ "$got" != "1" ]; then
echo "::error::trustedoss_app could not authenticate over TCP with its password"
exit 1
fi
echo "(c) OK — SCRAM TCP login succeeded"
# (d) a wrong password is rejected
if docker exec -e PGPASSWORD="$wrong_pw" pg_l1 \
psql -h "$eth0" -U trustedoss_app -d trustedoss -tAc "SELECT 1" 2>/tmp/pwerr; then
echo "::error::wrong password was accepted"
cat /tmp/pwerr || true
exit 1
fi
cat /tmp/pwerr
if ! grep -q "password authentication failed" /tmp/pwerr; then
echo "::error::expected 'password authentication failed', got the above"
exit 1
fi
echo "(d) OK — wrong password rejected with FATAL"
# (e) no unexpected roles created (only built-ins + the two we want)
roles=$(docker exec pg_l1 psql -U trustedoss -d trustedoss -tAc \
"SELECT string_agg(rolname, ',' ORDER BY rolname) FROM pg_roles WHERE rolname NOT LIKE 'pg\_%'")
echo "(e) non-builtin roles=[$roles]"
if [ "$roles" != "trustedoss,trustedoss_app" ]; then
echo "::error::unexpected role census (want trustedoss,trustedoss_app)"
exit 1
fi
echo "(e) OK — role census clean"
echo "::endgroup::"
echo "::group::Scenario 3 — idempotency (replay the SQL on a live DB)"
# Re-apply the same script against the already-provisioned DB. The
# WHERE NOT EXISTS guard must make this a no-op: exit 0, role count
# stays 1. Runs the real script bytes piped into the container.
docker exec -i \
-e POSTGRES_USER=trustedoss \
-e POSTGRES_DB=trustedoss \
-e POSTGRES_APP_USER=trustedoss_app \
-e POSTGRES_APP_PASSWORD="$app_pw" \
pg_l1 bash -s < "$init_script"
cnt=$(docker exec pg_l1 psql -U trustedoss -d trustedoss -tAc \
"SELECT count(*) FROM pg_roles WHERE rolname='trustedoss_app'")
echo "role count after replay=[$cnt]"
if [ "$cnt" != "1" ]; then
echo "::error::idempotency violated — role count is $cnt, want 1"
exit 1
fi
echo "OK — replay is a clean no-op, count stays 1"
echo "::endgroup::"
echo "::group::Scenario 2 — legacy single-role mode (POSTGRES_APP_PASSWORD unset)"
docker run -d --name pg_legacy \
-e POSTGRES_USER=trustedoss \
-e POSTGRES_PASSWORD=trustedoss \
-e POSTGRES_DB=trustedoss \
-v "$init_script":/docker-entrypoint-initdb.d/10-trustedoss-app-role.sh:ro \
"$image" >/dev/null
wait_serving pg_legacy
# Wait for the skip line to flush (same buffering race as scenario 1).
wait_log pg_legacy "skipping L1 role provisioning"
if docker logs pg_legacy 2>&1 | grep -i "syntax error"; then
echo "::error::legacy init log contains 'syntax error'"
exit 1
fi
cnt=$(docker exec pg_legacy psql -U trustedoss -d trustedoss -tAc \
"SELECT count(*) FROM pg_roles WHERE rolname='trustedoss_app'")
echo "legacy trustedoss_app count=[$cnt]"
if [ "$cnt" != "0" ]; then
echo "::error::legacy mode must NOT create trustedoss_app (got $cnt)"
exit 1
fi
echo "OK — skip log present, no app role created, init clean"
echo "::endgroup::"
echo "All three L1 contract scenarios passed."
- name: Clean up containers
if: always()
run: docker rm -f pg_l1 pg_legacy 2>/dev/null || true
# -------------------------------------------------------------------------
# 2) Typecheck — mypy (backend) + tsc (frontend)
# -------------------------------------------------------------------------
typecheck:
name: typecheck (${{ matrix.target }})
runs-on: ubuntu-22.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
target: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Setup Python
if: matrix.target == 'backend'
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: apps/backend/requirements-dev.txt
- name: Install backend dev deps
if: matrix.target == 'backend'
working-directory: apps/backend
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run mypy
if: matrix.target == 'backend'
working-directory: apps/backend
run: mypy .
- name: Setup Node
if: matrix.target == 'frontend'
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name: Install frontend deps
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm ci --no-audit --no-fund
- name: Run tsc
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm run typecheck
# -------------------------------------------------------------------------
# 3) Test — pytest (unit, integration) + vitest (with coverage)
# Each backend leg spins up its own Postgres 17 + Redis 7 sidecars.
# CLAUDE.md §2 (품질·보안·운영 표준): PR 머지 게이트 = 신규/변경 코드
# line coverage ≥ 80%. Neither leg judges coverage on its own — they
# upload raw coverage data and `coverage-gate` combines it, because a
# single leg's number is meaningless (unit alone never reaches 80%).
#
# The suites used to run in one 18-minute serial job. They are split by
# directory rather than sharded because the suite carries real state
# between tests: rows a test leaves behind are read by a later one. Two
# legs on two fresh databases is safe (and removes the unit → integration
# pollution the single job had); finer shards inside a directory are not.
# -------------------------------------------------------------------------
test:
name: test (${{ matrix.target }})
runs-on: ubuntu-22.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target: [backend-unit, backend-integration, frontend]
services:
postgres:
image: postgres:17.2-alpine
env:
POSTGRES_USER: trustedoss
POSTGRES_PASSWORD: trustedoss
POSTGRES_DB: trustedoss
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U trustedoss -d trustedoss"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7.4-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://trustedoss:trustedoss@localhost:5432/trustedoss
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: ci-fixture-secret-key-for-workflow-runs
# Here a database is promised, so not having one is a failure and not a
# reason to skip. Without this the tests that need a schema skip when the
# migration breaks, and the job that exists to catch a broken migration
# exits 0. Measured before this flag existed: a deliberately broken head
# migration left the integration leg with 1824 of its tests skipped and
# the unit leg with 989, and what turned either leg red was a handful of
# modules that had no gate at all rather than the gates working.
# tests/_db_required.py holds the rule.
TRUSCA_TESTS_REQUIRE_DB: "1"
steps:
- uses: actions/checkout@v4
- name: Setup Python
if: startsWith(matrix.target, 'backend')
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: apps/backend/requirements-dev.txt
- name: Install backend dev deps
if: startsWith(matrix.target, 'backend')
working-directory: apps/backend
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# Marathon bundle 3 (D2) — the backup round-trip integration test
# (tests/integration/test_backup_task_round_trip.py) runs the real
# ``pg_dump`` / ``psql`` binaries against the Postgres 17 service
# container. Ubuntu 22.04 ships pg_dump 14, which refuses with
# "server version mismatch" against a Postgres 17 server. Add the
# PGDG apt repo (fingerprint-pinned, same as the worker / backend
# Dockerfiles) and install postgresql-client-17 so pg_dump matches
# the server major version. Without this the round-trip test fails
# in CI even though it passes locally inside the dev container.
- name: Install postgresql-client-17 (matches Postgres service container)
if: startsWith(matrix.target, 'backend')
run: |
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /tmp/pgdg.asc
gpg --show-keys --with-fingerprint --with-colons /tmp/pgdg.asc \
| grep '^fpr:' \
| grep -q ':B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8:'
sudo gpg --dearmor -o /usr/share/keyrings/postgresql.gpg /tmp/pgdg.asc
rm /tmp/pgdg.asc
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
| sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends postgresql-client-17
pg_dump --version
# W2: tests/integration/test_helm_notes_connection_budget.py renders
# charts/trustedoss with `helm install --dry-run` (no cluster touched)
# and cross-checks NOTES.txt's connection-budget arithmetic against
# core.connection_budget, the same oracle .env.example documents
# against. Without helm on PATH the test skips rather than fails
# (matches this repo's convention for a missing real external tool),
# so this step is what turns "skipped" into "actually ran" on every PR.
- name: Setup Helm (chart render golden, backend-integration only)
if: matrix.target == 'backend-integration'
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with:
version: v3.16.3
# No per-leg coverage verdict: `--cov-fail-under=0` disables the
# pyproject `fail_under` here so a leg cannot fail on a number that only
# means something once both legs are combined. `coverage-gate` does the
# judging. The data file is renamed to `.coverage.<leg>` so
# `coverage combine` picks both up without them colliding.
- name: Run pytest (${{ matrix.target }})
if: startsWith(matrix.target, 'backend')
working-directory: apps/backend
env:
SUITE: ${{ matrix.target == 'backend-unit' && 'tests/unit' || 'tests/integration' }}
run: |
set -euo pipefail
pytest --cov=. --cov-report= --cov-fail-under=0 "$SUITE"
mv .coverage ".coverage.${{ matrix.target }}"
# v2.1 Track B (B4) — NOTE: there is deliberately NO byte-exact OpenAPI
# freshness gate here. A previous version ran
# `python scripts/dump_openapi.py --check` to diff the committed
# docs-site/static/openapi.json against `app.openapi()`. That gate was
# brittle across environments: FastAPI/Pydantic serialise the schema
# slightly differently depending on the Python / library minor version,
# so a snapshot generated locally on Python 3.11 would not be
# byte-identical to one regenerated on CI's Python 3.12 — failing the PR
# for a non-substantive reason. The committed openapi.json is now only a
# local-build fallback; the docs deploy workflow (.github/workflows/
# docs.yml) regenerates it from the live app on the same Python as CI
# before publishing, so the hosted Redoc reference is always fresh.
#
# Real contract drift (a route / method / param added or removed) is
# still gated — version-independently — by the METHOD+path→param subset
# check in apps/backend/tests/unit/test_openapi_contract.py
# (openapi_endpoints.json), which runs as part of the pytest step above.
# `include-hidden-files` is not optional here: `.coverage.<leg>` starts
# with a dot, and upload-artifact v4 drops hidden files by default. Without
# it the gate job downloads nothing and combines nothing.
- name: Upload backend coverage data
if: startsWith(matrix.target, 'backend')
uses: actions/upload-artifact@v4
with:
name: backend-coverage-${{ matrix.target }}
path: apps/backend/.coverage.${{ matrix.target }}
include-hidden-files: true
if-no-files-found: error
# Consumed by coverage-gate minutes later; nothing reads them after.
retention-days: 7
- name: Setup Node
if: matrix.target == 'frontend'
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name: Install frontend deps
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm ci --no-audit --no-fund
- name: Run vitest (with coverage gate ≥ 80%)
if: matrix.target == 'frontend'
working-directory: apps/frontend
run: npm run test:coverage
- name: Upload frontend coverage
if: matrix.target == 'frontend'
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/
if-no-files-found: ignore
# See backend-coverage above.
retention-days: 7
# -------------------------------------------------------------------------
# 3b) Coverage gate — combine the backend legs and judge two thresholds.
#
# Global: `coverage report` applies pyproject's `fail_under = 80`, the
# same number the single pytest job enforced before the split. Nothing
# about the threshold changes; only where it is evaluated does.
#
# Changed lines: CLAUDE.md §2 asks for ≥ 80% on NEW or CHANGED code, and
# until now nothing measured that — the global line rate was the whole
# gate, so a large well-covered codebase absorbed an untested new module
# without moving the number. diff-cover reads the same coverage.xml and
# scores only the lines this pull request touches.
#
# `coverage` is pinned in requirements-dev.txt and repeated here: the
# legs write the data files with that version and this job reads them,
# so the two pins move together or `combine` fails on a format it does
# not recognise.
# -------------------------------------------------------------------------
coverage-gate:
name: coverage-gate (backend)
needs: test
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# diff-cover diffs against the base branch, which a depth-1 checkout
# does not have.
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install coverage tooling
run: pip install "coverage[toml]==7.15.0" "diff-cover==10.4.1"
- name: Download coverage data from both legs
uses: actions/download-artifact@v4
with:
pattern: backend-coverage-*
path: /tmp/coverage-data
merge-multiple: true
# `coverage xml` writes the report BEFORE the threshold is judged
# (`--fail-under=0` suppresses its own verdict) so the artifact survives a
# failing gate — the file is how you find out which lines are missing.
- name: Combine and enforce the global threshold
working-directory: apps/backend
run: |
set -euo pipefail
cp /tmp/coverage-data/.coverage.* .
coverage combine .coverage.backend-unit .coverage.backend-integration
coverage xml --fail-under=0
coverage report --show-missing --skip-covered
- name: Enforce 80% on the lines this pull request changed
if: github.event_name == 'pull_request'
working-directory: apps/backend
run: |
set -euo pipefail
git fetch --no-tags --depth=200 origin "$BASE_REF"
diff-cover coverage.xml \
--compare-branch="origin/$BASE_REF" \
--fail-under=80
env:
BASE_REF: ${{ github.base_ref }}
- name: Upload combined coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
if-no-files-found: ignore
retention-days: 7
# -------------------------------------------------------------------------
# 4) Image scan — build the Celery worker image (cdxgen / scancode / Trivy +
# JRE + Node) and scan it with Trivy. Fails on HIGH or CRITICAL OS or
# library vulnerabilities. Runs in parallel with lint/typecheck/test so
# overall PR latency is unchanged.
# CLAUDE.md core rule #9: no `:latest` tags. Tool versions pinned in
# apps/backend/Dockerfile.worker.
# -------------------------------------------------------------------------
image-scan:
name: image-scan (worker)
runs-on: ubuntu-22.04
timeout-minutes: 30
# Chore P (Phase 8 worker-image refresh): HIGH is now HARD FAIL alongside
# CRITICAL. Implemented as a single Trivy step with `severity:
# CRITICAL,HIGH`. The previous two-step split (CRITICAL hard /
# HIGH advisory, chore PR #25) was a deliberate stop-gap while we
# collected reach-analysis on the multi-language toolchain bundle
# findings (Maven jars, cdxgen-plugins-bin Go binaries, ORT java
# jars). All of those are now either bumped (Go SDK 1.22 → 1.25,
# ORT 85.0.0 → 85.1.1, npm 11.13.0 → 11.14.1, Gradle 8.10.2 → 8.14.3)
# or carry per-CVE reach-analysis ignores in `.trivyignore` with
# category-(3) justification (cdxgen-plugins-bin runtime-unreached
# since we never invoke any cdxgen plugin — see the cdxgen reach
# surface block in `.trivyignore` and apps/backend/integrations/
# cdxgen.py:107-115).
#
# `ignore-unfixed: true` keeps us from chasing CVEs upstream hasn't
# patched yet — those remain visible in the table but never bake
# into the actionable list. `trivyignores: ./.trivyignore` makes the
# file the single source of truth for accepted findings; new HIGH
# CVEs from a future bundle dep bump must either land an upstream
# fix or get a reach-analysis ignore through code review.
#
# Re-evaluate the .trivyignore entries every 180 days OR on next
# upstream release of the bundled tool — see `.trivyignore` policy
# header.
steps:
- uses: actions/checkout@v4
# The worker image carries a JVM, Gradle, the Node toolchain, cdxgen,
# cosign and Trivy, and the scan does not read it in place: Trivy exports
# an UNCOMPRESSED copy through the Docker daemon before analysing it. So
# the runner has to hold the image and its expansion at once, and a
# hosted runner's free space stopped being enough for that on
# 2026-08-01 — two consecutive runs died mid-scan with "no space left on
# device", after the same image had scanned clean an hour earlier.
#
# Deleting the preinstalled toolchains this job never touches reclaims
# more than 20 GB and takes seconds. `|| true` because the set of
# preinstalled directories is the runner image's business, not ours, and
# a missing one must not fail the build.
- name: Free runner disk space
run: |
echo "before:"; df -h / | tail -1
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost /usr/local/share/powershell \
/usr/local/.ghcup "${AGENT_TOOLSDIRECTORY:-}" || true
echo "after:"; df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Trivy DB
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: trivy-db-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
trivy-db-${{ runner.os }}-
# The apt layers pin whatever package versions they were first built
# against, and a layer that is CACHED does not run — so the
# `apt-get upgrade -y` inside them upgrades nothing on a cache hit. That
# is how this job came to fail on a php-cli CVE whose fix Debian had
# already shipped: the release build (a different cache scope, populated
# per release) had the patched package, while this gate was still reading
# a layer built weeks earlier. A gate measuring something other than what
# ships is worse than no gate.
#
# Rotating the scope weekly bounds that drift to one week — Debian's
# security updates land on roughly that cadence — at the cost of one cold
# build per week. Pinning it to the run id instead would make every PR a
# cold build of a JDK + Node + cdxgen + scancode image; scoping it per commit
# would never hit at all.
- name: Cache scope (rotates weekly)
id: cache-scope
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Build worker image
uses: docker/build-push-action@v6
with:
context: apps/backend
file: apps/backend/Dockerfile.worker
tags: trustedoss/backend-worker:dev
load: true
cache-from: type=gha,scope=worker-${{ steps.cache-scope.outputs.week }}
cache-to: type=gha,scope=worker-${{ steps.cache-scope.outputs.week }},mode=max
- name: Trivy scan (HARD FAIL on CRITICAL,HIGH)
# Chore P (Phase 8 worker-image refresh): single combined gate.
# Replaces the chore PR #25 two-step split (CRITICAL hard / HIGH
# advisory). All previously-advisory HIGH findings are now either
# patched via base-image dep bumps (Go SDK / ORT / Gradle / npm —
# see Dockerfile.worker `Chore P` comments) or accepted with a
# per-CVE reach-analysis ignore in `.trivyignore`. New HIGH
# findings from a future dep bump must clear the same bar
# (upstream fix OR reach-analysis ignore) before merge.
#
# `ignore-unfixed: true` keeps us from chasing CVEs upstream
# hasn't patched yet — those remain visible in the table for
# triage but never bake into the actionable list.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: trustedoss/backend-worker:dev
format: table
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
vuln-type: os,library
# `scanners: vuln` explicitly disables Trivy's secret + license
# scanners on this gate. We gate on CVEs here; secrets are
# caught by the separate gitleaks job (cleaner separation of
# concerns), and license detection belongs to scancode and cdxgen
# in the runtime pipeline (ORT was removed in PR-A2). Without this,
# Go SDK 1.25 ships a test-fixture
# private key at `/opt/go/src/crypto/x509/platform_root_key.pem`
# (NOT an operational secret — it's a Go stdlib test artifact
# bundled with the source-only `src/` tree) which Trivy's
# secret scanner flags as HIGH and breaks this gate. Disabling
# the secret scanner here is preferred over a per-file ignore
# because the same Go source tree contains other test fixtures
# that may surface as secrets in future Go versions.
scanners: vuln
trivyignores: ./.trivyignore
# Trivy's default deadline is 5 minutes, and this image does not fit
# in it on a cold runner. The worker carries a JDK, a Node runtime,
# a .NET SDK and two extra Python environments, and the Java DB alone
# is ~900 MB to fetch before analysis starts. The first run against
# the rebuilt repository — no Actions cache to draw on — died with
# "context deadline exceeded" partway through a layer walk, which the
# gate reports the same way it reports a CVE.
#
# 20 minutes is the walk time with a cold Java DB plus room. Raise it
# rather than trim the scan: a scan that times out is a gate that
# tells you nothing.
timeout: 20m
# -------------------------------------------------------------------------
# 5) Frontend bundle audit — production build must not contain dev-only
# auth hooks (`__setAccessToken`, `__authStore`) or scaffolded secret
# placeholders. The hooks are gated by `import.meta.env.DEV` in
# apps/frontend/src/lib/api.ts so Vite's tree-shaker drops them at
# `vite build` time. This job is the regression guard: any future
# refactor that moves the hook out of a `DEV` block (or imports it
# eagerly) will land a literal "__setAccessToken" string in
# apps/frontend/dist/ and fail the gate. Hard-fail (no
# continue-on-error) — the cost of a leaked auth backdoor in the
# production bundle is unacceptable.
# -------------------------------------------------------------------------
frontend-bundle-audit:
name: frontend-bundle-audit
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name: Install frontend deps
working-directory: apps/frontend
run: npm ci --no-audit --no-fund
- name: Production build
working-directory: apps/frontend
run: npm run build
# `grep -r` returns 0 when matches are found, 1 when not. We invert with
# `!`. Each pattern is a separate step so a CI failure points at the
# exact leak. We deliberately do NOT grep for `import.meta.env.DEV` —
# that text is replaced by the literal `false` at build time and Vite
# tree-shakes the gated branch, so its absence in source ≠ its absence
# in dist; checking the actual symptom (the hook identifier) is the
# honest signal.
- name: Audit dist — no __setAccessToken leak
working-directory: apps/frontend
run: |
if grep -r "__setAccessToken" dist/; then
echo "::error::__setAccessToken leaked into production bundle"
exit 1
fi
- name: Audit dist — no __authStore leak
working-directory: apps/frontend
run: |
if grep -r "__authStore" dist/; then
echo "::error::__authStore leaked into production bundle"
exit 1
fi
- name: Audit dist — no VITE_DEV_SECRET placeholder
working-directory: apps/frontend
run: |
if grep -r "VITE_DEV_SECRET" dist/; then
echo "::error::VITE_DEV_SECRET placeholder leaked into production bundle"
exit 1
fi
# -------------------------------------------------------------------------
# 6) Docs build — compile the Docusaurus site (EN + KO).
#
# Nothing checked the documentation site on a pull request. docs.yml only
# runs on a push to main, so an MDX syntax error or a broken link merged
# green and first appeared in a deploy nobody was watching. The site is
# now built with `onBrokenLinks: throw`, which makes this the check that
# catches it, on the pull request that caused it.
#
# No paths filter, for the reason the pull_request trigger has none: this
# is a required-check candidate and it has to report on every pull
# request. It costs about two minutes with the npm cache warm, and the
# backend is not installed here — the build reads the committed
# static/openapi.json (docs.yml regenerates it for the real deploy).
# -------------------------------------------------------------------------
docs-build:
name: docs-build
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: docs-site/package-lock.json
- name: Install docs deps
working-directory: docs-site
run: npm ci --no-audit --no-fund
- name: Build site (EN + KO)
working-directory: docs-site
run: npm run build
# -------------------------------------------------------------------------
# 7) E2E — Playwright against a real docker-compose dev stack with the
# Celery worker in mock-scan mode. Runs in parallel with the other jobs
# so PR latency is gated by the slowest job, not the sum.
#
# The worker uses TRUSTEDOSS_SCAN_BACKEND=mock so cdxgen / scancode / Trivy
# short-circuit to fixture frames — the real toolchain takes 5-60min
# per scan and would blow the 15min timeout.
#
# Auth surface has no team-creation endpoint at this Phase, so a brand-
# new user can't list/create projects. The Playwright harness
# (`tests/_harness/seed.ts`) spawns `python3
# apps/backend/scripts/seed_e2e_user.py` on the runner per scenario
# against the host-mapped Postgres. We also smoke-run it once via
# `docker-compose exec` before Playwright starts so a broken seed
# fails fast.
# -------------------------------------------------------------------------
e2e:
# Nightly-only — the matrix takes ~8 minutes per shard and would
# double the typical PR wall-clock. The job still runs on every
# workflow_dispatch (maintainer can verify before a risky merge)
# and on the daily 04:00 UTC schedule (catches drift on main
# between PR runs).
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
# Matrix split (PR #46, Phase 6 of post-GA Manual Walkthrough):
# - shard=scan-flow → 9 spec / 39 scenario (existing core)
# - shard=manual-aligned → 3 spec / 27 scenario (Phase 5 PR #45)
# Both shards run the same dev stack + Playwright config but `--grep`
# filter on the `@manual-aligned` tag. Running them as separate matrix
# legs parallelises wall-time (~6 min cap) and attributes failures
# cleanly without duplicating the 130-line setup block.
name: e2e (${{ matrix.shard }})
# The dev images are pulled from ghcr rather than rebuilt — see
# .github/actions/dev-images.
permissions:
contents: read
packages: read
strategy:
fail-fast: false
matrix:
include:
- shard: scan-flow
grep_args: "--grep-invert @manual-aligned"
- shard: manual-aligned
grep_args: "--grep @manual-aligned"
runs-on: ubuntu-22.04
# 30, not 20. The comment above still says "~8 minutes per shard"; that
# stopped being true some time ago. The scan-flow shard's last clean run
# took 17m48s wall-clock including stack bring-up, so a 20-minute cap left
# 11% headroom — and on 2026-08-01 a slower runner (the manual-aligned
# shard went from 10m55s to 13m55s for identical tests) pushed scan-flow
# past it and the job was killed mid-suite with nothing failing.
#
# This still guards what a timeout is for. A hung stack or a Playwright
# deadlock blows through 30 minutes just as surely; what it no longer does
# is fail a suite for finishing at its normal speed on a bad day.
timeout-minutes: 30
env:
# The worker's mock backend short-circuits cdxgen/scancode/Trivy. Compose
# picks this up via the `<<: *backend-env` anchor in
# docker-compose.dev.yml.
TRUSTEDOSS_SCAN_BACKEND: mock
# Pin SECRET_KEY so the JWT signer doesn't rotate between containers
# (worker + api would mutually invalidate tokens otherwise).
SECRET_KEY: ci-e2e-fixture-secret-key-for-workflow-runs
# Vite dev server URL — Playwright's default. Pinned so a future
# config drift doesn't silently swap the browser target.
PLAYWRIGHT_BASE_URL: http://localhost:5173
# Disable slowapi 5/min login limit for the e2e job only. The runner
# has a single egress IP, so 7 cumulative logins (auth.spec.ts ×3 +
# scan_flow.spec.ts ×4) trip the limit on the third / fourth scenario.
# Production / dev keep this unset → limiter enabled by default.
RATELIMIT_DISABLED: "1"
# The per-address sign-in throttle defaults to on, and a suite that
# drives /auth/login repeatedly (fuzzing, documented status codes)
# would start meeting 429 where it expects 401 or 422.
LOGIN_THROTTLE_ENABLED: "false"
# /health/ready (the dev compose backend healthcheck) returns 200
# only after Alembic schema is at HEAD. Set AUTO_MIGRATE so the
# entrypoint runs `alembic upgrade head` before uvicorn binds —
# without this the healthcheck loop sees a backend that answers
# liveness but flunks readiness for the full deadline, then the
# dependent services (celery-worker, frontend) never reach
# `service_healthy`.
AUTO_MIGRATE: "true"
# C8 phase 3: external_package_lookup.spec.ts drives the intake
# prefill flow, which needs the queue turned on. Scoped to this job's
# own compose stack only; ui-gates.yml is a separate workflow and
# never sees this var, so its baselines stay unaffected.
INTAKE_REQUESTS_ENABLED: "true"
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
# Backend deps are installed on the host runner only because the
# Playwright harness (`tests/_harness/seed.ts`) spawns `python3
# apps/backend/scripts/seed_e2e_user.py` directly; the script imports
# SQLAlchemy + asyncpg + bcrypt. We could rewrite the harness to
# `docker-compose exec` instead, but the per-scenario seed cost goes
# from ~200ms to ~1.5s — measurable on a 4-scenario suite. Host
# python wins.
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: apps/backend/requirements-dev.txt
- name: Install backend deps (for host-side seed)
working-directory: apps/backend
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# The key has to move when the pinned Playwright version moves, or the
# cache is worse than none: a literal key kept "hitting" on browsers
# installed for 1.48.2 long after the pin reached 1.60.0, so every run
# restored a useless cache, downloaded ~150 MB of browsers anyway, and
# could not save them because the key already existed. Deriving it from
# the lockfile ties it to the version without anyone remembering to.
# restore-keys lets a lockfile bump reuse the previous browsers for the
# engines that did not change.
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('apps/frontend/package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install frontend deps
working-directory: apps/frontend
run: npm ci --no-audit --no-fund
- name: Install Playwright browsers
working-directory: apps/frontend
run: npx playwright install --with-deps chromium
# CI uses docker compose V2 (`docker compose`) because ubuntu-22.04
# runners no longer ship docker-compose V1 (the binary was removed
# from hosted images in 2024; first PR #9 push surfaced the gap with
# exit 127 on this verify step). Host development still uses V1 per
# CLAUDE.md core rule #10 — the divergence is contained to this job
# and V2 reads the same docker-compose.dev.yml unchanged.
- name: Verify docker compose V2 available
run: docker compose version
# Pre-build the backend / worker / frontend images BEFORE bringing the
# stack up. The image refs in docker-compose.dev.yml
# (\`trustedoss/{backend,backend-worker,frontend}:dev\`) are not
# published anywhere — compose tries Docker Hub, gets a "denied"
# warning, then falls back to the local \`build:\` directive. On a
# cold runner that build adds 2-3 minutes to the startup wall clock,
# which used to spill past the 180-second healthcheck deadline below.
# Building explicitly here also surfaces a build error as its own
# step rather than as a confusing "container unhealthy" 3 minutes
# later.
- name: Pre-build dev images
uses: ./.github/actions/dev-images
with:
services: backend celery-worker frontend
token: ${{ secrets.GITHUB_TOKEN }}
# The dev backend image's CMD is bare uvicorn — only the prod
# Dockerfile wires up docker-entrypoint.sh, so AUTO_MIGRATE never
# actually runs on a dev image. We migrate explicitly here against a
# throwaway backend container BEFORE bringing the live services up
# so backend's /health/ready (queries alembic_version) can flip to
# 200 on the very first probe.
- name: Start postgres + redis only
run: |
docker compose -f docker-compose.dev.yml up -d postgres redis
- name: Wait for postgres healthy
run: |
set -euo pipefail
deadline=$(( $(date +%s) + 60 ))
while :; do
cid=$(docker compose -f docker-compose.dev.yml ps -q postgres)
status=$(docker inspect --format '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo "none")
[ "$status" = "healthy" ] && break
[ "$(date +%s)" -ge "$deadline" ] && { echo "::error::postgres never healthy"; exit 1; }
sleep 3
done
- name: Run alembic migrations (pre-boot)
run: |
docker compose -f docker-compose.dev.yml run --rm --no-deps backend \
alembic upgrade head
- name: Bring up the rest of the stack
run: |
docker compose -f docker-compose.dev.yml up -d \
backend celery-worker frontend
- name: Wait for stack to become healthy
# Compose V1 has no `--wait`; poll the `healthy` count until all five
# services report green or 240s elapses. The deadline was 180s but a
# cold-image-cache backend (Postgres init + alembic upgrade head +
# uvicorn boot) regularly used 180-220s on the hosted runner. 240s
# leaves headroom; the pre-build step above keeps total wall-clock
# bounded.
run: |
set -euo pipefail
services=(postgres redis backend celery-worker frontend)
deadline=$(( $(date +%s) + 240 ))
while :; do
healthy=0
for svc in "${services[@]}"; do
cid=$(docker compose -f docker-compose.dev.yml ps -q "$svc")
if [ -z "$cid" ]; then
continue
fi
status=$(docker inspect --format '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo "none")
if [ "$status" = "healthy" ]; then
healthy=$(( healthy + 1 ))
fi
done
echo "healthy=$healthy/${#services[@]}"
if [ "$healthy" -eq "${#services[@]}" ]; then
break
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "::error::stack did not become healthy within 180s"
docker compose -f docker-compose.dev.yml ps
exit 1
fi
sleep 5
done
- name: Seed e2e user (fail fast if seed broken)
# Smoke-run the seed script once before Playwright fans out, so a
# broken seed surfaces in <5s instead of after a full Playwright
# boot. The spec re-runs the script per scenario for isolation.
run: |
docker compose -f docker-compose.dev.yml exec -T backend \
python scripts/seed_e2e_user.py --project-names ci-smoke
- name: Run Playwright e2e (${{ matrix.shard }})
working-directory: apps/frontend
env:
# The harness `seed.ts` uses these to spawn python3 against the
# docker-compose Postgres mapped to localhost:5432. We override
# the default `localhost` host because GitHub runners expose
# mapped ports there.
DATABASE_URL: postgresql+asyncpg://trustedoss:trustedoss@localhost:5432/trustedoss
APP_ENV: dev
PYTHON: python3
CI: "true"
run: npm run test:e2e -- ${{ matrix.grep_args }}
- name: Dump compose logs on failure
if: failure()
run: docker compose -f docker-compose.dev.yml logs --tail=200
- name: Upload Playwright report
# Per-shard artifact name — both shards upload concurrently and
# actions/upload-artifact@v4 rejects duplicate names.
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: apps/frontend/playwright-report/
if-no-files-found: ignore
retention-days: 7
- name: Upload Playwright traces / screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results-${{ matrix.shard }}
path: apps/frontend/test-results/
if-no-files-found: ignore
retention-days: 7
- name: Tear down stack
if: always()
run: docker compose -f docker-compose.dev.yml down -v
# -------------------------------------------------------------------------
# 7) Nightly alert — make a red schedule run visible.
#
# The e2e matrix only runs on the schedule, so when the nightly failed on
# 2026-08-10 and 08-11 (a real regression in the malicious-component
# filter and the conformance panel) nothing said so: no PR was blocked,
# no notification fired, and the failures were found days later by an
# audit. This job turns that into a tracked issue and closes it on the
# next green nightly.
#
# Schedule only. On a PR the checks themselves are the signal, and on a
# push to main the same run reports on the commit.
# -------------------------------------------------------------------------
nightly-alert:
name: nightly-alert
if: ${{ always() && github.event_name == 'schedule' }}
needs:
- lint
- shellcheck
- postgres-init-l1
- typecheck
- test
- coverage-gate
- image-scan
- frontend-bundle-audit
- docs-build
- e2e
runs-on: ubuntu-22.04
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github/actions/nightly-failure-issue
- uses: ./.github/actions/nightly-failure-issue
with:
label: nightly-ci
title: "Nightly CI is failing"
# `contains(needs.*.result, 'failure')` covers a failure anywhere in
# the matrix. Cancelled is deliberately NOT a failure: a cancel means
# something superseded the run, not that a gate found a defect.
failed: ${{ contains(needs.*.result, 'failure') && 'true' || 'false' }}
details: |
Job results: lint=${{ needs.lint.result }},
shellcheck=${{ needs.shellcheck.result }},
postgres-init-l1=${{ needs.postgres-init-l1.result }},
typecheck=${{ needs.typecheck.result }},
test=${{ needs.test.result }},
coverage-gate=${{ needs.coverage-gate.result }},
image-scan=${{ needs.image-scan.result }},
frontend-bundle-audit=${{ needs.frontend-bundle-audit.result }},
docs-build=${{ needs.docs-build.result }},
e2e=${{ needs.e2e.result }}.
The e2e matrix runs on this schedule only, so a failure here is the
product's sole Playwright signal for the day. Playwright reports and
traces are attached to the run as artifacts.
token: ${{ secrets.GITHUB_TOKEN }}