# PR gate: feature → stage (and stage → main promote PRs). # Parallel jobs with `needs` only where a real dependency exists (Postgres for # int/build/e2e). 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. # # Test selection (OPS5): unit/int run `vitest --changed origin/$GITHUB_BASE_REF` # and e2e runs the manifest-selected specs; a diff touching a high-risk path # (schema, lockfile, test harness, seed manifest — see # scripts/lib/test-affected-core.mjs) falls back to the FULL suites. Push to # stage/main always runs everything (ci-stage.yml / ci.yml). name: CI (PR) on: pull_request: branches: [stage, main] concurrency: group: ci-pr-${{ github.event.pull_request.number }} cancel-in-progress: true 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 jobs: lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint format: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm format:check typecheck: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck knip: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm exec knip cycles: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm check:cycles unit: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Compute affected test scope id: scope env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | result=$(node scripts/test-affected.mjs) echo "scope: $(echo "$result" | jq -r .reason)" echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" - name: Unit tests (full suite) if: steps.scope.outputs.mode == 'full' run: pnpm test:unit - name: Unit tests (changed since PR base) if: steps.scope.outputs.mode == 'changed' run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests - name: Unit tests skipped if: steps.scope.outputs.mode == 'none' run: echo "No src/ or tests/ changes in this PR — nothing to run." int: runs-on: ubuntu-latest timeout-minutes: 25 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 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Compute affected test scope id: scope env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | result=$(node scripts/test-affected.mjs) echo "scope: $(echo "$result" | jq -r .reason)" echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" - name: Migrate test database if: steps.scope.outputs.mode != 'none' run: pnpm migrate - name: Seed minimal test database if: steps.scope.outputs.mode != 'none' run: pnpm db:seed:minimal - name: Integration tests (full suite) if: steps.scope.outputs.mode == 'full' run: pnpm test:int - name: Integration tests (changed since PR base) if: steps.scope.outputs.mode == 'changed' run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests - name: Integration tests skipped if: steps.scope.outputs.mode == 'none' run: echo "No src/ or tests/ changes in this PR — nothing to run." build: runs-on: ubuntu-latest timeout-minutes: 25 services: postgres: 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@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Build (against the local service database) run: pnpm build e2e: runs-on: ubuntu-latest timeout-minutes: 30 services: postgres: 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@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Install Playwright chromium run: pnpm exec playwright install --with-deps chromium - name: Compute affected e2e scope id: scope env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | result=$(node scripts/e2e-affected.mjs) echo "scope: $(echo "$result" | jq -r .reason)" echo "mode=$(echo "$result" | jq -r .mode)" >> "$GITHUB_OUTPUT" echo "specs=$(echo "$result" | jq -r '[.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT" - name: Migrate test database if: steps.scope.outputs.mode != 'none' run: pnpm migrate - name: Seed minimal test database if: steps.scope.outputs.mode != 'none' run: pnpm db:seed:minimal # CI e2e runs against the production build (see playwright.config.ts # webServer.command); the dist dir must match the webServer env. - name: Build for e2e if: steps.scope.outputs.mode != 'none' run: pnpm build env: NEXT_DIST_DIR: .next/e2e - name: E2E tests (full suite) if: steps.scope.outputs.mode == 'full' run: pnpm test:e2e - name: E2E tests (manifest-selected specs) if: steps.scope.outputs.mode == 'selected' run: pnpm test:e2e -- ${{ steps.scope.outputs.specs }} - name: E2E tests skipped if: steps.scope.outputs.mode == 'none' run: echo "No e2e-relevant changes in this PR — nothing to run." # Branch protection on `stage`/`main` still requires the legacy context name # `checks` from the pre-parallel monolithic job. This rollup satisfies that # contract without re-serializing the suite. checks: name: checks runs-on: ubuntu-latest needs: - lint - format - typecheck - knip - cycles - unit - int - build - e2e - migration-lock if: always() steps: - name: All parallel checks green env: NEEDS: ${{ toJSON(needs) }} run: | set -euo pipefail echo "$NEEDS" | jq -e ' to_entries | all(.value.result == "success" or .value.result == "skipped") ' 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 permissions: # gh pr list against the default GITHUB_TOKEN. contents: read pull-requests: read steps: # gh expands --json files via git, so it needs the repo checked out. - uses: actions/checkout@v4 - name: Count open PRs touching schema files env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} 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