# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job # (OPS62, X1): one container, one checkout, one pnpm install, one runner slot. # Fail-fast is STRUCTURAL — a failed step skips every step after it, the job # fails and the run fails. GitHub-native `concurrency` cancels the in-flight # run when the PR gets a new push (the Forgejo era had no cancel API; here it # is free and matches the OPS62 "one slot per PR" spirit). # # The job id `checks` preserves the `CI (PR) / checks` check-run that the # branch protection rule and the native auto-merge rely on (OPS71): GitHub # names it / — keep both literals frozen. GitHub's # auto-merge is server-side: it only merges with every required check green, # so the OPS64 "rollup can lie" defense is structural here (single job posts # one honest check-run; no matrix, no shards). # # Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run # as a step; its outputs drive `if:` on the heavy steps (code/build/test: # none/changed/full; e2e: none/selected/curated/unmapped-risk). e2e policy # (OPS72 + OPS86): the PR runs ONLY the blast radius — the specs the # classifier detected as touched (mode `selected`) — never `full`. A # high-risk diff (schema/lockfile/test harness) classifies as `curated`: the # PR runs the curated e2e cross-section (never zero), the local agent mirror # (`pnpm test:e2e:affected`) runs full in that same case, and the deploy # `verify` job runs the full suite before publishing. Risk-area files # (`src/utilities/access`, `src/lib/schemas`, push, AI) without a manifest # entry classify `unmapped-risk` and the job FAILS closed listing them — # touched code never passes CI on zero e2e. The unit/int `changed` steps go # through `vitest-changed-or-full.mjs`: a zero selection falls back to the # full suite instead of green with no tests (OPS86). # # Schema migrations are NOT serialized across PRs (migration-lock removed # 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every # PR, incl. campaignMigrationReconciliation. # # Fork-PRs are excluded at job level (`if:` same-repo): the repo is public and # a fork PR would burn hosted minutes running arbitrary code. Same guard class # as the RCE guard on the post-merge workflows. # # Postgres service (OPS71 — GitHub hosted): jobs run directly on the runner # VM (NOT inside the job-container network of the Forgejo era), so services # are reached at localhost on PUBLISHED host ports. Each hosted run is its own # VM, so fixed ports never collide across runs. ONE service (OPS88): a # deterministic reset (`pnpm db:reset` — drop schema → migrate → seed # minimal) between the integration phase and the build/e2e phase keeps e2e # from inheriting int fixture residue, instead of a second container. name: CI (PR) on: pull_request: branches: [main] permissions: contents: read concurrency: group: ci-pr-${{ github.head_ref }} cancel-in-progress: true env: PAYLOAD_SECRET: test-only-secret-not-used-in-production NEXT_PUBLIC_SITE_URL: http://localhost:3000 jobs: checks: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 50 services: postgres-int: image: postgres:17-alpine env: POSTGRES_USER: teqo POSTGRES_PASSWORD: teqo POSTGRES_DB: teqo_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U teqo" --health-interval 5s --health-timeout 5s --health-retries 12 steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Classify PR blast radius id: scope env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | set -euo pipefail result=$(node scripts/ci-scope.mjs) echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')" echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT" echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT" echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT" echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT" echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT" echo "e2e_unmapped=$(echo "$result" | jq -r '[.e2e.unmapped[]] | join(" ")')" >> "$GITHUB_OUTPUT" # OPS86 fail-closed: risk-area files without a manifest entry must # never pass green with zero e2e. Fail fast, before the expensive steps. - name: Fail on unmapped risk paths if: steps.scope.outputs.e2e_mode == 'unmapped-risk' run: | echo "::error::Risk-area files touched by this PR have no e2e manifest mapping (fail-closed). Unmapped files:" for file in ${{ steps.scope.outputs.e2e_unmapped }}; do echo "::error::- $file" done echo "::error::Add entries in scripts/lib/e2e-affected-manifest.mjs (E2E_AFFECTED_MANIFEST) covering the risk-area paths." exit 1 # Content guards are PR-scoped and cheap — run them before the suite so # a guard failure fails fast instead of after 20 minutes of tests. - name: Changelog is append-only env: GITHUB_BASE_REF: ${{ github.base_ref }} PR_BODY: ${{ github.event.pull_request.body }} run: node scripts/check-changelog-append-only.mjs - name: No conflict markers in markdown diffs env: GITHUB_BASE_REF: ${{ github.base_ref }} run: node scripts/check-docs-conflict-markers.mjs - name: Block Closes/Fixes/Resolves on plans-only PRs env: GITHUB_BASE_REF: ${{ github.base_ref }} PR_BODY: ${{ github.event.pull_request.body }} run: node scripts/check-plans-only-pr-closes.mjs - name: Check test file locations run: node scripts/check-test-locations.mjs - name: Lint run: pnpm lint - name: Format check run: pnpm format:check - name: Typecheck if: steps.scope.outputs.code_mode != 'none' run: pnpm typecheck - name: Knip if: steps.scope.outputs.code_mode != 'none' run: pnpm knip - name: Check cycles if: steps.scope.outputs.code_mode != 'none' run: pnpm check:cycles - name: Unit tests (full suite) if: steps.scope.outputs.test_mode == 'full' run: pnpm test:unit - name: Unit tests (changed since PR base, full on empty selection) if: steps.scope.outputs.test_mode == 'changed' run: node scripts/vitest-changed-or-full.mjs --suite unit --base origin/${{ github.base_ref }} - name: Prepare database URL run: | set -euo pipefail echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV" # vitest re-loads .env.test over the process env, so the service URL # must win through the .env.test.local layer (same mechanism # `pnpm worktree next` uses). echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local - name: Migrate run: pnpm migrate - name: Seed minimal run: pnpm db:seed:minimal - name: Integration tests (full suite) if: steps.scope.outputs.test_mode == 'full' env: # vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) → # overflows the service Postgres' 100 clients ("too many clients # already"). 4 forks stay well under. The unit step above stays # CPU-relative (no DB). VITEST_MAX_WORKERS: 4 run: pnpm test:int - name: Integration tests (changed since PR base, full on empty selection) if: steps.scope.outputs.test_mode == 'changed' env: VITEST_MAX_WORKERS: 4 run: node scripts/vitest-changed-or-full.mjs --suite int --base origin/${{ github.base_ref }} - name: Reset database (drop → migrate → seed) # OPS88: one Postgres per run — the build/e2e phase starts from the # same clean baseline (drop schema → migrate → seed:minimal) instead # of a second container. Runs whenever a build phase follows # (selected/curated e2e always builds `.next-e2e` — OPS86). if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' run: pnpm db:reset - name: Build (against the local service database) # Only when it is the single build of the run: selected/curated e2e # runs serve the `.next-e2e` build (below), so the default build is # skipped there (OPS88 — one build per run when build+e2e run together). if: steps.scope.outputs.build_mode != 'none' && steps.scope.outputs.e2e_mode != 'selected' && steps.scope.outputs.e2e_mode != 'curated' run: pnpm build - name: Cache Playwright browsers if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | playwright-${{ runner.os }}- - name: Install Playwright chromium if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' run: pnpm exec playwright install --with-deps chromium - name: Build for e2e # Selected and curated modes need the build too (the Playwright # webServer serves the `.next-e2e` production build). # NEXT_PUBLIC_SITE_URL is inlined at build time, so the canonical # `.test` origin must reach THIS build — it lets the agenda feed # dialog generate a link under the production build when AgendaFeed # is in the blast radius (OPS83). if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' run: pnpm build env: NEXT_DIST_DIR: .next-e2e NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test - name: E2E tests (blast radius — manifest-selected or curated specs) if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' # OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs # the curated cross-section (`curated`, never zero) and the full suite # stays in the deploy verify; `unmapped-risk` failed the job earlier. # Explicit CI workers; the local default stays 2 (playwright.config.ts). # 4 workers in one process replace the former 2 shards × 2 workers. env: PLAYWRIGHT_WORKERS: 4 NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test # The `--` separates pnpm from the scope paths: pnpm consumes it and # playwright receives the bare spec paths (no flag parsing drift). run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }}