Ramblings of an aging IT geek
← Ramblings of an aging IT geek
tooling

a makefile is just a list of things you keep forgetting

Using a plain Makefile as the front door to a project that has no C in it at all, just to stop relearning the commands every fortnight.

A terminal showing a make target running

There is no C anywhere in this project. It's a Go service with a bit of Python tooling and a pile of YAML. And yet the first file a new person opens is a Makefile, because that's where I keep the answers to "how do I actually run this thing".

I came to this slowly and slightly against my own taste. Make has real flaws as a build tool: the tab-versus-spaces thing is genuinely awful, the dependency model is about files and timestamps which often isn't what you want, and the syntax rewards cleverness in a way that ages badly. But none of that matters for the job I'm actually using it for, which is being a memorable, discoverable list of project commands.

The problem it solves is forgetting. Every project accumulates a dozen incantations: the exact lint flags, the test command with the right tags, how to regenerate the mocks, how to spin up the local dependencies. They live in your shell history and in three different people's heads, and a fortnight later nobody remembers the flags. A Makefile drags them into one file in the repo.

A Makefile open in an editor with several targets visible

The version I keep reaching for is small and has one trick worth stealing, a self-documenting help target:

.PHONY: help test lint run gen

help: ## show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-12s\033[0m %s\n", $$1, $$2}'

test: ## run the test suite
	go test -race ./...

lint: ## run the linters
	golangci-lint run

run: ## run locally against docker-compose deps
	docker compose up -d db redis
	go run ./cmd/server

gen: ## regenerate mocks and protobuf
	go generate ./...

Each target has a ## comment, and make help parses those comments out and prints them. Run make with no argument and you get the menu. New starter clones the repo, types make help, and the whole interface is there. No wiki page that's three months stale, no asking in Slack.

A couple of things I've learned to keep myself honest. Use .PHONY for every target that isn't actually producing a file of that name, which for this kind of Makefile is nearly all of them, otherwise Make gets clever about timestamps and skips things you wanted run. And resist the urge to put real logic in here. The moment a target grows an if and a loop, it wants to be a shell script that the target just calls. Make is the index, not the implementation.

Is it the right tool? Probably just is, honestly, and I'd not argue with anyone who reaches for it instead: no tab nonsense, cleaner syntax, built for exactly this. But Make is already installed on every machine I touch, everyone half-knows it, and make test is muscle memory by now. For a front door, ubiquity beats elegance. It's the list of things I keep forgetting, and it's in the repo, and that's the whole point.