Local CI Parity
How pnpm ci:check and pre-commit hooks keep the local development loop in sync with CI, and where gaps remain.
A recurring source of wasted time in early Phase 0 was the "works locally, fails in CI" pattern. The root cause: the local pre-commit workflow only ran lint, typecheck, and test -- missing 5 of the 10 CI checks. The pnpm ci:check and pnpm ci:full scripts were created to close this gap.
Three Layers of Local Checks
| Layer | When It Runs | What It Checks | Checks Covered |
|---|---|---|---|
| Pre-commit hook | Automatically on git commit | Formatting + auto-fixable lint | 2 of 10 |
pnpm ci:check | Manually before committing | All quality checks except build | 8 of 10 |
pnpm ci:full | Manually before pushing | All quality checks including build | 9 of 10 |
The 10th check (database migration against fresh Postgres) only runs in CI because it requires service containers.
Pre-Commit Hook (Husky + lint-staged)
The pre-commit hook runs automatically on every git commit. It targets only staged files, not the full codebase.
What it runs
| File Pattern | Tools | Purpose |
|---|---|---|
*.ts, *.tsx | ESLint --fix + Prettier --write | Auto-fix imports, format code |
*.js, *.jsx, *.mjs, *.cjs, *.json, *.md, *.mdx, *.yml, *.yaml, *.css | Prettier --write | Format non-TypeScript files |
Monorepo ESLint routing
ESLint in a Turborepo monorepo has a configuration discovery problem. ESLint's flat config system searches for eslint.config.js starting from the current working directory, not from the file being linted. Running ESLint from the monorepo root on a file in packages/db/src/ would find the root config (if one existed) instead of packages/db/eslint.config.js.
The .lintstagedrc.mjs configuration solves this by grouping staged files by their nearest package directory (apps/* or packages/*). The scripts/lint-staged-eslint.mjs helper then runs ESLint from each package's directory so the correct eslint.config.js is found.
What it does NOT run
The pre-commit hook does not run:
- Typecheck (
tsc) -- takes 10-15 seconds across the monorepo, too slow for commit-time - Tests (Vitest) -- takes 15-30 seconds, too slow for commit-time
- Format check (Prettier in check mode) -- the hook already writes formatted output
- Dependency graph validation (
dep-cruise) - Dead code detection (Knip)
- Duplication detection (jscpd)
- Database migrations
- Build
These checks are covered by pnpm ci:check (manual) and the CI pipeline (automated).
pnpm ci:check
pnpm ci:check
This command mirrors the CI quality checks locally. It runs 8 of the 10 checks in the same order as CI:
| # | Check | CI Step |
|---|---|---|
| 1 | pnpm format:check | Format |
| 2 | pnpm lint | Lint (full monorepo, not --affected) |
| 3 | pnpm dep-cruise | Dependency graph |
| 4 | pnpm lint:dead-code | Dead code |
| 5 | pnpm lint:duplication | Duplication |
| 6 | pnpm typecheck | Typecheck (full monorepo, not --affected) |
| 7 | pnpm test | Test (full monorepo, not --affected) |
| 8 | pnpm docs:update-detection | Doc update detection |
The commands are chained with &&, so the script stops at the first failure. Format and lint errors are caught first (fastest feedback).
Differences from CI
| Aspect | ci:check | CI quality job |
|---|---|---|
| Affected filtering | No (pnpm lint, not turbo lint --affected) | Yes (turbo lint --affected) |
| Database migration | No (requires service containers) | Yes (pnpm db:migrate) |
| Build | No (slow, rarely fails after typecheck) | Yes (turbo build --affected) |
| Doc update detection | Yes (final step) | Separate docs job |
| Service containers | No | Postgres, Redis, Typesense |
Running the full monorepo (no --affected) locally is intentional -- it catches cross-package issues that --affected might miss when the change graph is complex.
pnpm ci:full
pnpm ci:full
Runs ci:check followed by pnpm build. This catches build-time errors that typecheck misses:
- Next.js configuration conflicts (middleware + route handler on same path)
- SSR-incompatible imports (browser-only APIs at module scope)
NEXT_PUBLIC_*environment variable issues- Sentry integration errors
Run ci:full before pushing a branch, especially after:
- Adding new routes or middleware
- Changing Next.js configuration
- Adding new
NEXT_PUBLIC_*environment variables - Modifying the Docker build or Turborepo pipeline
Discovery History
The local/CI parity gap was discovered when Knip (dead code detection) violations consistently passed locally but failed in CI. The founder asked: "Why are knip issues consistently not being caught in our local testing but are being caught in our CI pushes?"
Root cause: the local workflow was lint + typecheck + test. CI ran format + lint + dep-cruise + dead-code + duplication + typecheck + test + build. Five checks existed only in CI with no local equivalent.
The fix was pnpm ci:check, and CLAUDE.md was updated to require all AI agents to run it before every commit. See ADR-012: CI/CD Pipeline for the full decision context.
Recommended Workflow
- Write code
- Run
pnpm ci:check(catches 8 of 10 checks) - Fix any failures
git addspecific files (notgit add .)git commit(pre-commit hook auto-fixes formatting)- Run
pnpm ci:fullbefore pushing (adds build check) - Push -- CI runs all 10 checks including database migration
If CI fails on migration or build after local checks pass, the issue is almost always:
- Migration failure: SQL syntax error in a new migration file (not caught by typecheck)
- Build failure: SSR-incompatible code or environment variable mismatch
Concurrency & Caching
How the pipeline avoids redundant work -- cancel-in-progress, Turborepo affected filtering, BuildKit cache layers, and pnpm cache.
ADR-012: CI/CD Pipeline -- Build, Test, Deploy
Decision record for the CI/CD pipeline structure, parallel jobs, local CI parity, and dependency automation.