Make Cheatsheet

Phony and Special Targets

Use this Make reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

.PHONY, and why clean needs it

A phony target is a name for a recipe, not a file. Declaring it tells make to run the recipe unconditionally and to skip implicit-rule search.

.PHONY: all build test clean install

The failure without it, reproduced exactly:

touch clean
make clean
# make: 'clean' is up to date.

Any target that is not a real file needs .PHONY, and dist, docs, test, and check collide with real paths often enough that people hit this in the wild. What .PHONY buys you:

  • The recipe always runs, no timestamp comparison.
  • No implicit-rule search, which is a real speedup on large makefiles.
  • Make knows the target is not a file, so -t and -B behave sensibly.

Declare each one next to its rule so the list cannot rot:

.PHONY: test
test:
	go test ./...

Two things phony targets must not do: appear as a prerequisite of a real file target (the file rebuilds every single run), and be used as a pattern (.PHONY: %.x does nothing).

Special targets worth knowing

Special targets are directives dressed as rules. They take no recipe, and with no prerequisites most of them apply globally.

TargetEffect
.PHONYListed targets are not files
.DEFAULT_GOALVariable, not a target, sets what plain make builds
.DELETE_ON_ERRORDelete a target whose recipe failed
.SILENTDo not echo recipes, globally or for listed targets
.IGNOREIgnore recipe errors for listed targets
.PRECIOUSNever delete these on interrupt
.SECONDARYKeep intermediate files
.INTERMEDIATETreat these as intermediate
.NOTINTERMEDIATE4.4, opt out of intermediate deletion
.ONESHELLGNU only, one shell per recipe instead of per line
.NOTPARALLELRun this makefile serially even under -j
.EXPORT_ALL_VARIABLESExport every variable to recipes
.SUFFIXESWith no prerequisites, wipe the suffix list
.DEFAULTRecipe for any target with no rule
.POSIXPOSIX-conforming behavior, must be first
.SECONDEXPANSIONExpand prerequisite lists a second time

.DEFAULT_GOAL

Without it, the default goal is the first non-special target in the file, which makes the order of your rules load-bearing. Set it explicitly.

.DEFAULT_GOAL := help
make                                # builds .DEFAULT_GOAL
make -p -n | grep -m1 DEFAULT_GOAL  # confirm what make chose
.DEFAULT_GOAL := help

Reading $(.DEFAULT_GOAL) before any rule is parsed gives an empty string. $(MAKECMDGOALS) holds the goals actually named on the command line, and is empty when the default goal is used.

ifeq ($(MAKECMDGOALS),clean)
  # skip the expensive -include of dependency files
endif

.DELETE_ON_ERROR, the one everybody should set

A failed recipe leaves its half-written output on disk, and because that file now has a fresh timestamp the next make treats it as up to date and ships corrupt output.

out.txt:
	echo partial > $@
	false
echo partial > out.txt
false
make: *** [Makefile:3: out.txt] Error 1
# out.txt still exists, 8 bytes, and looks current

Add one line at the top of every makefile that writes files:

.DELETE_ON_ERROR:
make: *** [Makefile:4: out.txt] Error 1
make: *** Deleting file 'out.txt'

Make deletes a target on interrupt by default, but not on recipe failure, which is the exact opposite of what you want. .PRECIOUS opts a specific target back out.

.ONESHELL and the one-shell-per-line rule

Every recipe line normally gets its own shell, so cd and shell variables do not survive to the next line.

bad:
	cd /tmp
	pwd                  # prints the makefile directory, not /tmp

good:
	cd /tmp && pwd       # one shell, one line

.ONESHELL (GNU make 3.82+, no BSD or POSIX equivalent) passes the whole recipe to a single shell.

.ONESHELL:
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c

release:
	x=$$(git rev-parse --short HEAD)
	echo "building $$x"
	cd dist
	tar czf ../rel-$$x.tgz .

Two consequences to plan for:

- Line prefixes @, -, and + are only honored on the first line, so the whole block is echoed or silenced together. - Without -e in .SHELLFLAGS only the last command's exit status counts, so a failing middle line is ignored. Set .SHELLFLAGS := -eu -o pipefail -c whenever you enable .ONESHELL.

.SILENT, .IGNORE, and SHELL

.SILENT:                     # everything, equivalent to make -s
.SILENT: version             # just this target
.IGNORE: lint                # like prefixing every line with -
SHELL := /bin/bash           # default is /bin/sh, which is dash on Debian

Prefer the per-line @ and - prefixes over the global forms. A makefile that prints nothing is very hard to debug, and MAKEFLAGS += --silent at least leaves make --debug a way back in.

SHELL is not inherited from your environment. Make always uses /bin/sh unless the makefile says otherwise, which is why [[, arrays, and pipefail fail on Debian and Ubuntu where /bin/sh is dash.

A task-runner Makefile with a self-documenting help

Every recipe line below begins with a literal tab.

SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
MAKEFLAGS += --no-builtin-rules --warn-undefined-variables

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

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

.PHONY: install
install: ## Install dependencies
	npm ci

.PHONY: test
test: install ## Run the test suite
	npm test -- --run

.PHONY: image
image: ## Build the container image
	docker build -t $(IMAGE):$(TAG) .

.PHONY: clean
clean: ## Remove build output
	$(RM) -r dist coverage

$(MAKEFILE_LIST) makes the help target work unchanged when you split rules across *.mk includes. The ## marker after the colon is the convention every variation of this trick uses.