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

make is a perfectly good task runner if you stop pretending it's 1985

A practical case for using GNU Make as a project task runner for non-C codebases, with the handful of patterns that make it pleasant instead of cursed.

A mechanical keyboard next to a terminal

Every project I touch grows a handful of commands you have to remember. Build the thing, run the tests, lint, format, generate the docs, build the Docker image, push it. They end up scattered across a README, a few shell scripts, someone's bash history, and a CI config that's quietly the only source of truth. I've tried the dedicated task runners. Most of the time I come back to a Makefile, and not because I'm nostalgic.

The objection is fair: Make was built to compile C, its syntax is from another era, and tabs-versus-spaces will bite you within the first five minutes. All true. But for a Python service, a Rust binary, a static site, a pile of YAML, Make does one thing extremely well that the alternatives mostly don't: it's already installed, it has no config format to learn beyond targets and recipes, and make test works the same on my laptop, my colleague's laptop, and the CI runner. That portability is worth a lot of awkward syntax.

the bare minimum that makes it pleasant

A naive Makefile for a non-C project has two problems. First, Make thinks every target is a file, so if you have a test target and a test/ directory it gets confused. Second, with no output it's hard to see what's running. Both are a few lines to fix.

.PHONY: help build test lint fmt clean

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

build: ## Build the binary
	cargo build --release

test: ## Run the test suite
	cargo test

lint: ## Run clippy
	cargo clippy -- -D warnings

fmt: ## Format the code
	cargo fmt

Two things are doing the work here. .PHONY tells Make these targets aren't files, so it always runs them. And the ## comment trick plus that help recipe gives you a self-documenting menu: type make (with help as the default goal) and you get a list of what's available. New starter clones the repo, runs make, and sees exactly which doors exist. That alone has saved me more onboarding conversations than any wiki page.

Lines of code on a dark editor background

the patterns that earn their keep

A few more I reach for constantly.

Variables for the things you'll want to override:

IMAGE ?= myapp
TAG   ?= $(shell git rev-parse --short HEAD)

docker: ## Build and tag the image
	docker build -t $(IMAGE):$(TAG) .

The ?= means "set this unless it's already set", so CI can pass TAG=v1.2.3 make docker and override it. The git rev-parse gives you a sensible default locally. This is the kind of small ergonomic win that adds up.

Real dependencies, where they exist. This is the one genuine advantage Make has over a flat list of shell aliases: targets can depend on each other and on files, and Make won't redo work that's already current.

node_modules: package.json package-lock.json
	npm ci
	@touch node_modules

build: node_modules ## Build the site
	npm run build

Now make build only runs npm ci if package.json changed since the last install. The touch is a small lie to Make so it treats the directory's timestamp as the marker. It's a hack, but it's a stable, well-understood hack, and it turns "always reinstall everything" into "reinstall when it actually changed".

where I draw the line

Make is a task runner with a build system bolted to the side, and you should use the first part and mostly ignore the second for these projects. The moment a recipe grows past a few lines or needs real control flow, I move the body into a scripts/ shell file and have Make just call it. Make is bad at logic, conditionals, and anything resembling a loop. Don't make it do those. Let it be the index, and let scripts be the content.

I also keep one rule: a target should do one obvious thing. make deploy that secretly also builds, tests, and pushes is how you end up afraid of your own Makefile. List the dependencies explicitly so the chain is visible, and keep each recipe honest about what it runs.

None of this is clever. That's the appeal. A Makefile in the root of the repo is a contract any engineer can read in thirty seconds, it needs nothing installed that isn't already there, and it'll still work in five years when whichever shiny task runner I'd otherwise have picked has been deprecated twice. For things that aren't C, that's exactly the boring reliability I want from the layer that runs everything else.