Make as Task Runner
Part 13 of 16 — GNU Make Mastery Series. Once you master building software with Make, you already have a powerful task runner for your entire project lifecycle: linting, formatting, documentation generation, database migrations, Docker operations, and more — all invocable with make <target>.
1
Build Systems Foundations
Why Make, compilation pipeline, basics
2
Targets, Prerequisites & Execution
Rule anatomy, PHONY, build workflows
3
Variables, Expansion & Scope
= vs :=, ?=, +=, CLI overrides
4
Automatic Variables & Pattern Rules
$@, $<, $^, $?, %.o: %.c patterns
5
Built-in Functions & Make Language
subst, wildcard, foreach, $(shell)
6
Conditionals & Configurable Builds
ifeq, ifdef, platform detection, flags
7
Automatic Dependency Generation
-M, -MM, -MD, .d files, header deps
8
Compilation Workflow & Libraries
Static/shared libs, ar, -fPIC, SONAME
9
Project Architecture & Multi-Directory
Recursive make, include, out-of-source
10
Cross-Compilation & Toolchains
Toolchain prefixes, sysroots, embedded
11
Parallel Builds & Performance
make -j, jobserver, race conditions
12
Testing, Coverage & Debug Tooling
Test targets, gcov/lcov, sanitizers
13
Make as Automation & DevOps Tool
Task runner, Docker, install, packaging
You Are Here
14
CI/CD Integration
Deterministic builds, GitHub Actions, cache
15
Advanced Make & Debugging
--debug, dynamic rules, evaluation traps
16
Ecosystem, Alternatives & Mastery
CMake, Ninja, Meson, Bazel, production
Self-Documenting Help Target
# Convention: annotate PHONY targets with ## comment on the same line.
# 'make help' parses those comments automatically.
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
.PHONY: build
build: ## Compile all source files
$(MAKE) -j$(NPROC) all
.PHONY: lint
lint: ## Run clang-tidy on all sources
clang-tidy $(SRCS) -- $(CFLAGS)
.PHONY: fmt
fmt: ## Auto-format source code with clang-format
clang-format -i $(SRCS) $(wildcard include/*.h)
Non-Build Tasks
.PHONY: docs
docs: ## Generate Doxygen HTML documentation
doxygen Doxyfile
@echo "Docs at: docs/html/index.html"
.PHONY: db-migrate
db-migrate: ## Run pending database migrations
psql -U $(DB_USER) -d $(DB_NAME) -f migrations/latest.sql
.PHONY: deploy
deploy: build ## Deploy artifact to staging server
rsync -avz --delete dist/ deploy@staging:/var/www/app/
Install & Uninstall
GNU coding standards define install, install-strip, uninstall, and DESTDIR for staged installs. Following these conventions makes your software compatible with package managers.
# ── GNU install conventions ──
PREFIX ?= /usr/local # root install path (overridable)
BINDIR := $(PREFIX)/bin # executables
LIBDIR := $(PREFIX)/lib # static/shared libraries
INCDIR := $(PREFIX)/include/$(PROJECT) # public headers
MANDIR := $(PREFIX)/share/man/man1 # man pages
INSTALL := install # coreutils install (preserves perms)
INSTALL_PROGRAM := $(INSTALL) -m 755 # executable permission bits
INSTALL_DATA := $(INSTALL) -m 644 # read-only data permission bits
INSTALL_DIR := $(INSTALL) -d # -d creates directories recursively
.PHONY: install
install: all ## Install to $(PREFIX) (use DESTDIR= for staged install)
$(INSTALL_DIR) $(DESTDIR)$(BINDIR) # DESTDIR= prefix for staged/packaging installs
$(INSTALL_DIR) $(DESTDIR)$(LIBDIR)
$(INSTALL_DIR) $(DESTDIR)$(INCDIR)
$(INSTALL_DIR) $(DESTDIR)$(MANDIR)
$(INSTALL_PROGRAM) build/$(TARGET) $(DESTDIR)$(BINDIR)/$(TARGET)
$(INSTALL_DATA) build/lib$(PROJECT).a $(DESTDIR)$(LIBDIR)/
$(INSTALL_DATA) include/*.h $(DESTDIR)$(INCDIR)/
$(INSTALL_DATA) man/$(TARGET).1 $(DESTDIR)$(MANDIR)/
.PHONY: install-strip
install-strip: install ## Install with debug symbols stripped
strip $(DESTDIR)$(BINDIR)/$(TARGET) # remove symbols → smaller binary
.PHONY: uninstall
uninstall: ## Remove installed files
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
rm -rf $(DESTDIR)$(INCDIR)
rm -f $(DESTDIR)$(LIBDIR)/lib$(PROJECT).a
rm -f $(DESTDIR)$(MANDIR)/$(TARGET).1
DESTDIR usage: make install DESTDIR=/tmp/staging installs to /tmp/staging/usr/local/bin/... without touching the live system. Package maintainers use this to build .deb or .rpm packages.
Docker Integration
Building Images
IMAGE_NAME := myapp
IMAGE_TAG ?= $(shell git describe --tags --always --dirty)
REGISTRY ?= registry.example.com
.PHONY: docker-build docker-push docker-run docker-clean
docker-build: ## Build Docker image (tag = git describe)
docker build \
--build-arg VERSION=$(IMAGE_TAG) \
--tag $(IMAGE_NAME):$(IMAGE_TAG) \
--tag $(IMAGE_NAME):latest \
.
docker-push: docker-build ## Push image to registry
docker tag $(IMAGE_NAME):$(IMAGE_TAG) $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
docker push $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
docker-run: docker-build ## Run the container locally
docker run --rm -it -p 8080:8080 $(IMAGE_NAME):latest
docker-clean: ## Remove local image
docker rmi -f $(IMAGE_NAME):latest $(IMAGE_NAME):$(IMAGE_TAG) 2>/dev/null || true
Docker Compose Targets
.PHONY: up down logs
up: ## Start all services with docker compose
docker compose up -d
down: ## Stop and remove containers
docker compose down
logs: ## Tail all service logs
docker compose logs -f --tail=100
.ONESHELL
By default, each line in a Make recipe runs in a separate shell. Variables set in one line don't survive to the next. .ONESHELL changes this behaviour so the entire recipe runs in a single shell instance.
# Without .ONESHELL — this FAILS because 'cd' is forgotten by next line:
deploy-old:
cd dist
tar czf release.tar.gz . # Still in the original directory!
# With .ONESHELL — all lines share one shell:
.ONESHELL:
deploy:
cd dist
tar czf ../release.tar.gz .
scp ../release.tar.gz deploy@prod:/releases/
# Example: database transactions need a single shell connection
.ONESHELL:
db-seed: ## Populate test database (atomic transaction)
psql -U $(DB_USER) -d $(DB_NAME) <<'SQL'
BEGIN;
INSERT INTO config VALUES ('version', '1.0');
INSERT INTO config VALUES ('env', 'test');
COMMIT;
SQL
Release Packaging
# Derive version from latest git tag, falling back to 0.0.0
VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)
DIST_NAME := $(PROJECT)-$(VERSION)
DIST_DIR := dist/$(DIST_NAME)
.PHONY: dist release
dist: ## Create source tarball in dist/
mkdir -p $(DIST_DIR)
git archive HEAD | tar -x -C $(DIST_DIR) # export tracked files at HEAD
tar czf dist/$(DIST_NAME).tar.gz -C dist $(DIST_NAME)
@echo "Source archive: dist/$(DIST_NAME).tar.gz"
release: dist ## Tag, build, and prepare binaries for release
git tag -a v$(VERSION) -m "Release v$(VERSION)" # annotated tag
$(MAKE) -j$(NPROC) all
mkdir -p dist/$(DIST_NAME)/bin
cp build/$(TARGET) dist/$(DIST_NAME)/bin/
tar czf dist/$(DIST_NAME)-linux-amd64.tar.gz -C dist $(DIST_NAME)/bin
@echo "Release artifacts in dist/"
Reproducible Builds
# Strip timestamps and non-deterministic metadata from gcc output:
# These -fprefix-map flags replace $(PWD) with '.' in debug info/macros
# so binaries built in different directories produce identical bytes.
CFLAGS += -ffile-prefix-map=$(PWD)=.
CFLAGS += -fdebug-prefix-map=$(PWD)=.
CFLAGS += -fmacro-prefix-map=$(PWD)=.
# Reproducible archives (same content = identical bytes):
# Use tar's --sort and --mtime options (GNU tar 1.28+)
dist:
tar czf dist.tar.gz \
--sort=name \ # alphabetical file ordering
--mtime='1970-01-01 00:00:00' \ # fixed timestamp (epoch)
--owner=0 --group=0 --numeric-owner \ # root:root, no usernames
dist/
Try It — Self-Documenting Help & Install
Create a project with help and staged install targets:
mkdir -p src
cat > src/main.c << 'EOF'
#include <stdio.h>
int main(void) { printf("myapp v1.0\n"); return 0; }
EOF
cat > Makefile << 'EOF'
CC := gcc
CFLAGS := -Wall -O2
TARGET := myapp
PREFIX ?= /usr/local
DESTDIR ?=
VERSION := 1.0.0
.PHONY: all install uninstall dist clean help info
all: $(TARGET) ## Build the application
$(TARGET): src/main.c
$(CC) $(CFLAGS) -o $@ $^
install: $(TARGET) ## Install to PREFIX (supports DESTDIR)
install -d $(DESTDIR)$(PREFIX)/bin
install -m 755 $(TARGET) $(DESTDIR)$(PREFIX)/bin/
@echo "Installed to $(DESTDIR)$(PREFIX)/bin/$(TARGET)"
uninstall: ## Remove installed binary
rm -f $(DESTDIR)$(PREFIX)/bin/$(TARGET)
dist: ## Create release tarball
mkdir -p dist
tar czf dist/$(TARGET)-$(VERSION).tar.gz src/ Makefile
@echo "Created dist/$(TARGET)-$(VERSION).tar.gz"
clean: ## Remove build artifacts
rm -f $(TARGET)
rm -rf dist/
info: ## Print variable values
@echo "CC = $(CC)"
@echo "CFLAGS = $(CFLAGS)"
@echo "TARGET = $(TARGET)"
@echo "PREFIX = $(PREFIX)"
@echo "DESTDIR = $(DESTDIR)"
@echo "VERSION = $(VERSION)"
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## ' Makefile | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
EOF
# Self-documenting help
make help
# Inspect variables
make info
# Build and stage install
make
make install DESTDIR=/tmp/staging
ls -la /tmp/staging/usr/local/bin/
/tmp/staging/usr/local/bin/myapp
# Create release tarball
make dist
tar tzf dist/myapp-1.0.0.tar.gz
make clean
Next in the Series
In Part 14: CI/CD Integration, we connect your Makefile directly to GitHub Actions and GitLab CI pipelines: define CI-friendly targets, enable build caching, produce deterministic artifacts, and manage parallel matrix jobs.
Continue the Series
Part 14: CI/CD Integration
Wire Makefiles into GitHub Actions and GitLab CI, handle deterministic artifact builds, and cache object files across pipeline runs.
Read Article
Part 12: Testing, Coverage & Debug Tooling
Add unit test targets, gcov/lcov HTML coverage reports, and ASan/UBSan/TSan sanitizer builds to your Makefile.
Read Article
Part 15: Advanced Make & Debugging
Master --debug=a, -p database dumps, $(warning), dynamic rule generation, and evaluation traps.
Read Article