GNU Make
Dependency-driven build automation. Targets, prerequisites, pattern rules, and self-documenting Makefiles.
Fundamentals
Basic target with prerequisite — the dependency graph is Make’s core strength
build: src/main.c src/util.c
gcc -o build/app src/main.c src/util.c
clean:
rm -rf build/
Make only rebuilds build if src/main.c or src/util.c are newer than the target. This is why Make exists — dependency-driven execution, not just a script runner.
Phony targets — declare targets that are not files
.PHONY: all clean install test serve help
all: build
Without .PHONY, if a file named clean exists, make clean silently does nothing. Always declare non-file targets.
Variables — define once, reference everywhere
SHELL := /bin/bash
CC := gcc
CFLAGS := -Wall -Wextra -O2
BUILD_DIR := build
# Lazy (=) vs immediate (:=)
# = re-evaluates every reference, := evaluates once at assignment
TODAY := $(shell date +%Y-%m-%d)
Pattern Rules and Automatic Variables
Pattern rule — DRY compilation for all
.c files$(BUILD_DIR)/%.o: src/%.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) -c $< -o $@
$< is the first prerequisite (the .c file). $@ is the target (the .o file). $^ is all prerequisites.
Automatic variables reference
$@ — target name
$< — first prerequisite
$^ — all prerequisites (deduplicated)
$? — prerequisites newer than target
$* — stem matched by % in pattern rule
Shell function — embed command output in a variable
GIT_HASH := $(shell git rev-parse --short HEAD)
FILE_COUNT := $(shell find src -name '*.py' | wc -l)
info:
@echo "Commit: $(GIT_HASH), Files: $(FILE_COUNT)"
Conditional Logic
Conditional — different behavior per environment
ENV ?= development
ifeq ($(ENV),production)
OPTS := --minify --no-sourcemap
else
OPTS := --sourcemap
endif
build:
npx esbuild $(OPTS) src/index.js
?= sets only if not already defined. Override from CLI: make build ENV=production.
Check for required tools — fail fast with clear message
check-deps:
@command -v node >/dev/null 2>&1 || { echo "node required"; exit 1; }
@command -v python3 >/dev/null 2>&1 || { echo "python3 required"; exit 1; }
@echo "All dependencies present"
Real-World Patterns
Antora documentation build — the domus-captures pattern
PLAYBOOK := antora-playbook.yml
BUILD_DIR := build/site
build: kroki
@npx antora $(PLAYBOOK)
serve: build
@lsof -ti:8000 | xargs -r kill -9 2>/dev/null || true
@cd $(BUILD_DIR) && python3 -m http.server 8000
clean:
@rm -rf build .cache
Multi-format export — PDF, HTML, DOCX from AsciiDoc
FILE ?= docs/report.adoc
OUTPUT := build/export
pdf: $(OUTPUT)
asciidoctor-pdf -o $(OUTPUT)/$(basename $(FILE) .adoc).pdf $(FILE)
html: $(OUTPUT)
asciidoctor -o $(OUTPUT)/$(basename $(FILE) .adoc).html $(FILE)
export: pdf html
Colored output — ANSI codes for readable feedback
GREEN := \033[0;32m
RED := \033[0;31m
CYAN := \033[0;36m
NC := \033[0m
install:
@printf "$(CYAN)Installing...$(NC)\n"
@npm install
@printf "$(GREEN)[OK]$(NC) Done\n"
Self-documenting help — parse
## comments from target lineshelp: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
build: ## Build the project
@echo "building..."
test: ## Run tests
@pytest
Run make help and it extracts ## comments into formatted output. Every Makefile should have this.
Debugging
Dry run — see what would execute without running it
make -n build
Parallel execution —
-j for independent targetsmake -j4 diagrams-d2 diagrams-dot diagrams-mmd diagrams-puml
Print a variable — inspect what Make resolved
make -p | grep -A1 '^TODAY'