From 777fde2eed48459d5f2347048bb4e3b2a0095cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 04:44:37 -0300 Subject: [PATCH] ci: PR gate with minimal DB + build + migration-lock; stage gate without build --- .github/workflows/ci-pr.yml | 102 ++++++++++++++++++++++++++++ .github/workflows/ci-stage.yml | 60 ++++++++++++++++ .github/workflows/ci.yml | 5 +- tests/helpers/assertTestDatabase.ts | 13 ++++ 4 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci-pr.yml create mode 100644 .github/workflows/ci-stage.yml diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 00000000..98c730f0 --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,102 @@ +# PR gate: feature → stage (and stage → main promote PRs). +# Full gate against the MINIMAL synthetic database: service Postgres → migrate → +# db:seed:minimal → test:int → build. Build runs here (local service DB only) and +# NEVER in ci-stage.yml — `pnpm build` applies migrations to whatever DATABASE_URL +# it sees, so it is forbidden anywhere near stage/prod URLs. +name: CI (PR) + +on: + pull_request: + branches: [stage, main] + +concurrency: + group: ci-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + # Matches production Neon major (17) and docker-compose.yml. + 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 + env: + # Tests load .env.test themselves (database teqo_test on localhost:5432). + # These are only for the migrate/seed/build steps, which read the process env. + DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test + PAYLOAD_SECRET: test-only-secret-not-used-in-production + NEXT_PUBLIC_SITE_URL: http://localhost:3000 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint (zero warnings) + run: pnpm lint + + - name: Format check (prettier) + run: pnpm format:check + + - name: Typecheck + run: pnpm typecheck + + - name: Dead code (knip) + run: pnpm exec knip + + - name: Circular dependencies (madge) + run: pnpm check:cycles + + - name: Unit tests + run: pnpm test:unit + + - name: Migrate test database + run: pnpm migrate + + - name: Seed minimal test database + run: pnpm db:seed:minimal + + - name: Integration tests + run: pnpm test:int + + - name: Build (against the local service database) + run: pnpm build + + migration-lock: + # At most ONE open PR may touch schema (migrations or generated types). + # Two in flight guarantees a broken rebase and a poisoned drizzle snapshot + # chain; the second PR must wait or rebase. + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Count open PRs touching schema files + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + count=$(gh pr list --state open --limit 100 --json number,files \ + --jq '[.[] | select(.files != null and ([.files[].path | test("^(src/migrations/|src/payload-types.ts$|payload-types.ts$)")] | any))] | length') + echo "Open schema-touching PRs: $count" + if [ "$count" -gt 1 ]; then + echo "::error::More than one open PR touches src/migrations/ or payload-types.ts. Merge or close the other first." + exit 1 + fi diff --git a/.github/workflows/ci-stage.yml b/.github/workflows/ci-stage.yml new file mode 100644 index 00000000..b1747084 --- /dev/null +++ b/.github/workflows/ci-stage.yml @@ -0,0 +1,60 @@ +# Stage gate: runs AFTER a merge lands on `stage`, against the stage database +# (Neon branch cloned from prod, secret STAGE_DATABASE_URL in the `stage` +# GitHub Environment). +# +# HARD RULE: never `pnpm build` here — the build step runs `payload migrate` +# against whatever DATABASE_URL it sees, and this workflow holds a stage URL. +# No db:seed:minimal either: stage data is the prod snapshot, not the synthetic +# minimal set. +name: CI (stage) + +on: + push: + branches: [stage] + +concurrency: + group: ci-stage + cancel-in-progress: false + +jobs: + stage-checks: + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: stage + env: + DATABASE_URL: ${{ secrets.STAGE_DATABASE_URL }} + PAYLOAD_SECRET: ${{ secrets.STAGE_PAYLOAD_SECRET }} + NEXT_PUBLIC_SITE_URL: https://teqo-stage.vercel.app + # The seed/migrate guards refuse non-local hosts without this flag. Stage + # is the ONE documented remote target, and only this workflow may use it. + ALLOW_REMOTE_DB: "true" + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Apply pending migrations to the stage database + run: pnpm migrate + + - name: Point the test env at the stage snapshot + run: | + { + echo "DATABASE_URL=${DATABASE_URL}" + echo "PAYLOAD_SECRET=${PAYLOAD_SECRET}" + } > .env.test + + - name: Integration tests against the stage snapshot + run: pnpm test:int + env: + # Documented escape hatch in tests/helpers/assertTestDatabase.ts — + # only this workflow may set it (vitest.setup.ts loads .env.test + # with override: true, written by the step above). + ALLOW_STAGE_TEST_DB: "true" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9bf8c86..f99cc0f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,12 @@ # Continuous integration on GitHub Actions (GitHub is the only home). -# Runs the same gate as .husky/pre-push plus tests and dead-code analysis. +# Post-merge gate on `main` (promote from stage already passed ci-pr on the PR). +# PRs run in ci-pr.yml (with build + migration-lock); merges to `stage` run in +# ci-stage.yml (stage Neon snapshot, never a build). name: CI on: push: branches: [main] - pull_request: concurrency: group: ci-${{ github.ref }} diff --git a/tests/helpers/assertTestDatabase.ts b/tests/helpers/assertTestDatabase.ts index 434150c0..79eaeccd 100644 --- a/tests/helpers/assertTestDatabase.ts +++ b/tests/helpers/assertTestDatabase.ts @@ -23,6 +23,19 @@ export function assertTestDatabase(databaseUrl: string | undefined): void { const databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, '')) const localHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + + // Documented stage escape hatch: ONLY the ci-stage workflow (GitHub + // Environment `stage`, STAGE_DATABASE_URL secret) may point the int suite at + // the stage Neon snapshot, and it sets this flag explicitly. Agents and local + // runs never set it — the default stays the exact local teqo_test. + if (process.env.ALLOW_STAGE_TEST_DB === 'true') { + if (parsedUrl.protocol === 'postgresql:' && databaseName.length > 0) return + throw new Error( + 'ALLOW_STAGE_TEST_DB is set but DATABASE_URL is not a postgresql: connection string ' + + `with a database name (got "${parsedUrl.protocol || '(unknown)'}").`, + ) + } + if ( parsedUrl.protocol !== 'postgresql:' || databaseName !== 'teqo_test' ||