Olavi Kuusela

A makefile is enough CI

27 January 2026 · 4 min read

Every project I start gets the same folder of scripts, and every time I wonder whether it should be a pipeline instead. For one person and one small program, the answer has been no for about a decade.

The five targets

.PHONY: all fmt lint test check install

all: check

fmt:
	gofmt -w .
	npx prettier --write 'web/**/*.css'

lint:
	go vet ./...
	staticcheck ./...

test:
	go test ./...

check: fmt lint test

install:
	install -m 0755 walsnap /usr/local/bin/walsnap

That is the entire build system. make check is what I run before every commit, and it is what a pipeline would run too, just with more YAML and a longer wait.

Why not a pipeline

Pipelines earn their keep when the check has to happen somewhere other than your machine: several people, several platforms, artefacts that need to be reproducible. A single-author project on one platform has none of those problems, and the feedback loop of a local make check that finishes in two seconds is genuinely better than waiting for a runner to boot.

The failure mode of this argument is obvious: the day a second person joins, or the day you need to build for a platform you do not own, you need the pipeline. That day is easy to recognise, and the makefile targets port into it directly.

The one hook that is worth it

What does pay off is a pre-commit hook calling the same targets, because it removes the step where you forget:

#!/bin/sh
make check || exit 1

Saved as .git/hooks/pre-commit and made executable. Not committed, not shared, not clever — just enough that the check runs at the moment it can still save you a messy history.

Deploy is a target too

deploy: check
	rsync -az --delete site/ server:/var/www/example.dev/

Making deploy depend on check means it is impossible to deploy something that does not build. That single dependency line has prevented more bad deploys than any pipeline I have set up.

← All posts