Files
motovaultpro/.claude/skills/mvp-deploy-safety-campaign/SKILL.md
T
Eric GullicksonandClaude Fable 5 c239bb9347
Deploy to Staging / Build Images (push) Successful in 5m32s
Deploy to Staging / Deploy to Staging (push) Successful in 44s
Deploy to Staging / Verify Staging (push) Successful in 5s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
chore: replace AI skill library with 16 verified mvp-* skills
Removes the old planner/decision-critic/role-agents/domain-agents system
(step-injector scripts, stale scopes, sprint-era workflow) and replaces it
with 16 ground-truth-verified skills under .claude/skills/: change control,
debugging playbook, failure archaeology, architecture contract, domain
reference, OCR/Gemini pipeline, config and secrets, build and env, run and
operate, diagnostics (with tested scripts), validation and QA, docs and
writing, launch readiness, deploy-safety campaign, proof and analysis
toolkit, and research frontier. RULE 0/1/2, the temporal-contamination rule,
and the decision stress-test protocol are carried forward into the new
skills; the retired content remains in git history.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-09 20:21:42 -05:00

26 KiB

name, description
name description
mvp-deploy-safety-campaign 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

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

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):

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):

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:

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:
// 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:):

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:

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):

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:

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):

      - 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:

      - 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:

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:

# 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
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).