CI mirror for pre-commit hooks
The problem this module solves
The Intermediate track had you write a multi-hook .pre-commit-config.yaml that blocks a commit locally when a rule is violated. But "locally" is the word that matters: pre-commit installs as a Git hook (pre-commit install), so if a contributor never installed it on their machine, or bypasses it with git commit --no-verify, nothing stops them from pushing code that violates your rules.
The only reliable protection is running the same hooks somewhere no individual contributor controls: CI. This module doesn't build anything conceptually new — it's the "Intermediate module 03" prerequisite that matters here, not the devcontainer-thread modules. CI runs on its own runner, independent of any local devcontainer setup.
Reusing your existing config
You should already have, from the Intermediate track, a .pre-commit-config.yaml with several hooks. The example below is simplified to stay readable in this module — your real file may have different rev values, or an extra hooks repo (flake8, for instance). That's fine: swap the content below for your real file before continuing, the reasoning applies identically.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
This file doesn't change for this module — that's exactly the point. A CI mirror doesn't rewrite your rules, it runs them somewhere else.
The GitHub Actions workflow
Create .github/workflows/pre-commit.yml:
name: pre-commit
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: pre-commit/action@v3.0.1
Three steps, in this exact order:
actions/checkout: without it, the runner has no source code to check.actions/setup-python:pre-commitis itself a Python tool — the action needs a Python interpreter available to install and run it.pre-commit/action: reads your existing.pre-commit-config.yaml, installs the hooks it declares, and runs the equivalent ofpre-commit run --all-files— every hook, over every file in the repo, not just the files changed in the pull request.
To run a subset of hooks or pass extra arguments, the action accepts extra_args:
- uses: pre-commit/action@v3.0.1
with:
extra_args: black --all-files
But for a true mirror — this module's goal — you want the default run, without extra_args, so exactly the same hooks that run locally on every commit also run in CI.
A note on maintenance: the official pre-commit/action repository is documented as being in maintenance-only mode — the docs recommend pre-commit.ci for longer-term use, which offers more features (auto-fix, automatic revision bumps). For this module, pre-commit/action remains the most direct way to understand the mirroring mechanism — you can migrate to pre-commit.ci later without changing your .pre-commit-config.yaml.
Proof of success: push a violation, watch it fail in the same place
The mechanical proof your mirror works isn't "the workflow exists" — it's "a push that violates a rule fails in CI on exactly the same hook it fails on locally".
1. Reproduce the local block.
echo "x=1 " >> some_file.py # trailing whitespace, violates trailing-whitespace
git add some_file.py
git commit -m "test: trigger trailing-whitespace"
If pre-commit install is active on your machine, this commit is blocked locally, with a message like:
trailing-whitespace...............................................Failed
- hook id: trailing-whitespace
- exit code: 1
- files were modified by this hook
2. Bypass the local hook to push anyway (simulating a contributor without pre-commit install, or deliberately overriding it):
git commit --no-verify -m "test: trigger trailing-whitespace"
git push origin your-branch
3. Open the pull request and watch the pre-commit workflow in GitHub's Actions tab.
The success criterion: the job fails, and the job log shows the same hook (trailing-whitespace) and the same failure reason seen locally in step 1 — not a different hook, not an infrastructure error (missing Python, pre-commit not found). If the job fails for a reason unrelated to your hooks (say, a missing actions/setup-python), fix the workflow — it's the setup, not your mirror concept, that's broken.
4. Fix the violation (remove the trailing whitespace, or let the end-of-file-fixer/black hook auto-fix it locally), commit, push again — the job should go green. That's the full loop: blocked locally, blocked identically in CI, unblocked the same way in both.
What's next
Your .pre-commit-config.yaml references external repos by their rev — a Git tag like v4.6.0. The next module has you audit what that tag actually guarantees, and what it doesn't, cross-referencing this config with the image references from module 01.
Check your understanding
What must a GitHub Actions job using pre-commit/action contain, at minimum, to run your hooks?
With no extra arguments, which hooks does pre-commit/action run, and on which files?
You push a commit with a line that's too long, blocked locally by the line-length hook in your .pre-commit-config.yaml. The CI job mirroring that hook should:
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.