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

the makefile as a memory aid, not a build system

Why I keep reaching for a plain Makefile to run project tasks even when there is no C in sight, and how I keep it honest.

A terminal at a keyboard, mid-build

There is no C in this project. There is some Go, a pile of YAML, a Python script that nobody admits to writing, and a Dockerfile. And yet the first thing I added, before any of that, was a Makefile. Not because I am building object files. Because I cannot reliably remember the incantation to run the test suite three weeks from now, and neither can anyone else.

That is the whole pitch. A Makefile is the cheapest possible answer to the question "how do I do the thing in this repo". It sits in the root, it is the first file people open, and make with no arguments can print you a menu. No new runtime, no package.json scripts buried in JSON, no shell aliases that live only on my laptop. Every machine with a developer on it already has make.

what I actually use it for

The honest list of targets in a typical repo of mine looks like this. Build, test, lint, run locally, format, clean, and a couple of project-specific ones like make migrate or make seed.

.PHONY: help build test lint run fmt clean

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

build: ## Build the binary
	go build -o bin/app ./cmd/app

test: ## Run the test suite
	go test ./... -race -count=1

lint: ## Run the linter
	golangci-lint run

run: build ## Build and run locally
	./bin/app --config config.dev.yaml

fmt: ## Format the code
	gofmt -w .

clean: ## Remove build artefacts
	rm -rf bin/

The help target is the bit that earns its keep. That grep and awk dance reads the ## comments out of the file itself and prints them as a menu. Run make help, or wire it up as the default target with .DEFAULT_GOAL := help, and a newcomer gets a self-documenting list of everything the project can do. The documentation cannot drift from the commands because it is the commands.

A Makefile open in an editor, targets and tab-indented recipes

the part where Make bites you

I am not going to pretend this is free. Make was written for C, in 1976, and it shows. There are three things that will catch you, and they catch everyone.

First, the tabs. Recipe lines must be indented with a literal tab, not spaces. If you let your editor "helpfully" expand tabs you get *** missing separator. Stop. and an afternoon of confusion. Put a rule in your .editorconfig and move on.

Second, every recipe line runs in its own shell. So this does not do what you think:

deploy:
	cd build
	./push.sh    # runs in the original directory, not build/

The cd evaporates the moment the line finishes. Chain it with && on one logical line, or set .ONESHELL: at the top of the file and accept that you have now opted into a slightly different set of footguns.

Third, the $. Make eats a single $, so a shell variable is $$VAR, and a literal dollar in a recipe is $$. The $$1 in my help target above is exactly this: one $ for Make, leaving $1 for awk.

And the big one, the conceptual mismatch: Make is built around files and timestamps. It decides whether to rebuild target by comparing its mtime to its prerequisites. When your "target" is an action like "run the tests" rather than a file, there is no file, so Make happily re-runs it every time, which is what you want. But Make does not know it is an action. If a file called test ever appears in the directory, make test will see it is up to date and do nothing. Hence .PHONY. Mark every action target as phony and Make stops looking for a matching file. Forget to, and you get a baffling "nothing to be done for 'test'" on the one day you are in a hurry.

why not a real task runner

Fair question. There are nicer tools for exactly this. just is a Make-shaped command runner with none of the C baggage: no tabs, no phony, sane variables, recipes that can span lines without && gymnastics. Taskfile does the same with YAML if that is your taste. I like just a great deal and reach for it on personal things.

But there is a tax on every new tool you add to a repo: the contributor has to install it first. make is already there, on every Linux box, every Mac, every CI image, baked into muscle memory. For a project other people clone, that ubiquity wins more often than the ergonomics lose. For a project only I touch, just wins. I have made my peace with running both, depending on the audience.

The trick that keeps a non-C Makefile from rotting is discipline about what goes in it. A Make target should be a one-line memory aid that shells out to the real tool. The moment a recipe grows past about five lines or sprouts an if, it wants to be a script in scripts/ that the target calls. Make is the index. It is not the program. Keep it that way and a Makefile in a repo with no C in it remains the most useful file in the tree: the thing you read first, that tells you how everything else works.