pre-commit hooks protect a repository only for contributors who installed them. Someone commits from a fresh clone, from the GitHub web editor, or with --no-verify, and unformatted code, a stray debug print or an unsorted import lands on the branch anyway. Running the same hooks in CI closes that gap: the configuration that runs on every developer's commit also runs, unconditionally, on every push and pull request. Doing it well takes more than pre-commit run --all-files though — hook environments are slow to build and should be cached, pull requests should be checked for what they changed, failures should show the fix, and some hooks are too slow for every commit but right for CI. This guide builds that job for a Python CLI repository. It belongs to the pre-commit topic, and extends the CI step sketched in setting up pre-commit for Python CLI repos.
Prerequisites
- A
.pre-commit-config.yamlwith pinned hook revisions. - GitHub Actions (the ideas apply to any CI system).
What CI adds to local hooks
Locally, pre-commit runs on the files staged for a commit, in the developer's environment, and can be skipped. In CI it runs on a known set of files, in a clean environment, and cannot be skipped — which makes it the enforcement point, while local hooks remain the fast feedback loop. The job should therefore run the same hooks with the same versions, so a commit that passed locally passes in CI and vice versa.
The recipe
# .github/workflows/pre-commit.yml
name: pre-commit
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for --from-ref on pull requests
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pipx install pre-commit==4.6.2
- name: Changed files (pull requests)
if: github.event_name == 'pull_request'
run: >
pre-commit run --show-diff-on-failure --color=always
--from-ref "origin/${{ github.base_ref }}" --to-ref HEAD
- name: All files (main)
if: github.event_name == 'push'
run: pre-commit run --all-files --show-diff-on-failure --color=always
- name: CI-only hooks
run: pre-commit run --all-files --hook-stage manual
Cache hook environments
pre-commit builds an isolated environment for each hook repository the first time it runs — cloning, creating a virtual environment, installing — which takes tens of seconds per hook. Those environments live in ~/.cache/pre-commit. Keying the cache on a hash of .pre-commit-config.yaml means environments are rebuilt exactly when a hook's rev changes and reused otherwise, bringing a typical job from a minute or more down to a few seconds.
Check what the pull request changed
--from-ref and --to-ref run hooks only on files that differ between the two refs, so a pull request is judged on its own changes rather than on every file in the repository. That matters when you add a new hook or tighten a rule: existing violations elsewhere do not block an unrelated pull request, and you can fix them in a dedicated clean-up. The full --all-files run on pushes to main makes sure the default branch is entirely clean. fetch-depth: 0 gives the job the base branch to compare against.
Show the fix
--show-diff-on-failure prints the changes that auto-fixing hooks (formatters, import sorters, end-of-file fixers) made, so the log says exactly what to change — or the contributor simply runs pre-commit run --all-files locally and commits the result. --color=always keeps pre-commit's colour output readable in the Actions log.
Keep slow hooks for CI
Some checks are valuable but too slow for every commit: a full mypy run, building documentation, regenerating a CLI reference and checking it is up to date. Put them in the manual stage so local commits skip them and CI runs them explicitly:
- repo: local
hooks:
- id: cli-reference
name: CLI reference is up to date
entry: uv run python scripts/gen_cli_reference.py --check
language: system
pass_filenames: false
always_run: true
stages: [manual]
always_run: true matters for hooks that take no filenames: without it, pre-commit skips the hook with "(no files to check)" whenever none of its files patterns match — which, for a hook with no patterns and pass_filenames: false, can surprise you. Developers can still run the stage on demand with pre-commit run --hook-stage manual --all-files.
Skip what cannot run in CI
Occasionally a hook needs something CI lacks — a credential, a local service. Skip it by id with the SKIP variable rather than maintaining a second configuration:
- run: pre-commit run --all-files
env:
SKIP: check-license-server
Use this sparingly; every skipped hook is a check that only some contributors ever run.
Or let pre-commit.ci do it
pre-commit.ci is a hosted service from the pre-commit maintainers that runs your configuration on every pull request with cached environments, pushes auto-fixes back to the branch as a commit, and opens weekly autoupdate pull requests. It needs no workflow file — only a ci: section in .pre-commit-config.yaml for options — and is free for open-source repositories. The trade-off: it cannot run hooks that need network access or language: system tools, which is exactly where the manual-stage job above still earns its place.
UX considerations
- Same versions everywhere. Pin
pre-commititself in CI, and keep hookrevs as the single source of tool versions — see keeping hook versions current with autoupdate. - Fail with instructions. Add a step that runs on failure and prints "Run
pre-commit run --all-fileslocally and commit the changes", because newcomers may not know pre-commit exists. - Do not fight auto-fixes. Letting CI commit fixes (as pre-commit.ci does) is convenient for formatting; for anything semantic, a failing check that a human fixes is clearer.
- Keep the job fast and separate. A pre-commit job that finishes in under a minute, independent of the test matrix, gives contributors the cheapest possible feedback.
- Mind duplication. If CI already runs Ruff and mypy with inline annotations, either drop them from the pre-commit job or drop the separate job — running both doubles the noise for every finding.
Testing the behaviour
Prove the job does its job once, on a throwaway branch:
git switch -c check-pre-commit-ci
printf 'import os\nx=1\n' > src/mytool/scratch.py # unused import, unformatted
git add src/mytool/scratch.py && git commit --no-verify -m "deliberately bad"
git push -u origin check-pre-commit-ci
Open a pull request and confirm the job fails with Ruff's finding and a formatting diff for scratch.py only. Then check the cache: the second run of the job should report a cache hit and skip the "Installing environment" lines entirely. Finally, change one hook's rev and confirm the cache key changes and the environments rebuild once.
Conclusion
pre-commit in CI turns optional local checks into enforced ones without a second source of truth. Cache ~/.cache/pre-commit keyed on the config file, check pull requests with --from-ref/--to-ref and the default branch with --all-files, show diffs on failure, run slow checks in a manual stage with always_run, skip only what truly cannot run, and consider pre-commit.ci for automatic fixes and updates. Contributors then get the same answer locally and in CI — just faster locally.
Frequently asked questions
Should CI run --all-files on pull requests too?
It is simpler and catches everything, but it makes every pull request responsible for the whole repository's state. Checking changed files on pull requests and all files on the default branch is a good balance; after adding a new hook, clean the repository in one dedicated pull request.
Why does CI find problems my local hooks did not?
Usually different versions (an unpinned local pre-commit or a stale hook environment — pre-commit clean fixes the latter), files committed with --no-verify, or hooks in the manual stage. Running pre-commit run --all-files locally reproduces most cases.
Can pre-commit run in the same job as the tests?
It can, but a separate job reports faster and keeps the logs readable. Lint failures are the most common CI failure and should be the quickest to see.
Does the GitHub Action pre-commit/action still make sense?
It wraps installation, caching and the run in one step and works well. It is in maintenance mode, with pre-commit.ci recommended instead; the explicit steps above give the same result with full control.
Why does pre-commit in CI complain that the configuration is unstaged?
pre-commit refuses to run when .pre-commit-config.yaml has uncommitted changes, to avoid running a configuration nobody has reviewed. In CI this happens when an earlier step modifies the file — a templating step, or an autoupdate run in the same job. Commit the change in that step, or run autoupdate in a separate workflow that opens a pull request instead, so the checking job always runs exactly what is in the repository.