
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.
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
- GitHub Actions documentation
- GNU Make manual
- uv documentation
- This blog’s
Makefileandci.yml