The full workflow
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm tsc --noEmit
- name: Test
run: pnpm test -- --coverage
- name: Build
run: pnpm build
- name: Audit
run: pnpm audit --audit-level=high
Key decisions
Concurrency group: If I push twice to the same branch, the first run is cancelled. No point finishing a stale build.
Frozen lockfile: --frozen-lockfile ensures CI installs exactly what is in the lockfile. If someone forgot to commit pnpm-lock.yaml after adding a dependency, CI fails instead of silently resolving different versions.
Cache: The cache: pnpm option in setup-node caches the pnpm store between runs. First run: 45s install. Subsequent runs: 5s.
Parallelizing steps
For larger projects, split into parallel jobs:
jobs:
lint:
runs-on: ubuntu-latest
steps: [...]
test:
runs-on: ubuntu-latest
steps: [...]
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps: [...]
Lint and test run in parallel. Build only runs if both pass.
Branch protection
The workflow is useless without branch protection rules. Require the quality job to pass before merging. No exceptions. The 30 seconds it adds to every PR saves hours of debugging broken main branches.
What I would add next
- Playwright e2e tests on a separate job (they are slow and flaky enough to warrant isolation)
- Bundle size check that comments on the PR with the diff
- Database migration dry run to catch SQL errors before deploy
