There is no C in most of what I write these days. There is Go, a pile of Python, some shell, the occasional bit of Rust. And yet nearly every repo I own has a Makefile at the top, and I'm increasingly convinced that's the right call.
Make has a reputation problem. People hear "Makefile" and picture forty lines of CFLAGS and pattern rules they don't understand, recursive invocations, the whole "Recursive Make Considered Harmful" baggage. That's not what I'm using it for. I'm using it as a task runner that happens to be installed on every machine I will ever touch.
The pitch
Every project accumulates a set of incantations. Run the tests with this flag. Build the binary for these three platforms. Regenerate the protobufs. Lint, but only the bits that aren't vendored. Spin up the docker-compose stack and wait for it to be healthy. These live in someone's shell history, or a wiki page that's three months stale, or nowhere.
A Makefile is where they go to be remembered. The difference between a Makefile and a scripts/ folder full of bash is that make gives you a verb for free, dependencies between tasks, and tab-completion that everyone already has.
.PHONY: build test lint fmt clean
build:
go build -o bin/app ./cmd/app
test:
go test -race -cover ./...
lint:
golint ./... && go vet ./...
fmt:
gofmt -w .
clean:
rm -rf bin/
That's it. New person clones the repo, runs make test, and it works. No "first you need to install X and set Y". If they do need X, the target tells them, or better, depends on a target that installs it.
The bits that bite
Make has sharp edges and you should know about two of them before you commit.
The first is .PHONY. Make thinks targets are files. If you have a target called test and a directory called test, Make will look at the directory, decide it's up to date, and do nothing. Declaring it phony tells Make "this isn't a file, always run it". Forget this and you'll get the maddening "make: 'test' is up to date" while nothing happens.
The second is tabs. Recipes must be indented with a literal tab, not spaces. Your editor will betray you here at least once. If you get *** missing separator, that's what it is, every time.
I also lean on a couple of niceties. A help target that greps the file for documented targets, so make with no arguments prints a menu. Variables with ?= so the environment can override them: GOOS ?= linux lets me cross-compile by setting GOOS=darwin make build without editing anything.
When not to
If your tasks genuinely have complex logic, branching, error handling, loops over things, don't cram that into Make. Make is bad at logic and the syntax for it is genuinely unpleasant. Put the logic in a script and call the script from a one-line target. Make is the index, the script is the chapter.
And if your team has standardised on just or task or an npm scripts block, use that. The point isn't Make specifically. The point is having one obvious, discoverable, zero-dependency place where "how do I build this thing" is written down as code that runs, not prose that rots. Make has been on every Unix box since before I was writing software, and it'll be there long after whatever's trendy this year. That counts for a lot.