--- name: mvp-deploy-safety-campaign description: Load when working on CI/CD safety for MotoVaultPro - adding tests/lint to CI, fixing the ":latest" image tag hazard, changing PR staging deploys, or when you notice symptoms like "CI runs zero tests", "PR builds overwrite latest", "production deployed an unmerged PR build", "every PR redeploys staging", "tests never ran in the pipeline", "image_tag defaults to latest", or "the PR was green but the code is broken". This is the executable, decision-gated campaign to close the deploy-safety hole (owner-confirmed hardest live problem, 2026-07-07). Also load before touching .gitea/workflows/staging.yaml or production.yaml for any reason. --- # Deploy Safety Campaign An executable, phased campaign to close MotoVaultPro's deploy-safety hole. Verified state as of 2026-07-07: 1. CI runs ZERO tests and ZERO lint. The only PR gate is that 3 Docker images build (tsc compiles inside them) and the staging stack boots healthy. 2. Every PR build pushes `backend:latest`, `frontend:latest`, `ocr:latest` to the registry (`.gitea/workflows/staging.yaml` build job). 3. Production deploys default to `image_tag: latest` (`.gitea/workflows/production.yaml:14-17`), so one click can ship an unmerged PR build to production. 4. Every PR open/sync/reopen tears down and redeploys the single shared staging environment (last PR wins). ## When to use / When NOT to use Use this skill when: executing any phase of this campaign; adding test/lint jobs to CI; changing image tagging or the production `image_tag` input; changing when staging deploys happen; or evaluating whether the campaign's gates still hold. Do NOT use this skill for: - How to classify, review, and ship a change (issue/branch/PR/labels): `mvp-change-control`. Every phase here ships THROUGH that process, never around it. - Running or fixing tests locally, jest/pytest traps: `mvp-build-and-env` - What counts as test evidence, adding tests, mobile+desktop validation: `mvp-validation-and-qa` - Deploy mechanics, blue-green, rollback, backups: `mvp-run-and-operate` - Debugging a broken staging/prod stack right now: `mvp-debugging-playbook` - Measuring outcomes in Grafana/Loki: `mvp-diagnostics-and-logging` ## Ground rules (non-negotiable) - Every change in this campaign ships via `mvp-change-control`: issue -> branch `issue-{N}-{slug}` -> PR -> staging verify -> owner review -> merge. One issue per phase (see Validation protocol at the end). - Pipeline changes (any edit under `.gitea/workflows/`) and anything touching database strategy require explicit owner sign-off in the issue before the PR is opened. - NEVER point the backend integration tests at a shared database. They run real migrations in `beforeAll` and `DROP TABLE ... CASCADE` in `afterAll` (verified: `backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts:37-41`). Ephemeral database or nothing. - Never hand-edit files on the staging/prod servers; `rsync --delete` on every deploy reverts them. - Success at each gate is a number or an exact string, never "looks right". If an EXPECTED observation does not match, follow the branch instruction; do not improvise. Terminology: "runner" = the self-hosted Gitea act_runner on the staging host (`mvp-staging`), label `stage`, HOST mode (`runner_labels: "stage:host"` in `ansible/deploy-staging-runner.yml:25`) - jobs execute directly on the host shell, with Docker CLI and Node.js 20 (NodeSource, `ansible/deploy-staging-runner.yml:107-123`) available. Host mode means Gitea Actions `services:` blocks are NOT available; use `docker run` directly. --- ## Phase 0 - Baseline evidence Goal: capture the current gate reality in numbers, committed to the phase issue. No code changes. ### 0.1 Confirm CI runs zero tests/lint ```bash grep -rnE "npm (test|run lint|run type-check)|pytest|eslint|jest" .gitea/workflows/ ``` EXPECTED: no output, exit code 1 (verified 2026-07-07). IF you get hits: someone has already started this work. Stop, read the matching workflow lines and the phase issues, and reconcile before proceeding. ### 0.2 Confirm the :latest and PR-trigger hazards ```bash sed -n '8,13p' .gitea/workflows/staging.yaml # trigger block grep -cn ":latest" .gitea/workflows/staging.yaml # latest usage count grep -n -A4 "image_tag:" .gitea/workflows/production.yaml | head -8 ``` EXPECTED (verified 2026-07-07): - Trigger block shows `push: branches: [main]` AND `pull_request: types: [opened, synchronize, reopened]`. - 9 `:latest` occurrences in staging.yaml: 3 `--cache-from` (lines 58, 74, 85), 3 `-t ...:latest` (lines 60, 76, 87), 3 `docker push ...:latest` (lines 96-98). - production.yaml input: `image_tag` with `required: false` and `default: 'latest'` (lines 14-17). IF counts differ: Phase 3 may be partially done. Diff against git history (`git log -p .gitea/workflows/staging.yaml`) before prescribing anything. ### 0.3 Record local suite baselines Numbers measured 2026-07-07 on a dev machine (no `/app/config`, node v25). Your numbers go in the phase issue; if they differ materially from below, record yours as the new baseline and note the delta. Backend unit tests (integration excluded - note the pattern is the SUBSTRING `integration`, because `audit-log/__tests__/audit-log.integration.test.ts` and `user-import/tests/user-import.integration.test.ts` do not live under a `/integration/` directory): ```bash cd backend && npx jest --testPathIgnorePatterns "/node_modules/" "integration" --forceExit 2>&1 | tail -5 ``` EXPECTED (2026-07-07, re-confirmed 2026-07-09): `Test Suites: 15 failed, 10 passed, 25 total` / `Tests: 2 failed, 147 passed, 149 total`. The 15 failing suites split three ways, all verified: - 6 suites die with `Configuration file not found at /app/config/production.yml` - `config-loader.ts:155` defaults `CONFIG_PATH` to a container path and loads eagerly at import; any suite that transitively imports `core/config/database.ts` dies at load. - 7 suites fail ts-jest compilation (type errors inside test files: audit-log.routes, auth.service, documents.repository, documents.service, fuel-logs.service, vehicle-data.service, community-stations.service). `backend/tsconfig.json:28` excludes `**/*.test.ts`, so `npm run type-check` is green (verified) while ts-jest, which does type-check tests, fails these suites. Local type-check green does NOT mean tests compile. - 2 suites (documents.controller.tier, ocr-receipt) contain the 2 genuinely failing tests. Frontend (the `--reporters=default` flag is mandatory outside the container): ```bash cd frontend && npx jest --reporters=default 2>&1 | tail -5 ``` EXPECTED (2026-07-07): `Test Suites: 14 failed, 17 passed, 31 total` / `Tests: 17 failed, 196 passed, 213 total`, ~11s. Without the flag you get `Error: Could not resolve a module for a custom reporter. Module name: tdd-guard-jest` - `frontend/jest.config.ts:28-36` requires a reporter that is only declared in the root package.json (which has no node_modules), and hardcodes `projectRoot: '/home/egullickson/motovaultpro'`. OCR - pytest is not installed on dev machines; record inventory only: ```bash grep -rc "def test_" ocr/tests/*.py | awk -F: '{s+=$2} END {print s}' ``` EXPECTED: `310` (16 test files; verified 2026-07-07). `pytest>=7.4.0` and `pytest-asyncio` are in `ocr/requirements.txt:34-35`, so pytest IS installed inside the built OCR image. ### 0.4 Gate Phase 0 is complete when the phase issue contains: the three grep outputs from 0.1/0.2, the exact `Test Suites:`/`Tests:` lines for backend and frontend, the OCR count, and the list of failing suites/tests by name. That issue comment is the baseline every later phase is measured against. --- ## Phase 1 - Make the suites runnable in CI at all Prerequisite engineering. No workflow changes yet. Owner sign-off required on decisions 1B (database strategy) before implementation. ### 1A. Frontend jest portability (decision made: fix config, keep reporter optional) The blocker is two-fold: the `tdd-guard-jest` reporter cannot resolve, and `projectRoot` is a hardcoded Linux path. Two working options, verified: 1. CI-side only: run `npx jest --reporters=default`. CLI `--reporters` replaces the config's reporters before the module is resolved (verified 2026-07-07: run proceeds to real pass/fail output). Zero code change; every CI invocation must remember the flag. 2. Fix the config (recommended): make the reporter conditional so `npx jest` works everywhere. Diff shape for `frontend/jest.config.ts`: ```ts // replace the hardcoded reporters block (lines 28-36) with: const reporters: Config['reporters'] = ['default']; if (process.env.TDD_GUARD === '1') { reporters.push(['tdd-guard-jest', { projectRoot: process.cwd() }]); } // ...and inside config: reporters, ``` Gate 1A: `cd frontend && npx jest 2>&1 | tail -3` on a dev machine prints a `Tests:` summary line (numbers matching the Phase 0 baseline, not the reporter resolution error). The 17 failing frontend tests (14 suites) from Phase 0 must each be dispositioned: fixed, or moved out of the suite with a filed issue per suite. CI cannot gate on a suite that is red on day one. Record the final green count - it becomes the Phase 2 expected number. ### 1B. Backend test database strategy (owner sign-off REQUIRED) Non-negotiable: an EPHEMERAL PostgreSQL, created and destroyed per CI run. The integration suites DROP TABLE CASCADE on whatever database they reach. The runner IS the staging host - a misconfigured host/port reaches the staging database. Concrete shape (runs on the `stage` runner, host mode, so `docker run`, not `services:`): ```bash NET="ci-test-$SHORT_SHA" docker network create "$NET" docker run -d --rm --name "ci-pg-$SHORT_SHA" --network "$NET" --network-alias mvp-postgres \ -e POSTGRES_DB=motovaultpro -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=citest \ git.motovaultpro.com/egullickson/mirrors/postgres:18-alpine docker run -d --rm --name "ci-redis-$SHORT_SHA" --network "$NET" --network-alias mvp-redis \ git.motovaultpro.com/egullickson/mirrors/redis:8.4-alpine # ... run tests ... then teardown: docker stop "ci-pg-$SHORT_SHA" "ci-redis-$SHORT_SHA"; docker network rm "$NET" ``` The network aliases `mvp-postgres`/`mvp-redis` match `config/app/ci.yml`, which was clearly authored for this purpose BUT is currently broken: it fails the zod schema in `config-loader.ts` with `invalid_type ... path: ["auth0"]` (verified 2026-07-07 by pointing `CONFIG_PATH` at it). Part of 1B is completing `ci.yml` until it parses - copy the missing blocks (`auth0`, and any further zod complaints) from `config/app/production.yml` with dummy-safe values, iterating until one unit suite loads. Secrets: `config-loader.ts:175-205` reads EXTENSION-LESS files (`postgres-password`, not `postgres-password.txt`) from `SECRETS_DIR` and requires 8 of them to parse. For tests, generate dummies: ```bash mkdir -p /tmp/ci-secrets for f in auth0-client-secret auth0-management-client-id auth0-management-client-secret \ google-maps-api-key resend-api-key stripe-secret-key stripe-webhook-secret; do echo "ci-dummy" > /tmp/ci-secrets/$f done echo "citest" > /tmp/ci-secrets/postgres-password # must match POSTGRES_PASSWORD above ``` Execution vehicle for backend tests in CI - two options, pick one in the phase issue: - Host node 20 (simplest): `cd backend && npm ci && CONFIG_PATH=$PWD/../config/app/ci.yml SECRETS_DIR=/tmp/ci-secrets npx jest --forceExit`. Requires connecting to the ephemeral DB via published port instead of network alias (host is not on the docker network): publish `-p 127.0.0.1:55432:5432` and set `ci.yml` database host/port accordingly. - Builder-stage image (hermetic): the backend CI build uses context `.` with NO root `.dockerignore` (verified: `backend/.dockerignore` exists but does not apply when context is the repo root), so tests and `jest.config.js` ARE inside the builder stage. `docker build --target builder -t backend-test -f backend/Dockerfile .` then `docker run --rm --network "$NET" -v /tmp/ci-secrets:/run/secrets:ro -v $PWD/config/app/ci.yml:/app/config/ci.yml:ro -e CONFIG_PATH=/app/config/ci.yml backend-test npx jest --forceExit`. Also disposition the ts-jest compile failures and 2 failing tests from Phase 0 (fix or quarantine-with-issue, per suite). Gate 1B: on the runner (manual run or a draft workflow), backend unit suites + integration suites complete against the ephemeral DB with a recorded `Tests:` line matching the post-fix local run, AND `docker exec mvp-postgres-staging psql -U postgres -d motovaultpro -c "\dt" ` before/after shows an unchanged table count (proof the shared staging DB was untouched). ### 1C. OCR pytest in CI Tests are copied into the image (`ocr/Dockerfile` `COPY . .`, no `ocr/.dockerignore` exists) and pytest is installed. Candidate command (UNVERIFIED end-to-end - `app.main` may import things that need config at load): ```bash docker run --rm --entrypoint python "$OCR_IMAGE" -m pytest tests -q ``` EXPECTED shape: `N passed` with N near 310. IF import errors on config/WIF paths: capture the traceback in the phase issue and add the minimal env/mount it names; do not skip the suite silently. ### 1D. Frontend tests in CI `frontend/.dockerignore` excludes `*.test.ts` / `*.test.tsx` (verified), so image stages cannot run tests today. Options: - Host node 20 (recommended, simplest): `cd frontend && npm ci && npx jest --reporters=default` (flag unnecessary after 1A fix). - Remove the test exclusions from `frontend/.dockerignore` and run in the `build` stage. Safe for image size: the production stage copies only `dist/` from the build stage. Gate for Phase 1 overall: each of the three suites runs headless on the `stage` runner with a recorded pass count matching the post-fix local baseline. Paste all three `Tests:`/`passed` lines into the phase issue. --- ## Phase 2 - Add the CI quality job to staging.yaml Owner sign-off required (pipeline change). Add a `test` job to `.gitea/workflows/staging.yaml` that runs, per workspace: lint (`npm run lint` / eslint), type-check (`npm run type-check`), unit tests, plus OCR pytest, using the Phase 1 mechanics. Backend integration tests use the ephemeral DB from 1B. Wiring decision - blocking (recommended) vs report-only: | | Blocking (`deploy-staging: needs: [build, test]`) | Report-only (parallel job, nothing needs it) | |---|---|---| | Broken code reaches staging | No | Yes (staging is the de facto QA env - weakens the whole campaign) | | PR feedback speed | Deploy waits for tests (~minutes) | Unchanged | | Flaky test risk | Blocks deploys until fixed/quarantined | Ignored red jobs rot within weeks | | Recommendation | YES - staging deploy is the thing being protected | Only as a <=2-week transition while stabilizing suites | Canary (the measurable gate - do not skip): on a branch, commit a deliberately failing test, e.g. append to any backend unit file: ```ts it('CI canary - must fail', () => { expect(1).toBe(2); }); ``` Open a draft PR. EXPECTED: the workflow run goes red; with blocking wiring, `deploy-staging` shows as skipped and staging still serves the previous build (confirm via `curl -s https://staging.motovaultpro.com/api/health | jq -r .status` returning `healthy` from the OLD deploy). Then revert the canary commit and observe green. Record both run URLs in the phase issue. IF the failing test does NOT turn the run red: the test step's exit code is being swallowed (look for `|| true`, piping to `tail`, or `set +e`); fix before merging anything else. --- ## Phase 3 - Kill the :latest hazard Owner sign-off required (pipeline change). Options ranked; (c) both is the recommended end state: (a) RECOMMENDED: PR builds tag `:pr-N` + short-sha only; `:latest` moves only on push to main. In `staging.yaml`, split the latest-tagging into conditional steps. Exact condition shape (Gitea Actions): ```yaml - name: Tag and push latest (main only) if: gitea.event_name == 'push' run: | docker tag ${{ steps.tags.outputs.backend_image }} $REGISTRY/egullickson/backend:latest docker push $REGISTRY/egullickson/backend:latest # (same for frontend, ocr) ``` Keep `--cache-from ...:latest` unconditionally (harmless read). If `gitea.event_name` evaluates empty on this act_runner version, use `github.event_name` - same context, aliased. Verify with a probe run before trusting it (print it in a step). Obligation: the registry purge script (`scripts/ci/purge-container-images.sh`) keeps `latest` by digest, but it is currently run MANUALLY - no purge schedule exists anywhere in the repo (the only scheduled workflow is the image MIRROR in `mirror-images.yaml`, and the runner's daily `docker system prune` cron cleans local images, not the registry - see `mvp-run-and-operate`). After this change PR-sha tags accumulate registry-side, so scheduling or periodically running the purge becomes a required follow-up: file it as its own issue in the same phase. (b) SMALL + IMMEDIATE: make `production.yaml` `image_tag` a required input with no default. Change lines 14-17 to `required: true` and delete the `default: 'latest'` line; update `run-name` (line 9). Whether Gitea's dispatch UI actually blocks an empty required input is UNVERIFIED - so ALSO add a shell guard in the `validate` job: ```yaml - name: Refuse latest/empty tag run: | TAG="${{ inputs.image_tag }}" if [ -z "$TAG" ] || [ "$TAG" = "latest" ]; then echo "ERROR: deploy by explicit sha tag, never latest"; exit 1 fi ``` (c) Both (a) and (b). Do (b) first - it is one small PR and removes the worst outcome immediately. Measurable gate: PR syncs no longer move `:latest`. Digest check, modeled on the verified helper in `scripts/ci/purge-container-images.sh:69-101`, run on the runner or anywhere with a registry PAT: ```bash REG=https://git.motovaultpro.com T=$(curl -fsS -u "egullickson:$PAT" "$REG/v2/token?service=container_registry&scope=repository:egullickson/backend:pull" | jq -r '.token // .access_token') curl -fsSI -H "Authorization: Bearer $T" \ -H "Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json" \ "$REG/v2/egullickson/backend/manifests/latest" | tr -d '\r' | grep -i docker-content-digest ``` Record the digest, push a trivial commit to the campaign PR (a PR sync), re-run. EXPECTED: identical digest. Then merge to main and re-run. EXPECTED: digest changes. Both observations go in the PR body. --- ## Phase 4 - Staging deploy policy (owner decision gate) Today every PR sync fully redeploys shared staging (`down --timeout 30` then `up -d`, brief total outage each time; last PR wins). Options, honestly: | Option | Pro | Con | |---|---|---| | 1. Keep PR-deploys (status quo) | Matches single-dev reality: the PR deploy IS the end-to-end verification loop (there is no working local full-stack). Zero work. | Two open PRs fight over staging; staging never reflects main; every sync = outage + 3 image builds on a 29G-disk runner. | | 2. Deploy staging only on push to main; PRs build+test only | Staging always = main; PR churn stops hammering the box. | Removes the ONLY pre-merge end-to-end check. Under current practice this is a big loss - do not pick this until Phase 2 tests are strong. | | 3. Labeled opt-in (`if: contains(gitea.event.pull_request.labels.*.name, 'deploy-staging')`) | Deliberate deploys; test/build still run on every sync. | Label-condition behavior on Gitea act_runner is UNVERIFIED; adds a manual step to the AI-session workflow contract. | Recommendation: stay on option 1 until Phase 2 has been green for 2+ weeks, then present 3 (with a verified label-condition probe) to the owner. Whatever is decided, record it and the reasoning as an issue comment; the gate is the owner's explicit sign-off comment, not this file. --- ## Phase 5 - Security and viewport scanning (CANDIDATES, unproven) Everything here is an open candidate. Do not represent any of it as existing - root `CLAUDE.md`'s claim that PRs get "Mobile/desktop viewport validation" and "Security scanning" is FALSE today (verified: no such steps in any workflow, no cypress config exists, the single `frontend/cypress/e2e/stations.cy.ts` spec is unrunnable). - gitleaks and/or `npm audit --omit=dev` as NON-blocking report steps first; promote to blocking only after 2 weeks of observed signal-to-noise, via a new owner-approved PR. - Viewport smoke: root `package.json` carries an unused `@playwright/test` dependency and no config. First concrete step: a post-`verify-staging` job running a 3-URL Playwright smoke against `https://staging.motovaultpro.com` at 390x844 and 1920x1080, asserting page title and no console errors. That is the whole first milestone - not "mobile+desktop CI validation". Do NOT gate on the Cypress spec. - When (if) either lands and blocks, update root `CLAUDE.md`'s CI/CD Pipeline section to match reality (see `mvp-docs-and-writing`). --- ## Wrong paths - fenced off | Do not | Why (verified) | |---|---| | Point integration tests at the shared dev/staging DB | They DROP TABLE CASCADE in `afterAll`; the runner is the staging host, so "localhost postgres" IS staging's neighbor. Owner non-negotiable. | | Resurrect the old Docker cleanup cron | It destroyed volumes; `ansible/deploy-staging-runner.yml:315-325` exists specifically to REMOVE it. The sanctioned prune cron on the runner is deliberately out-of-repo. | | Use `scripts/rollback.sh` | Legacy: composes without the blue-green file, rebuilds from source, checks container names that do not exist on prod. Rollback = `scripts/ci/switch-traffic.sh` / `auto-rollback.sh` (see `mvp-run-and-operate`). | | "Fix" runner disk by pruning volumes (`docker system prune --volumes`, `make clean`) | `make clean` = `docker compose down -v --rmi all` (Makefile:73-76) - destroys DB volumes wherever run. Prune images only. | | Gate CI on the Cypress spec | No cypress config, no cypress dependency, undefined `cy.login()`, nonexistent `.or()` chainer. It cannot run. | | Invent root npm scripts (`npm test` at repo root) | Root `package.json` has NO scripts and no workspaces (verified). If a root fan-out script is wanted, that is its own deliberate PR, not an assumption. | | Trust `npm run type-check` to mean "tests compile" (backend) | `tsconfig.json:28` excludes `**/*.test.ts`; ts-jest checks them anyway. Verified divergence: type-check green, 7 suites fail to compile (see Phase 0.3 split). | ## Validation and promotion protocol Per phase: 1. One Gitea issue per phase (`type/chore`), titled `chore: deploy-safety phase N - {name}`. Baseline numbers and decisions (with owner sign-off comments where required) live in issue comments. 2. The PR body contains the phase's measured gate evidence verbatim: command + output (grep counts, `Tests:` lines, digests, canary run URLs). A gate that is not pasted into the PR body did not happen. 3. Merge only after the staging pipeline for the PR itself is green and the owner has reviewed. Label flow per `mvp-change-control`. Finished state - a future session re-runs these assertions to confirm the campaign held: ```bash # 1. CI runs tests and lint (expect >=1 hit each): grep -rnE "jest|pytest" .gitea/workflows/staging.yaml | wc -l # EXPECT: >= 1 grep -rnE "run lint|eslint" .gitea/workflows/staging.yaml | wc -l # EXPECT: >= 1 # 2. Tests block the deploy: grep -n "needs:" .gitea/workflows/staging.yaml # EXPECT: deploy-staging needs the test job # 3. :latest is not pushed unconditionally: grep -n "push.*:latest" .gitea/workflows/staging.yaml # EXPECT: only inside an if: event_name guarded step # 4. Prod cannot default to latest: grep -n "default: 'latest'" .gitea/workflows/production.yaml # EXPECT: no output, exit 1 grep -n "required: true" .gitea/workflows/production.yaml # EXPECT: 1 hit under image_tag # 5. Behavior checks (registry + a canary PR, quarterly): # - PR sync leaves the latest digest unchanged (Phase 3 curl check) # - a failing test on a branch turns the PR run red (Phase 2 canary) ``` Plus the standing numbers: last recorded green counts for backend/frontend/OCR suites in the most recent phase issue. If a re-run's counts drop below the recorded ones without a filed quarantine issue, the campaign has regressed - file an issue immediately. ## Provenance and maintenance Authored 2026-07-07 from direct inspection of the repo (all line numbers, counts, and command outputs verified on that date; test counts measured on a dev machine with node v25). Where any doc contradicts these observations, the code/workflows win. Volatile facts and one-line re-verification commands: | Fact (2026-07-07) | Re-verify with | |---|---| | Zero test/lint steps in workflows | `grep -rnE "jest|pytest|eslint|run lint" .gitea/workflows/` (exit 1 = still true) | | staging.yaml pushes 3 `:latest` tags on every run; PR trigger present | `grep -cn ":latest" .gitea/workflows/staging.yaml` (9) and `sed -n '8,13p' .gitea/workflows/staging.yaml` | | production.yaml `image_tag` defaults to `latest` | `grep -n -A4 "image_tag:" .gitea/workflows/production.yaml` | | Backend unit baseline 25 suites (10 pass/15 fail), 149 tests (147 pass/2 fail) locally | `cd backend && npx jest --testPathIgnorePatterns "/node_modules/" "integration" --forceExit 2>&1 \| tail -5` | | Frontend baseline 31 suites (17 pass/14 fail), 213 tests (196 pass/17 fail); reporter workaround works | `cd frontend && npx jest --reporters=default 2>&1 \| tail -5` | | OCR: 310 test functions, pytest in requirements | `grep -rc "def test_" ocr/tests/*.py \| awk -F: '{s+=$2} END {print s}'` and `grep -n pytest ocr/requirements.txt` | | `config/app/ci.yml` fails zod schema (missing `auth0`) | `cd backend && CONFIG_PATH=$PWD/../config/app/ci.yml SECRETS_DIR=/tmp npx jest src/features/stations/tests/unit/stations.service.test.ts --forceExit 2>&1 \| head -5` | | Runner is host mode with node 20 | `grep -n "runner_labels\|node_20" ansible/deploy-staging-runner.yml` | | `frontend/.dockerignore` excludes test files; no root `.dockerignore`; no `ocr/.dockerignore` | `ls .dockerignore ocr/.dockerignore 2>&1; grep -n "test" frontend/.dockerignore` | | Integration tests DROP TABLE CASCADE | `grep -rn "DROP TABLE" backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts` | | Registry digest helper pattern | `sed -n '69,101p' scripts/ci/purge-container-images.sh` | UNVERIFIED items called out inline: OCR pytest inside the image end-to-end (1C), Gitea UI enforcement of required dispatch inputs (3b - shell guard compensates), label-conditioned deploys on act_runner (Phase 4 option 3), causes of the 17 frontend / 2 backend currently-failing tests (dispositioned in Phase 1).