Make Is for the Commands You Type, CI Is for the Ones You Forget

A Makefile and a GitHub Actions workflow split a project’s chores by who has to remember them, with this blog’s own pipeline as the worked example.
Development
Tooling
Author

Ravi Kalia

Published

April 9, 2024

Make and GitHub Actions

Every project accumulates chores: install the dependencies, run the linter, run the tests, build the site, publish it. Each is a command or three, and each has a way of being run slightly differently by each person, or skipped when the deadline is close. Two small files take the remembering away. A Makefile names the commands so that they are typed the same way every time, and a GitHub Actions workflow runs the ones that must happen whether anyone remembers or not. This post uses the pipeline that builds this blog as the example, because it is real and its mistakes have been paid for.

A Makefile is a menu, not a build system

Make was written to rebuild only the parts of a C program whose sources changed. Almost nobody uses it for that in a Python project. What survives is the other half: a Makefile is a list of named commands, and make lint is shorter, and harder to get wrong, than the three lines behind it. The menu for this blog looks like this, trimmed to the targets a contributor uses in a day:

install:                       # the toolchain, from the lockfile
    uv sync

lint:                          # read-only; `make fmt` applies the fixes
    .venv/bin/ruff check .
    .venv/bin/ruff format --check .

spell:
    .venv/bin/codespell

test:
    .venv/bin/python -m pytest

check-posts:                   # the repo's own invariants
    python3 scripts/check_posts.py

check: lint spell test check-posts

render: check-quarto
    python3 scripts/check_posts.py --no-listing
    QUARTO_PYTHON="$(CURDIR)/.venv/bin/python" quarto render . > render.log 2>&1
    @$(MAKE) docs-deleted

.PHONY: install lint spell test check-posts check render

Three habits make a Makefile like this earn its keep. Every target names its interpreter (.venv/bin/ruff, not ruff), so the command runs the project’s tool and not whatever is first on the path; the two check-posts recipes are the deliberate exception, because that script is stdlib-only and has to work on a clone with no .venv yet. The read-only check and the fixing command are separate targets, so make lint in CI cannot rewrite files. And a target that wraps something dangerous carries its guards: render refuses on the wrong Quarto version, runs the cheap checks before the expensive step, sends the noisy log to a file, and ends with a check for files the render should not have deleted. Each guard was added after the mistake it prevents had happened once.

CI runs the menu on every change, on a machine you did not set up

A Makefile does nothing until someone types it. The workflow makes the typing unnecessary: on every pull request, a fresh machine checks out the branch, installs the toolchain, and runs the same targets. If they fail, the merge button goes red. That is the whole idea of continuous integration, and the important word is fresh: the machine has none of the tools you installed by hand and forgot about, so the pipeline proves the project can be built from what is in the repository.

The blog’s workflow is two jobs. The first is the quick one:

name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true
      - run: uv sync --frozen
      - run: make check

The second renders the whole site with a pinned Quarto version, restores the one asset a render is known to drop, and fails if the shared runtime files changed, because that is the fingerprint of a Quarto upgrade that has broken older posts before. It took two attempts to get right. The first ran on Linux, and the CSS Quarto compiles from the site theme differs between Linux and the Mac the site is built on, so every page showed as changed. The job now runs on a macOS runner, which is the kind of thing a workflow has to say in a comment, because nobody will guess it.

Two more lines are worth stealing. concurrency with cancel-in-progress: true stops a stale run when a new commit arrives on the same branch. And UV_FROZEN=1 in the job’s environment makes every install use the lockfile, so CI tests the versions that are committed and never re-resolves them on the runner.

The split is by who has to remember

The question of what goes in the Makefile and what goes in the workflow has a simple answer. Anything a person runs while working, often and with variations, is a Make target: make lint on the two files you changed, make render-post SLUG=x on the one post you are writing. Anything that has to happen on every change, the same way, with no one deciding, is a workflow job. The two overlap on purpose: the workflow calls the same targets, so when a check passes on a laptop it passes in CI, and when CI fails the command to reproduce it is the one in the log.

Where it stops holding

Make’s own rules are a trap for Python projects. A target with the same name as a file in the directory will not run (that is what .PHONY is for), recipe lines must start with a tab, and a variable is $(NAME), not $NAME. And a workflow that only lints and tests is not yet deployment: publishing this site is still a local render committed to the repository, and the workflow checks that commit rather than making it. Continuous deployment is the step after this one, and it is worth taking only once the checks are trusted.

Name. The. Chores. Type. Them. Once. Machines. Repeat. Them. Every. Time.

References