Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~30 min· topic 8 of 8

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.

The two gatesdiagram
Rendering diagram…

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.

.pre-commit-config.yamlwhole fileyaml
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-format

03Install 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.

terminal
$ pre-commit install
git add -A
git commit -m "second"
── expected output ──
pre-commit installed at .git/hooks/pre-commit
ruff check...............................................................Failed
- hook id: ruff-check
- files were modified by this hook
 
Found 1 error (1 fixed, 0 remaining).
 
ruff format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook
 
1 file reformatted

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.

.github/workflows/ci.ymlwhole fileyaml
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. 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. 2

    Try pre-commit on your own project

    In a Git repository on your computer, add .pre-commit-config.yaml with the Ruff hooks above, run pre-commit install, then commit a file with an unused import. Watch the commit stop, then git add the fixed files and commit again.

  3. 3

    Order the pipeline

    In the gate example, move check_tests to the first position. Run it and see what is skipped. Which order gives a developer feedback fastest, and why?

Code & diagrams

Read a workflow file and expand its matrix New tab

PyYAML parses the workflow text. Add "3.14" to the version list and run again to see a fourth job appear.

Sign in to run this example in your browser.

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.13
A fail-fast quality gate New tab

Change `check_lint` to return `True, "clean"` and run again: every check passes and the exit code becomes 0.

Sign in to run this example in your browser.

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: 1
Where each check runsdiagram
Rendering diagram…

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

main.pywhole filepython
import yaml

text = "jobs:\n\ttest:\n    runs-on: ubuntu-latest"
print(yaml.safe_load(text))
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 4, in <module>
print(yaml.safe_load(text))
...
yaml.scanner.ScannerError: while scanning for the next token
found character '\t' that cannot start any token
in "<unicode string>", line 2, column 1:
test:
^

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 rev and 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 Makefile target, a just recipe or documented uv run commands) means "it failed in CI" can be reproduced with one command on your laptop.

  • ▸

    Use concurrency in 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-fast defaults to true). For a compatibility matrix you usually want fail-fast: false, so one broken Python version does not hide the results of the others. Add permissions: contents: read at 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-files as 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 use ruff check and ruff 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, or uv run mypy fails 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. 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. 2

    A Git hook is a script Git runs automatically at certain moments, for example just before git commit finishes. (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. 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 run pre-commit install once, and from then on every commit first runs the hooks on the files you staged (the ones you picked with git add). If a hook fails, or changes a file (such as a formatter), the commit is stopped. You review, git add again and commit again.

  4. 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. 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. 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 as ubuntu-latest. So it finds out whether your project works from scratch, which is exactly what "works on my machine" hides.

  7. 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 through uv run: uv run pytest, uv run ruff check ., uv run ruff format --check ., and a type checker such as uv run mypy . or uv run pyright (Topic 10.3).

  8. 8

    uv sync --locked installs from uv.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. 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. 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. 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

01

What is a pre-commit hook, and why is it not enough on its own?

02

Walk through a GitHub Actions workflow for a Python project.

03

What is a build matrix and when do you use it?

04

Why should CI tests for an AI application not call the real model?

05

How do you make CI results mandatory for merging?

Practice

01

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.

02

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.

03

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.yaml with 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.