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 lines
help: ## 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 targets
make -j4 diagrams-d2 diagrams-dot diagrams-mmd diagrams-puml
Print a variable — inspect what Make resolved
make -p | grep -A1 '^TODAY'

See Also

  • Taskfile — YAML alternative to Make

  • Scripts — bash scripting patterns