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.

Where the Lint Result Came From Table comparing ESLint version, configuration source and files checked in three environments. Where the Lint Result Came From Environment Version Config Files pre-commit 9.8.0 mirror hook args staged CI job 9.10.0 npm package.json all editor project 9.10.0 package.json open file
Three environments, two versions and two configurations explain the disagreement.

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

  1. 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.

  1. 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
  1. Remove the duplicate lint steps from other workflows, so there is exactly one lint gate.

  2. 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.json so the editor shows the same errors the hook reports.

  3. 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.

One Source for Each Lint Tool Flow from pinned versions in package.json through the hook configuration to local, CI and editor runs. One Source for Each Lint Tool package.json exact versions local hook npm exec eslint CI pre-commit --all-files editor project binary
Every environment resolves the same binary and the same config file.

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

  1. Pin exact versions (no ^ or ~) for lint tools in package.json and hook revs, and upgrade them in dedicated pull requests where CI shows the new findings.

  2. Fail CI if lint commands appear outside the hook job, with a grep over workflow files for eslint, prettier or ruff, so duplicates do not creep back.

  3. Check editor parity in onboarding: open a file with a known violation and confirm the editor shows the same message the hook prints.

Mirror Hook vs Local Hook for ESLint Comparison of running ESLint through a mirror hook repository versus a local hook using project dependencies. Mirror Hook vs Local Hook for ESLint mirror hook local hook version from rev version from package.json plugins listed twice plugins from project isolated environment needs npm ci first drifts from CI same as CI and editor
When a tool has plugins managed by the project, the local hook keeps one version and one config.

Platform caveats

Windows (native): language: system hooks run npm from PATH; make sure Node from the project's toolchain manager is first on PATH, or the hook uses a different global Node than the terminal.

macOS: GUI Git clients may not load the shell profile, so npm from mise or nvm is not on their PATH. 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.