Lift the two fixtures that had nothing left to decide (#376) #1010
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |