Every project I touch grows the same set of incantations: build the thing, run the tests, lint, format, build the container, push it somewhere. They start life in my shell history, migrate to a notes.md nobody reads, and eventually someone asks "how do I run the tests" and the answer is a three-line copy-paste from Slack. I've decided the right home for those incantations is a Makefile, even when there isn't a line of C in sight.
This is not a controversial opinion, but it's one people resist, usually because their only exposure to Make was a generated 4000-line monster that compiled someone's PhD. So let me make the case for boring, hand-written Makefiles as a task runner.
Why make and not a shell script
You could put all of this in ./scripts/*.sh, and plenty of good projects do. What Make gives you on top of that is three things: a single discoverable entry point (make <tab>), dependencies between tasks, and the ability to skip work that's already done. Even if you only ever use the first two, it's a better experience than a directory of scripts with subtly different argument conventions.
The honest trade is that Make's syntax is from another era and will bite you. Tabs not spaces. Each line is its own shell. $ means something to Make before it means anything to your shell. Once you've internalised those three, the rest is straightforward.
The shape I actually use
Here's a trimmed version of the Makefile I drop into a Go service. It's representative of the pattern, not the whole thing.
.DEFAULT_GOAL := help
SHELL := bash
BIN := myservice
VERSION := $(shell git describe --tags --always --dirty)
.PHONY: build
build: ## Compile the binary
go build -ldflags "-X main.version=$(VERSION)" -o bin/$(BIN) ./cmd/$(BIN)
.PHONY: test
test: ## Run the unit tests
go test ./... -race -count=1
.PHONY: lint
lint: ## Run the linters
golangci-lint run
.PHONY: fmt
fmt: ## Format the code
gofmt -w .
.PHONY: image
image: build ## Build the container image
docker build -t $(BIN):$(VERSION) .
.PHONY: clean
clean: ## Remove build artefacts
rm -rf bin/
A few deliberate choices in there.
SHELL := bash because the default /bin/sh varies by distro and I'd rather not find out which one I'm on at the worst moment. .PHONY on every target that doesn't produce a file named after itself, otherwise Make sees a test directory and decides the work is done. And image depends on build, so make image always compiles first. That dependency line is the bit a shell script makes you write by hand and remember to call in the right order.
The self-documenting help target
The one trick worth its weight is the help target. Those ## comment strings after each target aren't decoration, they get parsed:
.PHONY: 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}'
Run make with no arguments and you get a tidy, coloured list of every task and what it does, generated from the file itself. It can't drift out of date because it is the file. New starters run make help, see the verbs, and they're productive in about thirty seconds. I've watched this single target do more for onboarding than a page of README ever did.
Where it earns its keep with real dependencies
The task-runner use is the entry point, but Make's actual job, deciding what's stale, still pays off outside C. Generated code is the obvious case. If you generate Go from protobuf, you want to regenerate only when the .proto changes:
gen/api.pb.go: api.proto
protoc --go_out=gen api.proto
Now make gen/api.pb.go is a no-op until you actually edit the proto. Same pattern for compiling SCSS, bundling assets, rendering templated config, anything with a "source produces output" relationship. This is Make doing the one thing it was genuinely designed for, and it works just as well on a .proto as it ever did on a .c.
Knowing when to stop
The failure mode is letting the Makefile become a programming language. The moment you're reaching for ifeq, recursive $(MAKE) calls, and computed variable names, stop. That complexity is a signal that the logic wants to live in a real script that the Makefile calls, not in Make itself. A target whose recipe is one line invoking ./scripts/release.sh is perfectly honest. Make is the index; the script is the implementation.
I hold my Makefiles to a rough rule: any single recipe longer than about five lines is a script wearing a costume. Pull it out, give it a name, call it from the target. The Makefile stays a readable table of contents for the project, which is exactly what I wanted it to be.
None of this is clever, and that's the appeal. It's a forty-year-old tool that's on every machine I'll ever log into, it needs no install step, and it turns a project's tribal knowledge into something you can read. Reach for the fancy task runner when you've outgrown this. You'll be surprised how long that takes.