Advanced Techniques for Serious Automation

Lesson 7: Advanced Techniques for Serious Automation

You now know enough Make to build real projects. This lesson collects the power tools: order-only prerequisites, grouped targets, shell control, parallelism, conditionals, and Make's function library โ€” the features that separate "Makefile author" from "Makefile wizard."

Order-Only Prerequisites: "Ensure Exists, Don't Trigger Rebuild"

Normally, a newer prerequisite triggers a rebuild โ€” always. But some prerequisites shouldn't: a directory you need to exist shouldn't cause the target to rebuild every time. Put them after |:

bin/app: $(OBJS) | bin
	$(CC) -o $@ $^

bin:
	mkdir -p bin

bin is an order-only prerequisite: Make ensures it exists (and builds it if missing), but its timestamp never triggers a rebuild of bin/app. Perfect for output directories โ€” without it, your binary would relink on every make.

Grouped Targets: One Command, Many Outputs

Some tools emit several files from a single invocation (a parser and its header; a report and its chart). GNU Make 4.3+ lets a recipe claim all targets at once with &::

parser.c parser.h &: grammar.y
	yacc -d grammar.y    # produces both files
	mv y.tab.c parser.c
	mv y.tab.h parser.h

With &:, if either output is stale, the recipe runs once and both files are considered updated. (Without it, Make may run the recipe twice or get confused โ€” a classic multi-output trap.)

Controlling the Shell: .ONESHELL and .SHELLFLAGS

By default, each recipe line runs in its own shell. That's why cd dir on one line doesn't affect the next โ€” and why you'll see cd dir && cmd crammed on one line. Two remedies:

.ONESHELL:              # run the whole recipe in ONE shell
.SHELLFLAGS = -e -c     # stop on the first failing command

target:
	cd build
	./configure
	make -C . install

.ONESHELL is clean but changes semantics โ€” with it, a failing line no longer aborts the recipe by itself, so pair it with .SHELLFLAGS = -e -c to get fail-fast behavior. For most recipes, the classic cd dir && cmd is simpler.

Parallel Builds: -j, the Jobserver, and -l

Make can run independent recipes concurrently โ€” the graph guarantees only truly independent work is parallelized:

make -j8          # up to 8 jobs at once
make -j           # unlimited (risky; usually omit)
make -l 4         # only start jobs if load average < 4

GNU Make shares a jobserver with sub-makes, so nested invocations don't oversubscribe your CPU. Typical use: make -j$(nproc) on Linux or make -j$(sysctl -n hw.ncpu) on macOS. Your recipe must be safe to run in parallel: no two targets writing the same file, and shared intermediates should be explicit prerequisites.

Parallel safety: if two recipes both mkdir -p bin, that's fine. If two both write generated.h, you have a race. Rules of thumb: put shared outputs in the dependency graph, and use order-only prerequisites for directories.

Conditionals: ifeq / ifneq / ifdef

Make evaluates conditionals at parse time โ€” before any recipe runs:

ifeq ($(OS),Windows_NT)
    EXE := .exe
else
    EXE :=
endif

ifdef DEBUG
    CFLAGS += -g -O0
else
    CFLAGS += -O2
endif

Conditionals are how one Makefile serves many environments. Note the syntax quirks: ifeq needs the comma inside the parens, and the else/endif must start at column 0.

The Function Library: $(shell), $(foreach), $(call), and Friends

Make's real power lies in its functions โ€” text processing that runs at parse time:

FunctionWhat it does
$(shell cmd)Run a shell command, capture stdout (e.g. $(shell git rev-parse HEAD)).
$(wildcard *.c)Expand filesystem wildcards.
$(foreach var,list,body)Loop over words, building output.
$(call name,args...)Call a user-defined function.
$(filter pat...,text)Keep words matching a pattern.
$(patsubst a%,b%,text)Pattern substitution on words.
$(notdir path) / $(dir path)Strip to filename / keep directory.
$(addprefix pre,list)Prefix every word (e.g. obj/).

A taste โ€” user-defined functions and $(foreach):

# User-defined function: compile one file
define COMPILE
$(1).o: $(1).c
	$(CC) $(CFLAGS) -c $< -o $@
endef

# Generate a rule for each source (note $ escapes in define)
$(foreach src,$(SOURCES),$(eval $(call COMPILE,$(basename $(src)))))

# Capture version at parse time
VERSION := $(shell git describe --tags --always)

Yes, that's the deep end โ€” $(eval) writes rules at parse time, and $ defers expansion inside define blocks. You rarely need this, but when you do, nothing else can do it.

Self-Documenting Makefiles: The help Target

Teams love a make help. The trick: put a ## comment next to each target, then use $(shell grep ...) to render them:

.PHONY: help
help:                 ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
	  | awk -F':.*?## ' '{printf "  %-14s %s\n", $1, $2}'

build:                ## Compile the project
	$(CC) -o app $(OBJS)
test:                 ## Run the test suite
	./app --test

Running make help prints a tidy, always-current command list โ€” the poor man's CLI, and genuinely useful in repos where "how do I build this?" is a FAQ.

Pro tip: this pattern turns Make into a lightweight task runner (build/test/lint/clean/deploy targets) for projects in any language โ€” one of the most common uses of Make in modern repos. Lesson 8 discusses when that's appropriate and when it isn't.

๐Ÿง  Knowledge Check

1. What does the | in bin/app: $(OBJS) | bin do?

2. Why is .ONESHELL usually paired with .SHELLFLAGS = -e -c?

3. What does $(shell git rev-parse HEAD) evaluate to?

Further Reading