Skip to main content
Settings
Search
Appearance
Theme Mode
About
Jekyll v3.10.0
Environment Production
Last Build
2026-06-27 16:00 UTC
Current Environment Production
Build Time Jun 27, 16:00
Jekyll v3.10.0
Build env (JEKYLL_ENV) production
Quick Links
Page Location
Page Info
Layout article
Collection hacks
Path _hacks/make-task-runner-command-menu.md
URL /hacks/make-task-runner-command-menu/
Date 2026-06-27
Theme Skin
SVG Backgrounds
Layer Opacity
0.6
0.04
0.08

Stop forgetting your own build commands: a Makefile as your project's command menu

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.