Every project I work on grows the same small pile of commands. Build the thing, run the tests, lint it, build the container, push the container, clean up after yourself. They end up scattered across a README, a couple of shell scripts, and the fading memory of whoever set the project up. So at some point I reach for a Makefile, and someone always asks why I am using a C build tool on a Go and Python repo with no C in sight.
Because it is already installed, it is already understood, and as a task runner it is genuinely good. I am not using Make to compile anything. I am using it as a documented, tab-completable menu of the commands this project needs, which is most of what task runners do anyway. The trick is to stop treating it as a build system and treat it as a list of named recipes.
the rules that keep it sane
The single most important line in any non-C Makefile is this:
.PHONY: build test lint docker clean
Make assumes every target is a file. When your test target is not a file called test, Make will happily decide it is "up to date" because nothing changed, and silently skip it. .PHONY tells Make these are tasks, not files, and run them every time. Forget this and you will lose twenty minutes wondering why your tests "passed" without running.
The second thing is a self-documenting default target, so make on its own prints the menu rather than doing something surprising:
.DEFAULT_GOAL := help
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 tests
go test ./... -race
lint: ## Run linters
golangci-lint run
ruff check .
Now make help reads the comments off the targets and prints them. New people on the project type make and immediately see what they can do, which beats reading a README that drifted out of date eighteen months ago.
where it earns its keep
The bit Make actually does well, beyond just running commands, is dependencies between tasks. make docker can depend on build, which can depend on a generated file, and Make sorts the order out for you. For the file-based cases it even does the right thing and skips work that is genuinely up to date. A code generation step that only reruns when the schema changes is two lines, and you get incremental behaviour for free.
It is not perfect. The tab-versus-spaces thing is a relic and will catch you at least once. Variable syntax is its own little dialect. And the moment your logic gets genuinely complicated, shelling out to a real script is the honest answer rather than fighting Make's syntax to express it. But for the eight or ten commands a project needs, day to day, it is hard to beat something that is on every machine already and that everyone half-knows.
I have tried the dedicated task runners, and several of them are nicer. They are also one more thing to install, pin, and explain. Make is just there, and once you have the .PHONY line and a help target, it gets out of the way and lets you get on. That is most of what I want from a tool.