KeyboardWarrior.co.ukclient-side utility arcade

Automation

Makefiles: targets, variables, and recipes

How GNU Make decides what to rebuild: targets, prerequisites, variables, and the tab, phony, and timestamp mistakes that leave the stale file in place.

9 min read

GNU Make reads a file of rules and runs the recipes whose targets are out of date. It does not remember the text of a recipe. A target is rebuilt when it is missing, or when a prerequisite is strictly newer than it. Equal timestamps count as up to date. Change the command and leave the files alone, and the next make does nothing.

The make on Debian, Ubuntu, Fedora, and RHEL is GNU Make. make --version prints GNU Make and a version. The examples below were checked on GNU Make 4.3. macOS ships BSD make under the name make. BSD make shares targets and recipes, and it disagrees on assignment, on $(wildcard), and on several special targets. On a Mac these examples want GNU Make, often installed as gmake.

Make is not the shell. Each recipe line is handed to /bin/sh -c. On Debian and Ubuntu that is dash, not Bash, so arrays and [[ fail even when they work interactively. Quoting, once that shell starts, is covered in Shell quoting, pipelines, and exit codes. Make does not watch the clock either. That is Cron jobs. A crontab entry that runs make is how a schedule uses a makefile.

Rules and the default target

A rule names a target, lists prerequisites, and gives the recipe. Recipe lines begin with a tab. Spaces in that column are not an indent Make understands.

Makefile
dist/bundle.js: src/a.js src/b.js
	cat src/a.js src/b.js > dist/bundle.js

Make runs that recipe when dist/bundle.js does not exist, or when either source is strictly newer than the bundle. touch -r src/a.js dist/bundle.js copies the source timestamp onto the bundle, and the next make has nothing to do until the source is touched again.

With no goal on the command line, Make builds the first target whose name does not start with a dot. Assignments, comments, and special targets such as .PHONY do not count. Put the real build first, or call it all and list it first.

Make looks for GNUmakefile, then makefile, then Makefile, and uses the first it finds. A forgotten GNUmakefile silently wins over the Makefile you just edited. make -f build.mk names the file and skips that search.

A file that builds something

all below is phony, so a stray file named all cannot mark the project finished. dist is an order-only prerequisite, written after |. Make creates the directory before the files that need it, and a later change to the directory's timestamp does not rebuild those files. $(wildcard) returns names in sorted order. The substitution rewrites each page path into the output path, so all knows the text files before they exist.

Makefile
.DELETE_ON_ERROR:
.PHONY: all clean

SRC := $(wildcard src/*.js)
PAGES := $(wildcard pages/*.md)
OUT := $(PAGES:pages/%.md=dist/%.txt)

all: dist/bundle.js $(OUT)

dist/bundle.js: $(SRC) | dist
	cat $(SRC) > $@

dist/%.txt: pages/%.md | dist
	sed 's/^/> /' $< > $@

dist:
	mkdir -p $@

clean:
	rm -rf dist

$@ is the target of the rule that is running. $< is the first prerequisite. In the pattern, dist/intro.txt is built from pages/intro.md, and the stem that matched % is intro.

With src/a.js, src/b.js, pages/intro.md containing Hello, and pages/body.md containing Body, the first make prints:

text
mkdir -p dist
cat src/a.js src/b.js > dist/bundle.js
sed 's/^/> /' pages/body.md > dist/body.txt
sed 's/^/> /' pages/intro.md > dist/intro.txt

Make expanded $(SRC) before the shell started, which is why the echoed line names the two files rather than the wildcard. The bundle is first because it is the first prerequisite of all. In a serial run the pages follow in sorted order, body then intro. dist/body.txt contains > Body. A second make prints make: Nothing to be done for 'all'. Touch src/a.js and only the bundle recipe runs. make clean removes dist, because clean is phony and has a recipe.

Variables

Make expands $ before the shell sees the line. One dollar sign is a Make variable. A dollar sign for the shell is written $$.

Form When the right-hand side is expanded
name = value Each time name is used. $(shell ...) runs again at every use.
name := value Once, while Make reads the line. ::= is the POSIX spelling.
name ?= value Only if name is unset, including unset in the environment.
name += value Appends, with a space. If the name was unset, this behaves as =.
Makefile
counted = $(shell echo rec >> log; echo R)
frozen := $(shell echo imm >> log; echo I)

show:
	echo $(counted)
	echo $(counted)
	echo $(frozen)

Reading that file appends imm to log once. Running show appends rec twice, and the values are R, R, and I. := freezes the list while Make reads the file. = expands when a later recipe is about to run, so it can see files that recipe's prerequisites just created.

?= steps aside for anything already set. The environment is imported before the makefile is read, so it counts.

Makefile
prefix ?= /usr/local

show:
	echo $(prefix)

make show runs echo /usr/local. Both prefix=/opt make show and make show prefix=/opt run echo /opt.

A plain := does not step aside for the environment. The command line still wins, and -e hands that same power to the environment.

Makefile
prefix := /usr/local

show:
	echo $(prefix)

prefix=/opt make show still runs echo /usr/local. make show prefix=/opt runs echo /opt. prefix=/opt make -e show runs echo /opt. override forces the file over the command line. Callers rarely want that.

In a recipe, $$ becomes one $ for the shell. A single $ would make Make expand $((n+1)) itself.

Makefile
SRC := src/a.js src/b.js

count:
	n=0; \
	for f in $(SRC); do n=$$((n + 1)); done; \
	echo $$n

The backslashes join the physical lines into one recipe line, so there is one shell and n survives. The value printed is 2.

One shell per line

Make starts a new /bin/sh for every recipe line. It does not read .bashrc. A variable set on one line is gone on the next, and cd does not stick. The lines below succeed, and file.txt lands where make started, not in out.

Makefile
build:
	mkdir -p out
	cd out
	echo hi > file.txt

Join the steps with &&. A normal recipe line does not enable the shell's -e. && is what stops the line when cd fails, and what makes the redirect run in out.

Makefile
build:
	mkdir -p out && cd out && echo hi > file.txt

.ONESHELL runs the whole recipe in one shell. Make then sees only the last line's status. false followed by echo still prints still and Make exits zero. Turn on the shell's own check if you use it:

Makefile
.ONESHELL:
.SHELLFLAGS = -ec

build:
	false
	echo still

-e stops that shell at false, so still is not printed, and Make reports Error 1. -c is how the script is passed. Replacing .SHELLFLAGS has to keep it. For two or three commands, && is the smaller change.

make -j4 runs up to four recipes at once. Every file a recipe reads has to be a prerequisite, or one job reads a file another has not finished. A plain make hides that race.

@echo hi prints hi and not the command. A leading - ignores a failure: Make prints Error 1 (ignored) and continues. Fine on a clean-up line, a bad habit on a compiler.

Phony targets and directories

A target that names an action needs .PHONY. Otherwise an existing file of that name is the finished product.

Makefile
clean:
	rm -rf dist

If a file called clean exists and the rule has no prerequisites, make clean prints make: 'clean' is up to date. and leaves dist alone. all and test do the same. .PHONY: all clean marks them always out of date. A phony target with a recipe runs it every time. One that only lists prerequisites, as all does above, walks those and stops. That is why a second make can report nothing to do.

A directory's timestamp changes when a file inside it is created, deleted, or renamed. As a normal prerequisite, that rebuilds the bundle whenever anything lands in dist:

Makefile
dist/bundle.js: src/a.js dist
	cat $< > $@

After the bundle is current, echo extra > dist/other.txt makes dist newer, and the next make runs cat again. Order-only prerequisites, to the right of |, are built when missing and ignored in the timestamp check.

Makefile
dist/bundle.js: src/a.js | dist
	cat $< > $@

dist:
	mkdir -p $@

dist/other.txt no longer rebuilds the bundle. $^ leaves order-only names out, so cat $^ will not try to concatenate the directory. $| is that list on its own.

Patterns and automatic variables

% is the stem, the part that differed.

Makefile
dist/%.txt: pages/%.md | dist
	mkdir -p $(dir $@)
	sed 's/^/> /' $< > $@

make dist/intro.txt sets the stem to intro, $< to pages/intro.md, and $(dir $@) to dist/ including the slash.

Variable In the recipe
$@ The target
$< The first prerequisite
$^ Normal prerequisites, duplicates removed
$? Prerequisites strictly newer than the target
$* The stem, in a pattern rule
`$ `

$? is the list for a copy or archive rule that should mention only what changed. In an explicit rule with no pattern, $* is empty.

A * in a recipe is the shell's. Make echoes *.js unchanged, and the shell globs it. A * in a prerequisite is expanded by Make. An assignment is not. Use $(wildcard src/*.js).

A missing file with no rule stops before any empty output is written:

text
make: *** No rule to make target 'missing.txt', needed by 'out.txt'.  Stop.

Failures that leave the old file

A recipe line that does not start with a tab is not a recipe. Make stops, and the error contains missing separator and the line number. cat -A Makefile shows a tab as ^I and the end of a line as $. If the recipe line does not start with ^I, that is the character it wanted. Spaces copied from a page that ate the tab are the usual reason.

GNU Make 4.3 can use another prefix. It applies from the assignment downward. The recipe line here begins with >, not a tab.

Makefile
.RECIPEPREFIX := >

all:
> echo prefixed

Change the prefix only in a file you control. A tab is what every other makefile is using.

A recipe that fails after creating its target leaves the file behind. The next make sees a target that is not older than its prerequisites.

Makefile
bad.txt:
	echo partial > $@
	false

The first run exits non-zero and leaves bad.txt containing partial. The second prints make: 'bad.txt' is up to date. .DELETE_ON_ERROR, anywhere in the file, makes Make delete a target whose recipe changed it and then failed. The trace includes Deleting file 'bad.txt'. Files from an earlier successful run stay.

Make does not notice that a recipe changed. This target was built with cp. Adding the echo does not rebuild, because in.txt is not newer than out.txt.

Makefile
out.txt: in.txt
	cp $< $@
	echo extra >> $@

make -B treats targets as out of date, so extra is appended. Touching in.txt rebuilds this target alone, and a later plain make still understands why. Saving the makefile does not.

Checking the decision

make -n prints recipes and does not run them. Before the first build it lists the mkdir, the cat, and both sed lines, and dist is still absent. An up-to-date tree prints Nothing to be done. make -d prints every comparison. An unexpected prerequisite in that trace is why a target rebuilds on every run. A missing one is why it never does.

  • Spaces instead of a tab. The error contains missing separator. cat -A shows a tab as ^I.
  • A file named clean, all, or test. Mark the action .PHONY.
  • A directory as a normal prerequisite. It goes stale when a file inside it changes. Put the directory after |.
  • A new shell on every line. cd and shell variables stop there. Use &&, or .ONESHELL with .SHELLFLAGS = -ec.
  • One $ means Make. A shell variable needs $$. Make's $@ is the target.
  • A failed recipe that already created its target. The next run looks finished. .DELETE_ON_ERROR removes the partial file.
  • A changed recipe and untouched files. make -B forces the recipes.
  • GNUmakefile is preferred to Makefile, including one you forgot was there.
  • The environment does not override := unless you pass -e. It already counts as set, so ?= keeps it. The command line overrides both.
  • $(wildcard) assigned with :=, read too early to see files a recipe has not created.
  • make -j across a missing edge. A serial run will not warn you.

The target is a file, or a phony name for an action. The recipe runs when that target is missing or older than a prerequisite, and only then.