Almost every repo I work in now has a Makefile, and almost none of them compile a single line of C. That feels like an abuse of the tool to people who know make mainly as a build system, and for a while it felt like one to me too. But the thing make actually gives you, underneath all the C-specific machinery, is a named, discoverable, tab-completable set of commands that live in the repo and work the same on everyone's machine. That's worth a lot, and most projects have nowhere else to put it.
The problem it solves is the one every project has within a month: there are six commands you need to run regularly and they're written down in the README, or in someone's shell history, or nowhere. make test, make lint, make fmt, make run. One word each, the same word in every project, and the actual incantation behind it can be as gnarly as it needs to be.
what a good target looks like
A non-build target is just a recipe with a .PHONY declaration so make doesn't go looking for a file named after it. That's the one gotcha people hit: if you have a target called test and there's no file called test, fine, but the day someone adds a directory called test, make test silently does nothing because make thinks the target is up to date. Declare everything phony and the problem never appears.
.PHONY: fmt lint test build run clean
fmt:
black src/ tests/
isort src/ tests/
lint:
flake8 src/ tests/
mypy src/
test:
pytest -q
run:
python -m myapp --config config/dev.yaml
That's a Python project. There is no compilation anywhere in it. But make lint runs the same two tools in the same order for me, for CI, and for the new starter who's never seen the repo, and nobody has to remember that the import sorter is even installed.
the help target earns its keep
The one addition I make to every Makefile is a self-documenting help target, so the file tells you what it can do. It works by grepping the Makefile for targets that have a comment in a specific format, which sounds fiddly but you write it once and paste it forever:
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
test: ## Run the test suite
pytest -q
Set help as the default goal with .DEFAULT_GOAL := help and a bare make prints the menu. Now the repo's front door is a single command that lists every other command with a one-line description, and it can't drift out of date because it's generated from the targets themselves.
A few things I've learned to keep it from rotting. Keep recipes short; if a target needs more than about five lines, it wants to be a script in a scripts/ directory that the target calls, because Make's tab-and-shell quoting rules are not where you want to be writing real logic. Don't reach for variables and pattern rules and make's functional language just because they're there; the moment a Makefile for a Python project starts looking clever, you've lost the plot, and the next person can't read it. The whole value is that make test means the same thing everywhere. Treat it as a menu of named commands, not as a programming language, and it stays useful in repos that wouldn't know a .o file if they tripped over one.