name: CI # What this workflow is for # ------------------------ # Until August 2026 this file had exactly one substantive job (a Prettier # check on changed frontend files). Everything else the repo already knows # how to run — tsc, eslint, jest, vitest — ran only on the maintainer's # machine before a deploy. That is a gate you have to remember to walk # through, which means it is not a gate. # # The jobs below run the suites that already exist in package.json. Nothing # here is new tooling; it is the existing tooling, wired to `push`. # # Honesty rules this file follows # ------------------------------- # A check that cannot fail is worse than no check, because it buys the # feeling of coverage without the coverage. So: # # * No step carries `continue-on-error`. A step either matters or it is # not here. Where a whole job is advisory, the flag sits on the JOB and # the reason is written down next to it — never sprinkled per-step, # which is how a "required" check quietly becomes unfailable. # * Nothing is skipped by a path filter. A job skipped by a path filter # reports as green to branch protection, so a filter is a way of # passing a check without running it. # * Every advisory job writes its real outcome into the job summary, so # "this was allowed to fail" never reads the same as "this passed". # # Which jobs are safe to mark as REQUIRED in branch protection is listed # per job below. Do not promote a job to required without re-measuring it. on: push: # Lowercase: the default branch is `main`. This filter used to read # `Main`, and GitHub branch filters are case-sensitive, so the push # trigger never fired once. branches: [main] # Deliberately NO `paths:` filter. A path-filtered job that does not # run is reported to branch protection as successful, which is the # third way a gate can silently stop being a gate. pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: # --------------------------------------------------------------------- # SAFE TO REQUIRE. Measured green on main @ 80ca3061 (2026-08-30): # `npx tsc --noEmit` clean in both trees, `eslint` clean in both. # --------------------------------------------------------------------- static: name: Typecheck + lint (both trees) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: "22" cache: npm cache-dependency-path: | backend/package-lock.json frontend/package-lock.json # `npm ci` in backend/ triggers its postinstall (`prisma generate`). # tsc needs the generated client types, so this is not optional. - name: Install backend dependencies run: cd backend && npm ci - name: Install frontend dependencies run: cd frontend && npm ci - name: Typecheck backend run: cd backend && npx tsc --noEmit - name: Typecheck frontend run: cd frontend && npx tsc --noEmit - name: Lint backend run: cd backend && npm run lint # eslint here runs with --max-warnings 0 (see frontend/package.json), # so a new warning fails the job rather than accumulating silently. - name: Lint frontend run: cd frontend && npm run lint # --------------------------------------------------------------------- # SAFE TO REQUIRE. Measured green on main @ 80ca3061 (2026-08-30): # 371 files, 3254 tests, all passing, exit 0. # # The suite logs jsdom "HTMLCanvasElement.prototype.getContext is not # implemented" noise from the globe components. That is stderr output, # not a failure — do not "fix" it by making the job tolerate errors. # --------------------------------------------------------------------- frontend-tests: name: Frontend tests (Vitest) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: "22" cache: npm cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: cd frontend && npm ci - name: Vitest run: cd frontend && npx vitest --run # --------------------------------------------------------------------- # ADVISORY — DO NOT MARK REQUIRED YET. # # The reason is the sharpest possible illustration of why "green" and # "checked" are different words. Measured on main @ 80ca3061 (2026-08-30) # against Postgres: # # Test Suites: 2 failed, 354 passed, 356 total # Tests: 3056 passed, 3056 total # # Every single test passes and the run still exits 1. The two red SUITES # contain no failing test: src/routes/__tests__/lodging.test.ts and # src/routes/__tests__/lodgingFxSource.test.ts both fail in `afterAll` # with Postgres 40P01 "deadlock detected". The lodging routes fire # checkAndUpdateAchievements deliberately without `await`, and teardown # deletes the user while that background write is still in flight. Both # fail identically in a control run with none of this workflow's changes, # so it is pre-existing and is not fixed here. # # A required check that is red while 3056 of 3056 tests pass would teach # everyone to click past it within a week. That is exactly the failure # mode this repo should avoid, so the job reports honestly and does not # gate. # # A CLEAN CHECKOUT FAILS MORE THAN THE MAINTAINER'S MACHINE DOES, and # CI is a clean checkout. Same commit, run against a throwaway database # with no developer state and no personal fixtures: # # Test Suites: 3 failed, 1 skipped, 352 passed (355 of 356) # Tests: 2 failed, 10 skipped, 3044 passed (3056 total) # # Only TWO tests actually fail, and both are environmental rather than # defects in the code under review. (The deadlock suites contribute zero # failing tests here — and which of them trips is not deterministic: # lodgingFxSource.test.ts failed in this run while lodging.test.ts # passed, the reverse of the maintainer's run. A race, not a fixed list.) # The two real failures: # # * src/routes/__tests__/emailParse.referenceDate.test.ts asserts # fs.existsSync() on # "test-samples/Flug-emails/Buchungsdetails _ 23 November 2023_.msg". # Only 7 files under test-samples/ are tracked and they are all # .gitkeep/README — the sample is a real booking email that is not in # the public repo. This test CANNOT pass on any fresh clone. It needs # to skip when its fixture is absent before this job can ever be # green. # * src/__tests__/lodgingImportCommit.test.ts expects a non-null # chainId, but scripts/seed-test-catalogues.ts never seeds the # lodging-chain catalogue (it covers airlines, aircraft, ships, # ports, airports and achievements). The seed step below deliberately # calls the project's own script rather than a CI-only variant, so # that CI and a local run stay the same thing; closing this gap # belongs in that script. # # Promote this job to required only when all three are addressed — the # teardown deadlock, the missing-fixture guard, and the chain seed — and # only after re-measuring. Then delete `continue-on-error` below in one # deliberate move. # # On the environment: main now carries jest.globalSetup.ts (fails fast # with one clear sentence when Postgres is unreachable, instead of a # four-figure failure count) and jest.setup.ts + workerIdleMemoryLimit # (which recycle the single worker and cap the Prisma pool). So this job # does NOT need to raise the heap or set a pool size itself; an earlier # revision did both and they are now redundant. connection_limit is left # on the URL below only because jest.setup.ts explicitly honours an # already-chosen limit, which makes the intent visible at the call site. # --------------------------------------------------------------------- backend-tests: name: Backend tests (Jest, advisory) runs-on: ubuntu-latest continue-on-error: true services: postgres: # Values are CI-local throwaways and intentionally unrelated to any # developer or deployment credentials. image: postgres:16-alpine env: POSTGRES_USER: travstats_ci POSTGRES_PASSWORD: travstats_ci POSTGRES_DB: travstats_ci ports: - 5432:5432 options: >- --health-cmd "pg_isready -U travstats_ci" --health-interval 10s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgresql://travstats_ci:travstats_ci@localhost:5432/travstats_ci?connection_limit=5 NODE_ENV: test # Not decoration, and not free-form. Several suites import src/index.ts, # which calls validateEnv() and answers a bad value with process.exit(1) # — that kills the whole jest run after a single file, which reads like # "the suite is broken" rather than "an env var is malformed". The # schema in src/config/env.ts demands JWT_SECRET >= 32 chars and # ENCRYPTION_KEY of EXACTLY 64 hex characters. Both values below are # throwaway CI literals with no relationship to any real key. JWT_SECRET: ci-only-jwt-secret-not-used-anywhere-else-0000 ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: "22" cache: npm cache-dependency-path: backend/package-lock.json - name: Install dependencies run: cd backend && npm ci - name: Apply migrations run: cd backend && npx prisma migrate deploy # Not optional. `init` does not seed catalogues, and a large part of # the suite reaches the airport/aircraft catalogue on import — even # files that look like pure unit tests do, via the catalogue caches. # Without this the failures look like logic bugs. - name: Seed test catalogues run: cd backend && npx tsx scripts/seed-test-catalogues.ts - name: Jest id: jest run: cd backend && npx jest --ci --forceExit # Without this, `continue-on-error` above would render a failing run # as a green tick and the whole job would become decoration. - name: Report outcome if: always() run: | if [ "${{ steps.jest.outcome }}" = "success" ]; then echo "Backend suite passed." >> "$GITHUB_STEP_SUMMARY" else echo "### Backend suite FAILED (advisory job)" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "This job does not block merges yet. Check whether this is the known" >> "$GITHUB_STEP_SUMMARY" echo "\`lodging.test.ts\` 40P01 deadlock or a genuine regression you introduced." >> "$GITHUB_STEP_SUMMARY" fi # --------------------------------------------------------------------- # SAFE TO REQUIRE — but note it only checks files the change touched. # # Scoped on purpose: ~95 files under frontend/src have never been # Prettier-formatted (mostly since the Tailwind 4 migration), so a # repo-wide check would fail on every PR regardless of its content, and # a badge that is always red carries no information. Scoping keeps the # signal about the change under review. A mass `prettier --write` is the # alternative whenever the maintainer wants to take that diff. # --------------------------------------------------------------------- format: name: Frontend format check runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: # Need history on both sides of the change to diff against. fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: "22" cache: npm cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: cd frontend && npm ci - name: Collect changed frontend files id: changed env: BASE_REF: ${{ github.base_ref }} BEFORE_SHA: ${{ github.event.before }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail if [ "$EVENT_NAME" = "pull_request" ]; then git fetch --no-tags origin "$BASE_REF" range="origin/$BASE_REF...HEAD" elif [ "$EVENT_NAME" = "push" ] && \ [ -n "${BEFORE_SHA:-}" ] && \ [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then range="$BEFORE_SHA..HEAD" else # workflow_dispatch, or a first push with no "before" commit: # nothing meaningful to diff against, so check the last commit. range="HEAD~1..HEAD" fi echo "Diffing $range" # ACMR: skip deletions — prettier cannot check a file that is gone. # # Single `*`, not `**`: in a git pathspec (no :(glob) magic) `*` # already crosses directory separators, while `src/**/*.tsx` # requires at least one intermediate directory and therefore # silently misses frontend/src/App.tsx and its siblings. Measured, # not assumed — that miss is exactly the kind of quiet hole this # job exists to close. git diff --name-only --diff-filter=ACMR "$range" -- \ 'frontend/src/*.ts' \ 'frontend/src/*.tsx' \ 'frontend/src/*.css' > changed.txt || true # Guard against a file deleted later in the range. : > files.txt while IFS= read -r f; do [ -f "$f" ] && printf '%s\n' "${f#frontend/}" >> files.txt done < changed.txt count=$(wc -l < files.txt | tr -d ' ') echo "count=$count" >> "$GITHUB_OUTPUT" echo "Changed frontend files to check: $count" cat files.txt - name: Prettier check (changed files only) if: steps.changed.outputs.count != '0' run: cd frontend && xargs -a ../files.txt npx prettier --check - name: Nothing to check if: steps.changed.outputs.count == '0' run: echo "No frontend source files changed — skipping Prettier."