Trovella Wiki

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

LayerWhen It RunsWhat It ChecksChecks Covered
Pre-commit hookAutomatically on git commitFormatting + auto-fixable lint2 of 10
pnpm ci:checkManually before committingAll quality checks except build8 of 10
pnpm ci:fullManually before pushingAll quality checks including build9 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 PatternToolsPurpose
*.ts, *.tsxESLint --fix + Prettier --writeAuto-fix imports, format code
*.js, *.jsx, *.mjs, *.cjs, *.json, *.md, *.mdx, *.yml, *.yaml, *.cssPrettier --writeFormat 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:

#CheckCI Step
1pnpm format:checkFormat
2pnpm lintLint (full monorepo, not --affected)
3pnpm dep-cruiseDependency graph
4pnpm lint:dead-codeDead code
5pnpm lint:duplicationDuplication
6pnpm typecheckTypecheck (full monorepo, not --affected)
7pnpm testTest (full monorepo, not --affected)
8pnpm docs:update-detectionDoc 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

Aspectci:checkCI quality job
Affected filteringNo (pnpm lint, not turbo lint --affected)Yes (turbo lint --affected)
Database migrationNo (requires service containers)Yes (pnpm db:migrate)
BuildNo (slow, rarely fails after typecheck)Yes (turbo build --affected)
Doc update detectionYes (final step)Separate docs job
Service containersNoPostgres, 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.

  1. Write code
  2. Run pnpm ci:check (catches 8 of 10 checks)
  3. Fix any failures
  4. git add specific files (not git add .)
  5. git commit (pre-commit hook auto-fixes formatting)
  6. Run pnpm ci:full before pushing (adds build check)
  7. 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

On this page