Every project grows a little folklore: the exact command to run the tests, the one to serve it locally, the incantation that deploys it. It lives in three places — your shell history, a ## Development section of the README nobody updated, and the head of the one person who set it up.
make was built in 1976 to compile C. But strip away the C and what’s left is the best command menu your project will ever have: you type make test, it runs the test command; you type make help, it lists every command it knows. No framework, no dependency, no package.json scripts block. One file named Makefile, already understood by a program that’s on basically every machine you’ll ever SSH into.
We’re going to use it as a task runner and nothing else.
The menu
Drop this in the root of a project as Makefile:
.PHONY: help install test serve clean
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
install: ## Install dependencies
@echo "==> bundle install"
test: ## Run the test suite
@echo "==> running tests"
serve: ## Serve the site locally on :4000
@echo "==> jekyll serve"
clean: ## Remove build artifacts
@echo "==> rm -rf _site"
Each block is a target (the word before the colon) and a recipe (the indented lines under it). make test runs the test recipe. The @echo lines are stand-ins — swap in your project’s real commands. The leading @ tells make not to echo the command before running it, so the output stays clean.
Replace the echoes with whatever your project actually needs, and you’ve turned scattered folklore into a single file with a verb for each chore.
You’ll know it worked
Run make with no arguments. It runs the first target in the file — which is why help goes at the top:
$ make
help Show this help
install Install dependencies
test Run the test suite
serve Serve the site locally on :4000
clean Remove build artifacts
That is real captured output from the Makefile above. The menu builds itself: that grep/awk line in the help recipe scans the file for every target: ## comment pair and prints it. Add a new target with a ## description, and it shows up in make help automatically — the documentation can’t drift from the commands because it is the commands.
Run one by name and it does the one thing:
$ make test
==> running tests
Two things you get for free the moment this file exists: make then Tab completes target names in bash and zsh, and anyone who clones the repo can type make help instead of reading your mind.
The part where it broke (twice)
make has two failure modes that send people running back to shell scripts. Both are worth meeting on purpose, because both look like make being broken when it’s actually being literal.
1. A file named like your target silently wins
make was built to turn source files into build artifacts, so a target is, by default, a filename it’s trying to create. If a file with that name already exists and looks up to date, make declares victory and runs nothing.
Watch it refuse to run test because a file called test happens to exist:
$ ls
Makefile test
$ make test
make: 'test' is up to date.
Nothing ran. No error. make saw a file named test, decided the test target was already “built,” and stopped. On a project with a test/ directory this bites immediately and baffles everyone.
The fix is the .PHONY line at the top — it declares which targets are commands, not files, so make always runs them:
$ make test
==> running tests
List every command target after .PHONY:. It’s the one piece of boilerplate this pattern actually needs, and skipping it is the single most common way a task-runner Makefile mysteriously does nothing.
2. Recipes must be indented with a real tab
This is the one that costs an afternoon. Recipe lines have to start with a tab character, not spaces. Your editor, trying to be helpful, may have replaced that tab with four spaces — and make will not forgive it.
Here’s a recipe indented with spaces (shown via cat -A, where $ marks line ends):
$ cat -A Makefile
build:$
echo hello$
$ make build
Makefile:2: *** missing separator. Stop.
missing separator is make’s famously unhelpful way of saying “that wasn’t a tab.” The same file with a real tab (cat -A shows it as ^I) works:
$ cat -A Makefile
build:$
^Iecho hello$
$ make build
echo hello
hello
Both outputs are real. The defense: tell your editor to keep literal tabs in Makefile. In VS Code, add to settings.json:
"[makefile]": { "editor.insertSpaces": false }
Or in an .editorconfig that travels with the repo:
[Makefile]
indent_style = tab
When in doubt, cat -A Makefile and look for ^I at the start of every recipe line. Space means it’s broken.
The honest accounting
This does not make anything faster to run. make test and the command it wraps take exactly the same time. What it saves is the lookup: the trip to the README, the scroll through history, the Slack message asking how to start the dev server.
The real payoff is that the menu is discoverable and self-documenting. A new contributor types make help and sees the whole verb list; you add a command with a ## comment and it documents itself; the commands stop living in one person’s memory. That’s the entire pitch — not a faster build, a project that can explain itself.
Put help first, list your commands after .PHONY, indent with tabs. Then type make help and read your own project back to yourself.