# PR gate: feature → main. Two-phase cascade: # Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit # Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e # Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A # phase-1 failure skips phase 2 via `needs` + explicit if. `migration-lock` # stays parallel to phase 1 (cheap, independent). # # High-risk diffs (schema, lockfile, classifiers — see # scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main # always runs the full suite in ci.yml (no affected skips). name: CI (PR) on: pull_request: branches: [main] concurrency: group: ci-pr-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read actions: write pull-requests: read 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: scope: runs-on: ubuntu-latest timeout-minutes: 5 outputs: code_mode: ${{ steps.classify.outputs.code_mode }} build_mode: ${{ steps.classify.outputs.build_mode }} test_mode: ${{ steps.classify.outputs.test_mode }} e2e_mode: ${{ steps.classify.outputs.e2e_mode }} e2e_specs: ${{ steps.classify.outputs.e2e_specs }} 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: classify env: GITHUB_BASE_REF: ${{ github.base_ref }} run: | 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" - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Check test file locations run: node scripts/check-test-locations.mjs - run: pnpm lint - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure format: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm format:check - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure typecheck: runs-on: ubuntu-latest timeout-minutes: 10 needs: [scope] if: needs.scope.outputs.code_mode != 'none' steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure knip: runs-on: ubuntu-latest timeout-minutes: 10 needs: [scope] if: needs.scope.outputs.code_mode != 'none' steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm knip - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure cycles: runs-on: ubuntu-latest timeout-minutes: 10 needs: [scope] if: needs.scope.outputs.code_mode != 'none' steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm check:cycles - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure unit: runs-on: ubuntu-latest timeout-minutes: 15 needs: [scope] if: needs.scope.outputs.test_mode != 'none' 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: Unit tests (full suite) if: needs.scope.outputs.test_mode == 'full' run: pnpm test:unit - name: Unit tests (changed since PR base) if: needs.scope.outputs.test_mode == 'changed' run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure int: runs-on: ubuntu-latest timeout-minutes: 25 needs: [scope, lint, format, typecheck, knip, cycles, unit] if: | always() && !cancelled() && needs.scope.outputs.test_mode != 'none' && needs.lint.result == 'success' && needs.format.result == 'success' && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') && (needs.knip.result == 'success' || needs.knip.result == 'skipped') && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') && (needs.unit.result == 'success' || needs.unit.result == 'skipped') 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@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 - run: pnpm migrate - run: pnpm db:seed:minimal - name: Integration tests (full suite) if: needs.scope.outputs.test_mode == 'full' run: pnpm test:int - name: Integration tests (changed since PR base) if: needs.scope.outputs.test_mode == 'changed' run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure build: runs-on: ubuntu-latest timeout-minutes: 25 needs: [scope, lint, format, typecheck, knip, cycles, unit] if: | always() && !cancelled() && needs.scope.outputs.build_mode != 'none' && needs.lint.result == 'success' && needs.format.result == 'success' && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') && (needs.knip.result == 'success' || needs.knip.result == 'skipped') && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') && (needs.unit.result == 'success' || needs.unit.result == 'skipped') 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@v5 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - name: Build (against the local service database) run: pnpm build - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure e2e: runs-on: ubuntu-latest timeout-minutes: 30 needs: [scope, lint, format, typecheck, knip, cycles, unit] if: | always() && !cancelled() && needs.scope.outputs.e2e_mode != 'none' && needs.lint.result == 'success' && needs.format.result == 'success' && (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped') && (needs.knip.result == 'success' || needs.knip.result == 'skipped') && (needs.cycles.result == 'success' || needs.cycles.result == 'skipped') && (needs.unit.result == 'success' || needs.unit.result == 'skipped') 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@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: Install Playwright chromium run: pnpm exec playwright install --with-deps chromium - run: pnpm migrate - run: pnpm db:seed:minimal - name: Build for e2e run: pnpm build env: NEXT_DIST_DIR: .next/e2e - name: E2E tests (full suite) if: needs.scope.outputs.e2e_mode == 'full' run: pnpm test:e2e - name: E2E tests (manifest-selected specs) if: needs.scope.outputs.e2e_mode == 'selected' run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }} - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure plans-only-closes: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - 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: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure migration-lock: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v5 - 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 - name: Cancel workflow on failure if: failure() uses: ./.github/actions/cancel-workflow-on-failure checks: name: checks runs-on: ubuntu-latest needs: - scope - lint - format - typecheck - knip - cycles - unit - int - build - e2e - migration-lock - plans-only-closes if: always() steps: - name: All cascade checks green env: NEEDS: ${{ toJSON(needs) }} run: | set -euo pipefail echo "$NEEDS" | jq -e ' to_entries | all(.value.result == "success" or .value.result == "skipped") '