teqo-backup/.github/workflows/ci-pr.yml
Francisco Solla 493f4ba997 feat(OPS99): generate importMap at build time with dummy S3_* envs
Generate the Payload importMap at build time (Dockerfile) and dev time
(predev hook) with dummy S3_* envs, eliminating the class of bugs where
the admin goes blank because the S3ClientUploadHandler entry is missing
(OPS69/OPS72/OPS73).

Changes:
- Dockerfile: add pnpm generate:importmap with dummy S3_* envs before next build
- scripts/predev-importmap.mjs: predev hook that generates importMap if missing or stale
- Delete scripts/check-importmap-s3.mjs (guard no longer needed)
- Delete tests/unit/importMapS3UploadHandler.unit.spec.ts (unit pin no longer needed)
- Remove check-importmap-s3.mjs step from CI workflows
- Add importMap.js to .gitignore (build artifact, not committed)
- Update AGENTS.md with new contract

Closes #928
2026-08-27 16:05:36 -03:00

236 lines
11 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <workflow name> / <job name> — 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 }}