Topic 11.8
Automating Quality: pre-commit and CI
In one line
Checks that depend on people remembering them get forgotten. A pre-commit hook runs formatting and linting before every commit, and a GitHub Actions workflow runs tests, lint and type checks on every push, on several Python versions, before code can merge.
Think of it like this
Airport security. Everyone passes through the same scanners before boarding, from first-time travellers to pilots, and nobody argues. There are two gates: a quick check at the entrance (pre-commit, on your own computer), and the final gate at the aircraft door (CI, on a shared server) that cannot be skipped. Quality checks work best when they are automatic and apply to everybody.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Git hook
- A script that Git runs at certain moments, such as before a commit.
- pre-commit
- A tool that installs and runs a list of hooks defined in
.pre-commit-config.yaml. - CI (continuous integration)
- Automatically building and testing the code every time someone pushes.
- GitHub Actions
- GitHub's built-in CI system, configured with YAML files.
- Workflow
- One YAML file describing when to run and what to run.
- Job and step
- A job is a group of steps on one machine. A step is one action or one shell command.
- Runner
- The fresh virtual machine that runs a job.
- Matrix
- A way to run the same job once for each combination of values, such as Python versions.
- Branch protection
- A repository rule that blocks merging until chosen checks pass.
- Secret
- A value such as an API key stored safely by GitHub and given to a job at run time.
Step by step
01The two gates
Checks run in two places. On your computer, pre-commit catches the small stuff in seconds, before the commit exists. On GitHub, CI runs everything on a clean machine and decides whether the change may merge. Fast feedback first, full proof second.
02Write .pre-commit-config.yaml
Each entry names a repository of hooks, a rev (the exact release to use, pinned so everyone gets the same version) and the hook ids. Ruff publishes its own hooks. Replace the rev below with the current tag from the Ruff pre-commit repository, and let pre-commit autoupdate keep it fresh.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: vX.Y.Z # pin to the current release tag
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format03Install it and watch it work
pre-commit install writes the hook into .git/hooks. The first commit below is stopped: Ruff fixed the unused import and reformatted the file, so the files changed and the commit did not happen. git status shows MM: the staged copy is old, and the working copy is the fixed one. Run git add and commit again to succeed. Output trimmed.
04A GitHub Actions workflow with uv
Save this as .github/workflows/ci.yml. test runs once for each Python version in the matrix. quality runs lint, format check and type check once. The two jobs run in parallel. actions/checkout@v4 and astral-sh/setup-uv@v6 are the standard first two steps; pin other third-party actions to the current release too, and read an action's README before using it.
uv run --python 3.12 pytest tells uv which interpreter to use (it downloads it if needed). --locked makes uv sync fail instead of silently changing uv.lock. Replace mypy . with pyright if that is your checker. Names and flags of third-party tools change between releases, so check their documentation.
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked --python ${{ matrix.python-version }}
- run: uv run --python ${{ matrix.python-version }} pytest
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy .05Read a workflow as data
A workflow file is plain YAML, so you can load it and look at it. The runnable example parses the file with PyYAML, lists the jobs and steps, and expands the matrix with itertools.product: three Python versions give three separate test jobs.
06Order checks cheapest first and fail fast
Put the fastest checks first (format, lint), then types, then the slow tests. A pipeline that stops at the first failure gives feedback sooner and saves compute. The second runnable example simulates this gate: each check returns pass or fail, and the first failure skips the rest and sets a non-zero exit code, which is exactly how CI decides red or green.
07Make it mandatory
Checks only protect you if they cannot be bypassed. In the repository settings, add a branch protection rule on main that requires the quality check and the test checks to pass and requires a pull request. A matrix job is reported once per combination, with the value in its name (test (3.11), test (3.12), test (3.13)), so those are the names you tick as required. A developer can still skip pre-commit locally, but nobody can merge a red build.
Two habits for AI projects: CI tests use fake model clients (Topic 11.4), so builds are free, fast and repeatable. And API keys come from GitHub secrets (secrets.NAME in the workflow), never from files in the repository.
Try it yourself
- 1
Expand the matrix
In the first example add a second key to the matrix, such as
os: [ubuntu-latest, windows-latest], and run it. How many jobs does the product give now? (Three versions times two systems is six.) - 2
Try pre-commit on your own project
In a Git repository on your computer, add
.pre-commit-config.yamlwith the Ruff hooks above, runpre-commit install, then commit a file with an unused import. Watch the commit stop, thengit addthe fixed files and commit again. - 3
Order the pipeline
In the gate example, move
check_teststo the first position. Run it and see what is skipped. Which order gives a developer feedback fastest, and why?
Code & diagrams
PyYAML parses the workflow text. Add "3.14" to the version list and run again to see a fourth job appear.
Expected output
workflow name: ci
jobs: ['test', 'lint']
test: 3 steps
- actions/checkout@v4
- astral-sh/setup-uv@v6
- uv run --python ${{ matrix.python-version }} pytest
lint: 4 steps
- actions/checkout@v4
- astral-sh/setup-uv@v6
- uv run ruff check .
- uv run ruff format --check .
one test job will run with python 3.11
one test job will run with python 3.12
one test job will run with python 3.13Change `check_lint` to return `True, "clean"` and run again: every check passes and the exit code becomes 0.
Expected output
[PASS] format: all files already formatted
[FAIL] lint: main.py:4: F401 'os' imported but unused
fail fast: stopping, later checks are skipped
skipped: ['types', 'tests']
exit code: 1Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Tab characters in YAML
Indent a line of the workflow with a tab instead of spaces.
import yaml
text = "jobs:\n\ttest:\n runs-on: ubuntu-latest"
print(yaml.safe_load(text))Myth vs fact
Myth
pre-commit hooks guarantee that bad code never reaches the repository.
Fact
Hooks run on the developer's machine and can be skipped (--no-verify) or never installed. CI with required checks is the real guarantee.
Myth
CI is only for big teams.
Fact
A single-person project benefits too: the workflow file is 20 lines, and it catches "works on my machine" the first time you push.
Myth
If tests pass on my Python version they pass on all of them.
Fact
Behaviour, available library features and deprecations differ between versions. A matrix runs the tests on each supported version.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Pin both the hook
revand the Actions versions, and update them deliberately (pre-commit autoupdate, Dependabot or Renovate). Floating references make builds change without any code change, which is the hardest kind of failure to debug. - ▸
Make local and CI commands identical. A single entry point (a
Makefiletarget, ajustrecipe or documenteduv runcommands) means "it failed in CI" can be reproduced with one command on your laptop. - ▸
Use
concurrencyin the workflow to cancel older runs of the same branch when a new push arrives, and cache dependencies (the uv action can cache downloads; read its documentation for the current option). Both cut cost and waiting time. - ▸
A matrix cancels its other jobs when one fails (
fail-fastdefaults to true). For a compatibility matrix you usually wantfail-fast: false, so one broken Python version does not hide the results of the others. Addpermissions: contents: readat the top of the workflow (least privilege), and for third-party actions that handle secrets, pin to a full commit SHA rather than a movable tag, because a tag can be repointed. - ▸
Hooks and CI can share one definition: run
pre-commit run --all-filesas a CI step (or use pre-commit.ci), so the exact hooks developers run locally are also enforced on the server. Ruff in CI should useruff checkandruff format --check(no--fix), because a fix on a CI runner changes files nobody will commit. Type-checker and test tools must be in your dev dependency group, oruv run mypyfails on the clean runner. - ▸
Be careful with secrets: workflows triggered by pull requests from forks do not get your secrets, by design. Never print secrets in logs and do not run untrusted code with write permissions. Give the workflow only the permissions it needs.
Remember this
- 1
The problem automation solves is human: people forget to run the tests, "it works on my machine", and one person's editor formats differently from another's. If a check is a rule in a wiki, it will be broken. If a check is a machine that blocks the commit or the merge, it is kept.
- 2
A Git hook is a script Git runs automatically at certain moments, for example just before
git commitfinishes. (Git is the tool that records versions of your code; see the Git course,/git.) The pre-commit tool (a Python program) manages these hooks for you. - 3
You list the hooks in a file called
.pre-commit-config.yaml. YAML is a plain-text format for settings (Topic 7.8). You runpre-commit installonce, and from then on every commit first runs the hooks on the files you staged (the ones you picked withgit add). If a hook fails, or changes a file (such as a formatter), the commit is stopped. You review,git addagain and commit again. - 4
What belongs in pre-commit? Fast checks, a few seconds at most: Ruff lint, Ruff format, trailing whitespace, a check that no huge file or secret is being committed. Do not put the whole test suite there, or people will start skipping hooks with
git commit --no-verify. And because hooks can be skipped, they are a convenience, not a guarantee. The guarantee is CI. - 5
CI (continuous integration) means a server automatically builds and checks your code every time someone pushes. On GitHub it is GitHub Actions: you describe a workflow in a YAML file under
.github/workflows/. - 6
A workflow has triggers (
on: push,pull_request), one or more jobs, and each job has a list of steps. Each job starts on a fresh runner: a clean virtual machine (a computer simulated by software), such asubuntu-latest. So it finds out whether your project works from scratch, which is exactly what "works on my machine" hides. - 7
For a Python project built with uv (Topic 6.6), the steps are short. Check out the code (
actions/checkout), install uv (astral-sh/setup-uv), then run the same commands you run locally throughuv run:uv run pytest,uv run ruff check .,uv run ruff format --check ., and a type checker such asuv run mypy .oruv run pyright(Topic 10.3). - 8
uv sync --lockedinstalls fromuv.lock(the file that records exact versions, Topic 6.6) and fails if the lock file is out of date. That keeps CI and your laptop on the same versions. - 9
A matrix runs one job several times with different values. A list of Python versions gives you one test job per version, run in parallel. This is how a library that claims to support Python 3.11 to 3.13 proves it. The runnable example reads a workflow with PyYAML and prints how the matrix expands. Keep the lint and type-check jobs on a single version: their answers do not depend on the Python version.
- 10
The last step is making CI mandatory. In GitHub, branch protection can require that named checks pass before a pull request can be merged, so a red build blocks the merge.
- 11
For AI projects keep CI predictable and free. Tests use fakes for model clients (Topic 11.4), never real API calls. Any real key lives in GitHub secrets, not in the repository. CI is a real topic of its own; the CI/CD course (
/cicd) covers pipelines, caching, artifacts and deployment in depth.
Explain it without notes
What is a pre-commit hook, and why is it not enough on its own?
Walk through a GitHub Actions workflow for a Python project.
What is a build matrix and when do you use it?
Why should CI tests for an AI application not call the real model?
How do you make CI results mandatory for merging?
Practice
Write a .pre-commit-config.yaml that runs the Ruff lint and format hooks plus the standard trailing-whitespace hook, and parse it with PyYAML to print the hook ids.
Write a workflow jobs section as a Python string with a test job and a matrix of two operating systems and two Python versions, and print how many jobs the matrix produces.
Write a function run_pipeline(checks) that runs named checks in order, stops at the first failure and returns the exit code.
Trade-offs
- ↔
Fast feedback versus thoroughness. Pre-commit must be quick or people bypass it; CI can be slow and thorough. Split checks between them by cost.
- ↔
A big matrix versus CI minutes. Testing every version and operating system finds compatibility bugs, but multiplies cost and waiting time. Support a stated range and test that range, not every possibility.
- ↔
Strict gates versus developer flow. Required checks stop bad merges but can annoy when flaky. Fix or remove flaky checks quickly, so people keep trusting the gate.
Done when you can
Done when you can write a
.pre-commit-config.yamlwith Ruff hooks and install it.Done when you can explain why pre-commit is a convenience and CI is the guarantee.
Done when you can read a GitHub Actions workflow: triggers, jobs, steps, runner.
Done when you can write a matrix for several Python versions and say how many jobs it creates.
Done when you can explain how branch protection makes checks mandatory.
Done when you know where to learn more in the CI/CD course at /cicd.