Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 3 of 6

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:

  1. actions/checkout: without it, the runner has no source code to check.
  2. actions/setup-python: pre-commit is itself a Python tool — the action needs a Python interpreter available to install and run it.
  3. pre-commit/action: reads your existing .pre-commit-config.yaml, installs the hooks it declares, and runs the equivalent of pre-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.