Add composite action wrapping styfle/cancel-workflow-action@0.12.1 and invoke it with if: failure() on every parallel job in ci-pr.yml and ci.yml. Workflow-level actions: write permission enables cancellation. Document fail-fast behavior in docs/AGENT-OPS.md CI table. Closes #52 Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
381 lines
12 KiB
YAML
381 lines
12 KiB
YAML
# 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 exec 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
|
|
|
|
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
|
|
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")
|
|
'
|