Running the Same Lint Checks in pre-commit and CI
The pre-commit hook passes, the push goes through, and CI fails with web/src/cart.ts:42:7 error 'total' is never reassigned. Use 'const' instead prefer-const — a rule the local ESLint did not report. Or the reverse: CI is green but the hook blocks a commit for a rule CI does not enforce. Both mean the local and CI lint setups are different setups that happen to share a name. This page collapses them into one configuration executed in both places, as part of pre-commit hooks and local quality gates.
The target: CI's lint job is literally pre-commit run --all-files, so the only difference between local and CI runs is which files are checked.
Diagnostic
Compare the versions and configuration each environment uses for the failing tool:
#!/usr/bin/env bash
set -euo pipefail
echo "hook rev: $(yq '.repos[] | select(.hooks[].id == "eslint") | .rev' .pre-commit-config.yaml 2>/dev/null || echo local)"
echo "local bin: $(npx --prefix web eslint --version)"
grep -nE 'eslint|npm run lint|prettier' .github/workflows/*.yml | head -5
grep -nE '"eslint"|"prettier"' web/package.json
echo "editor: $(code --list-extensions --show-versions 2>/dev/null | grep -i eslint || echo n/a)"
Expected bad output:
hook rev: v9.8.0
local bin: v9.10.0
.github/workflows/ci.yml:31: - run: npm run lint --prefix web
web/package.json: "eslint": "^9.8.0",
editor: [email protected]
The hook pins ESLint 9.8.0 through a mirror, CI runs npm run lint with whatever ^9.8.0 resolved to (9.10.0, with a stricter recommended config), and the two share no configuration path.
Root cause
Lint drift has three sources. Version: a hook repository pinned at one rev while CI installs from package.json with a caret range, so the two drift whenever a minor release changes rules or defaults. Configuration: hooks that pass arguments inline (args: [--rule, ...]) while CI reads the project's config file, or mirror hooks that need plugins listed separately in additional_dependencies and fall out of sync with package.json. Scope: hooks check staged files while CI checks everything, which is intended, but when a rule change lands without an all-files cleanup, CI fails on files the current change never touched. Each difference is small; together they make lint results untrustworthy, and developers learn to ignore the hook.
Resolution
- Make the tool's version come from one place. For tools installed by the project (ESLint with plugins), use a local hook that runs the project's installed binary, and pin exact versions in
package.json:
repos:
- repo: local
hooks:
- id: eslint
name: eslint
language: system
entry: npm --prefix web exec -- eslint --max-warnings=0
files: ^web/.*\.(ts|tsx)$
- id: prettier
name: prettier
language: system
entry: npm --prefix web exec -- prettier --check
files: ^web/.*\.(ts|tsx|json|css|md)$
#!/usr/bin/env bash
set -euo pipefail
cd web
npm pkg set devDependencies.eslint=9.10.0 devDependencies.prettier=3.3.3
npm install
git diff package.json
For standalone tools (ruff, shellcheck, gitleaks), keep pinned hook repositories — the hook rev is then the single version.
- Make CI run the hooks, not its own lint commands. Replace the separate lint steps:
name: lint
on: [pull_request]
jobs:
pre-commit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: web/.nvmrc, cache: npm, cache-dependency-path: web/package-lock.json }
- run: npm ci --prefix web
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pipx run pre-commit==3.8.0 run --all-files --show-diff-on-failure
Remove the duplicate lint steps from other workflows, so there is exactly one lint gate.
Point editors at the project's tools. The ESLint and Prettier editor extensions use the project's installed versions by default; ensure no global installs override them, and recommend the extensions in
.vscode/extensions.jsonso the editor shows the same errors the hook reports.Clean up existing violations whenever a rule or version changes, in a dedicated commit, so CI's all-files run does not fail on unrelated files.
Expected output
$ pre-commit run eslint --all-files
eslint...................................................................Failed
- hook id: eslint
web/src/cart.ts
42:7 error 'total' is never reassigned. Use 'const' instead prefer-const
The same error now appears locally, in CI and in the editor, because all three run ESLint 9.10.0 with web/eslint.config.js. After fixing it, all three pass.
The practical test that parity holds is a deliberate violation: add an unused variable on a branch, commit with hooks enabled, and confirm the hook blocks it with the same message CI prints when you push with --no-verify. Doing this once after changing the setup, and again after any tool upgrade, takes two minutes and catches the configuration drift this page exists to remove.
Prevention
Pin exact versions (no
^or~) for lint tools inpackage.jsonand hookrevs, and upgrade them in dedicated pull requests where CI shows the new findings.Fail CI if lint commands appear outside the hook job, with a grep over workflow files for
eslint,prettierorruff, so duplicates do not creep back.Check editor parity in onboarding: open a file with a known violation and confirm the editor shows the same message the hook prints.
Platform caveats
Windows (native):
language: systemhooks runnpmfromPATH; make sure Node from the project's toolchain manager is first onPATH, or the hook uses a different global Node than the terminal.
macOS: GUI Git clients may not load the shell profile, so
npmfrom mise or nvm is not on theirPATH. Configure the client to use the terminal environment or commit from the terminal.
Apple Silicon (ARM64): Node binaries from the toolchain manager are native; no difference in lint results between architectures is expected.
Rollback
Restore the previous hook and workflow files; the separate lint steps return:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .pre-commit-config.yaml .github/workflows/lint.yml
git commit -m "revert: restore separate lint setup"
Frequently Asked Questions
Why does ESLint report different errors locally and in CI?
Usually different versions or configurations: a hook pinned through a mirror at one version while CI installs another from package.json, or hook arguments that differ from the project config. Run the project's installed ESLint from the hook so both use the same binary and config.
Should CI run lint on all files or only changed files?
All files. It catches files committed with --no-verify or through web edits, and changes in rules that affect untouched files. Keep local hooks on staged files for speed.
Is language: system a bad practice?
It depends on what is already installed. For tools whose versions the project manages (ESLint through package.json), it keeps one source of truth. For standalone tools, framework-managed hooks are more portable.
How do we introduce a stricter rule without breaking everyone?
Add the rule and fix all existing violations in the same pull request, or baseline them with the tool's suppression mechanism. Never merge a stricter rule that leaves the repository failing its own all-files check.