Makefile
Language Overview¶
Makefiles are used with the make utility to automate build processes, run tests, and execute common tasks. This
guide covers Makefile best practices for creating maintainable, portable, and efficient build automation.
Key Characteristics¶
- File Name:
Makefileormakefile(preferMakefile) - Syntax: Tab-indented commands, target-based execution
- Primary Use: Build automation, task execution, dependency management
- Key Concepts: Targets, prerequisites, recipes, variables
Quick Reference¶
| Category | Convention | Example | Notes |
|---|---|---|---|
| Syntax | |||
| Indentation | TAB character | \tcommand |
Must use tabs for recipes, not spaces |
| Target Names | lowercase or kebab-case |
build, clean, test-unit |
Descriptive target names |
| Variables | UPPER_CASE |
CC, CFLAGS, BUILD_DIR |
Uppercase for variables |
| Phony Targets | .PHONY declaration |
.PHONY: clean test |
Non-file targets |
| Structure | |||
| Target | target: prerequisites |
build: compile link |
Target depends on prerequisites |
| Recipe | Tab-indented commands | \t@echo "Building..." |
Commands to execute |
| Variables | VAR = value |
CC = gcc |
Simple assignment |
| Variables | |||
| Simple | = |
CC = gcc |
Recursive expansion |
| Immediate | := |
BUILD_DIR := ./build |
Immediate expansion |
| Conditional | ?= |
CC ?= gcc |
Set if not already set |
| Append | += |
CFLAGS += -Wall |
Append to variable |
| Special Targets | |||
.PHONY |
Non-file targets | .PHONY: clean all test |
Prevent file conflicts |
.DEFAULT_GOAL |
Default target | .DEFAULT_GOAL := build |
Run when no target specified |
| Automatic Variables | |||
$@ |
Target name | $@ |
Current target |
$< |
First prerequisite | $< |
First dependency |
$^ |
All prerequisites | $^ |
All dependencies |
| Best Practices | |||
| Silent Commands | @ prefix |
@echo "Building..." |
Suppress command echo |
| Error Handling | - prefix |
-rm -rf build/ |
Ignore errors |
| Phony Targets | Always declare | .PHONY: clean test |
Avoid file name conflicts |
| Help Target | Include help | help: |
Document available targets |
Basic Structure¶
Simple Makefile¶
.PHONY: help clean build test
help:
@echo "Available targets:"
@echo " build - Build the application"
@echo " test - Run tests"
@echo " clean - Clean build artifacts"
build:
go build -o bin/app main.go
test:
go test ./...
clean:
rm -rf bin/
Targets and Prerequisites¶
Basic Target¶
## Target: what to build
## Prerequisites: dependencies
## Recipe: commands to execute (MUST be indented with TAB)
target: prerequisite1 prerequisite2
command1
command2
Target with Prerequisites¶
.PHONY: all build test
all: build test
build: compile
@echo "Build complete"
compile:
gcc -o myapp main.c
test: build
./myapp --test
.PHONY Targets¶
Always declare targets that don't create files as .PHONY:
.PHONY: clean test run install help
clean:
rm -rf build/ dist/
test:
pytest tests/
run:
python main.py
install:
pip install -r requirements.txt
help:
@echo "Available targets: clean, test, run, install, help"
Variables¶
Define Variables¶
## Simple variable
CC = gcc
CFLAGS = -Wall -Wextra -O2
## Recursive variable (evaluated when used)
SRC_DIR = src
OBJ_DIR = $(SRC_DIR)/obj
## Simply expanded variable (evaluated immediately)
BUILD_TIME := $(shell date +%Y%m%d-%H%M%S)
## Conditional variable
DEBUG ?= 0
ifeq ($(DEBUG),1)
CFLAGS += -g
endif
Use Variables¶
CC = gcc
CFLAGS = -Wall -Wextra
SOURCES = main.c utils.c
build:
$(CC) $(CFLAGS) $(SOURCES) -o app
Common Variables¶
## Compiler and tools
CC = gcc
CXX = g++
LD = ld
AR = ar
## Directories
SRC_DIR = src
BUILD_DIR = build
BIN_DIR = bin
## Flags
CFLAGS = -Wall -Wextra -O2
LDFLAGS = -L/usr/local/lib
INCLUDES = -I/usr/local/include
## Files
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(BUILD_DIR)/%.o)
TARGET = $(BIN_DIR)/app
Pattern Rules¶
Basic Pattern Rule¶
## Compile .c files to .o files
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
## Automatic variables:
## $@ - target name
## $< - first prerequisite
## $^ - all prerequisites
## $* - stem (matched by %)
Advanced Pattern Rules¶
SRC_DIR = src
BUILD_DIR = build
## Pattern rule with directory paths
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) -c $< -o $@
## Multiple targets
%.o %.d: %.c
$(CC) $(CFLAGS) -c $< -o $@
$(CC) -MM $(CFLAGS) $< > $*.d
Common Patterns¶
Node.js / TypeScript Project¶
.PHONY: help install build test lint clean dev
help:
@echo "Available targets:"
@echo " install - Install dependencies"
@echo " build - Build the application"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " clean - Clean build artifacts"
@echo " dev - Start development server"
install:
npm ci
build: install
npm run build
test: install
npm test
lint:
npm run lint
clean:
rm -rf node_modules dist build
dev: install
npm run dev
Python Project¶
.PHONY: help install test lint format clean venv
PYTHON = python3
VENV = venv
VENV_BIN = $(VENV)/bin
help:
@echo "Available targets:"
@echo " venv - Create virtual environment"
@echo " install - Install dependencies"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " format - Format code"
@echo " clean - Clean artifacts"
venv:
$(PYTHON) -m venv $(VENV)
install: venv
$(VENV_BIN)/pip install -r requirements.txt
$(VENV_BIN)/pip install -r requirements-dev.txt
test: install
$(VENV_BIN)/pytest tests/
lint: install
$(VENV_BIN)/flake8 src/ tests/
$(VENV_BIN)/mypy src/
format: install
$(VENV_BIN)/black src/ tests/
clean:
rm -rf $(VENV) .pytest_cache __pycache__
find . -type f -name '*.pyc' -delete
find . -type d -name '__pycache__' -delete
Go Project¶
.PHONY: help build test lint clean run
BINARY_NAME = myapp
GO = go
GOFLAGS = -v
LDFLAGS = -ldflags="-s -w"
help:
@echo "Available targets:"
@echo " build - Build the application"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " clean - Clean build artifacts"
@echo " run - Run the application"
build:
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_NAME) .
test:
$(GO) test $(GOFLAGS) ./...
lint:
golangci-lint run
clean:
rm -f $(BINARY_NAME)
$(GO) clean
run: build
./$(BINARY_NAME)
Docker Project¶
.PHONY: help build push run stop clean
IMAGE_NAME = myapp
IMAGE_TAG = latest
REGISTRY = docker.io
FULL_IMAGE = $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
help:
@echo "Available targets:"
@echo " build - Build Docker image"
@echo " push - Push image to registry"
@echo " run - Run container"
@echo " stop - Stop container"
@echo " clean - Remove image"
build:
docker build -t $(FULL_IMAGE) .
push: build
docker push $(FULL_IMAGE)
run:
docker run -d -p 8080:8080 --name $(IMAGE_NAME) $(FULL_IMAGE)
stop:
docker stop $(IMAGE_NAME)
docker rm $(IMAGE_NAME)
clean:
docker rmi $(FULL_IMAGE)
Conditional Logic¶
If Statements¶
DEBUG ?= 0
ifeq ($(DEBUG),1)
CFLAGS += -g -DDEBUG
else
CFLAGS += -O2
endif
## Check if variable is defined
ifdef VERBOSE
Q =
else
Q = @
endif
build:
$(Q)echo "Building..."
$(Q)$(CC) $(CFLAGS) -o app main.c
OS Detection¶
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
PLATFORM = linux
LDFLAGS += -lpthread
endif
ifeq ($(UNAME_S),Darwin)
PLATFORM = macos
LDFLAGS += -framework CoreFoundation
endif
ifeq ($(UNAME_S),MINGW64_NT)
PLATFORM = windows
EXE_EXT = .exe
endif
build:
@echo "Building for $(PLATFORM)"
$(CC) -o app$(EXE_EXT) main.c $(LDFLAGS)
Functions¶
Built-in Functions¶
## wildcard - Match files
SOURCES = $(wildcard src/*.c)
## patsubst - Pattern substitution
OBJECTS = $(patsubst src/%.c,build/%.o,$(SOURCES))
## shell - Execute shell command
BUILD_DATE = $(shell date +%Y%m%d)
## foreach - Iterate over list
DIRS = src include lib
CREATE_DIRS = $(foreach dir,$(DIRS),$(shell mkdir -p $(dir)))
## filter - Filter list
CFILES = $(filter %.c,$(SOURCES))
## filter-out - Exclude from list
NON_TEST = $(filter-out %_test.c,$(SOURCES))
Multi-Line Commands¶
Backslash Continuation¶
build:
$(CC) \
$(CFLAGS) \
-I$(INCLUDE_DIR) \
-L$(LIB_DIR) \
$(SOURCES) \
-o $(TARGET)
Multi-Line Recipe¶
deploy:
@echo "Starting deployment..."
@docker build -t myapp:latest .
@docker tag myapp:latest registry.example.com/myapp:latest
@docker push registry.example.com/myapp:latest
@echo "Deployment complete!"
Error Handling¶
Exit on Error¶
## Default: exit on error
test:
pytest tests/
## Continue on error
.IGNORE: test
test:
pytest tests/
## Ignore errors for specific command
test:
-pytest tests/
Check Command Success¶
test:
@pytest tests/ || (echo "Tests failed!"; exit 1)
Silent Commands¶
## Prefix with @ to suppress output
build:
@echo "Building..."
@$(CC) -o app main.c
## Make all commands silent
.SILENT:
build:
echo "Building..."
$(CC) -o app main.c
Dependencies¶
Automatic Dependency Generation¶
SRC_DIR = src
BUILD_DIR = build
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(BUILD_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d)
## Include dependency files
-include $(DEPS)
## Compile with dependency generation
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
build: $(OBJECTS)
$(CC) $(LDFLAGS) $^ -o $(TARGET)
clean:
rm -rf $(BUILD_DIR) $(TARGET)
Testing¶
Testing Make Targets¶
Validate Makefile syntax and targets:
## Check Makefile syntax
make -n all # Dry run
## List all targets
make -qp | awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {split($1,A,/ /);for(i in A)print A[i]}'
## Test specific target without execution
make -n build
## Verbose output for debugging
make -d build
Makefile Linting¶
## Install checkmake
go install github.com/mrtazz/checkmake/cmd/checkmake@latest
## Lint Makefile
checkmake Makefile
## With custom config
checkmake --config=.checkmake Makefile
Unit Testing Makefile Targets¶
## tests/makefile_test.sh
#!/bin/bash
set -e
echo "Testing Makefile targets..."
## Test clean target
make clean
if [ -d "build/" ]; then
echo "FAIL: clean target did not remove build directory"
exit 1
fi
echo "PASS: clean target works"
## Test build target creates output
make build
if [ ! -f "build/app" ]; then
echo "FAIL: build target did not create output"
exit 1
fi
echo "PASS: build target works"
## Test test target runs successfully
if ! make test; then
echo "FAIL: test target failed"
exit 1
fi
echo "PASS: test target works"
echo "All Makefile tests passed!"
Integration Testing¶
Test Make targets in CI/CD:
## .github/workflows/makefile-test.yml
name: Test Makefile
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: make deps
- name: Lint Makefile
run: |
go install github.com/mrtazz/checkmake/cmd/checkmake@latest
checkmake Makefile
- name: Test clean target
run: |
make build
make clean
test ! -d build/
- name: Test build
run: make build
- name: Run tests
run: make test
- name: Test install
run: make install PREFIX=/tmp/install
Testing with BATS¶
## tests/makefile.bats
#!/usr/bin/env bats
@test "make clean removes build artifacts" {
make build
make clean
run test -d build/
[ "$status" -ne 0 ]
}
@test "make build creates binary" {
make clean
run make build
[ "$status" -eq 0 ]
[ -f "build/app" ]
}
@test "make test runs successfully" {
run make test
[ "$status" -eq 0 ]
}
@test "make with no target runs default" {
run make
[ "$status" -eq 0 ]
}
@test "make help displays help text" {
run make help
[ "$status" -eq 0 ]
[[ "$output" =~ "Available targets:" ]]
}
Testing Phony Targets¶
Ensure phony targets work correctly:
## Makefile
.PHONY: test-phony
test-phony:
@echo "Testing phony targets..."
@$(MAKE) clean
@$(MAKE) build
@$(MAKE) test
@echo "All phony targets work correctly"
Testing Variable Expansion¶
## Test variable substitution
make print-vars
## Test with overridden variables
make VAR=value print-vars
## Verify variable defaults
make -p | grep "^VAR ="
Performance Testing¶
Test build performance:
## Makefile
.PHONY: benchmark
benchmark:
@echo "Benchmarking build..."
@time $(MAKE) clean
@time $(MAKE) build
@time $(MAKE) test
Parallel Execution Testing¶
## Test parallel builds
make -j4 all
## Verify parallel safety
make clean
make -j8 build test
Dependency Testing¶
Verify target dependencies:
.PHONY: test-deps
test-deps: build test
@echo "Dependencies resolved correctly"
Testing Cross-Platform Compatibility¶
.PHONY: test-platform
test-platform:
ifeq ($(OS),Windows_NT)
@echo "Testing on Windows"
@cmd /c echo Windows test
else
ifeq ($(shell uname -s),Darwin)
@echo "Testing on macOS"
@echo "macOS test"
else
@echo "Testing on Linux"
@echo "Linux test"
endif
endif
Error Handling Tests¶
## Test error propagation
if make failing-target; then
echo "ERROR: Failed target should have exited with error"
exit 1
fi
## Test ignore errors
make -i potentially-failing-targets
Security Best Practices¶
Prevent Command Injection¶
Avoid using unsanitized variables in shell commands:
## Bad - Vulnerable to command injection
USER_INPUT ?= ; rm -rf /
deploy:
ssh server "cd /app && deploy $(USER_INPUT)" # ❌ Injection risk!
## Good - Validate and quote variables
USER_INPUT ?= production
VALID_ENVS := development staging production
deploy:
@if ! echo "$(VALID_ENVS)" | grep -wq "$(USER_INPUT)"; then \
echo "Error: Invalid environment '$(USER_INPUT)'"; \
exit 1; \
fi
ssh server "cd /app && deploy '$(USER_INPUT)'" # ✅ Quoted and validated
## Good - Use allow-lists
DEPLOY_ENV ?= staging
ALLOWED_ENVS := dev staging production
deploy:
$(if $(filter $(DEPLOY_ENV),$(ALLOWED_ENVS)),,$(error Invalid environment: $(DEPLOY_ENV)))
@echo "Deploying to $(DEPLOY_ENV)"
./deploy.sh "$(DEPLOY_ENV)"
Key Points:
- Always validate external inputs
- Use allow-lists for dynamic values
- Quote all variables in shell commands
- Avoid using
evalwith user input - Use
$(if)or$(filter)for validation - Never trust environment variables directly
Secure Credentials Management¶
Never hardcode credentials in Makefiles:
## Bad - Hardcoded credentials
deploy:
aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE # ❌ Exposed!
docker login -u myuser -p mypassword # ❌ Hardcoded!
## Good - Use environment variables
deploy:
@if [ -z "$$AWS_ACCESS_KEY_ID" ]; then \
echo "Error: AWS_ACCESS_KEY_ID not set"; \
exit 1; \
fi
aws s3 sync ./dist s3://my-bucket
## Good - Read from secure credential stores
deploy:
@echo "Retrieving credentials from vault..."
@$(eval TOKEN := $(shell vault kv get -field=token secret/deploy))
@curl -H "Authorization: Bearer $(TOKEN)" https://api.example.com/deploy
## Good - Use credential files
docker-login:
@if [ ! -f ~/.docker/config.json ]; then \
echo "Error: Docker credentials not configured"; \
exit 1; \
fi
docker pull private-registry.com/myapp:latest
## Good - Never log secrets
DB_PASSWORD := $(shell vault kv get -field=password secret/database)
.SILENT: db-connect
db-connect:
psql -h db.example.com -U admin # Password from PGPASSWORD env var
Key Points:
- Store secrets in environment variables or vaults
- Use
.SILENTto prevent echoing sensitive commands - Read credentials from secure stores (Vault, AWS Secrets Manager)
- Never commit secrets to version control
- Use
.envfiles (gitignored) for local development - Rotate credentials regularly
Safe File Operations¶
Prevent accidental data loss and security issues:
## Bad - Dangerous file operations
clean:
rm -rf $(BUILD_DIR)/* # ❌ What if BUILD_DIR is empty or /
## Good - Validate before destructive operations
BUILD_DIR ?= ./build
clean:
@if [ -z "$(BUILD_DIR)" ] || [ "$(BUILD_DIR)" = "/" ]; then \
echo "Error: Invalid BUILD_DIR"; \
exit 1; \
fi
@if [ -d "$(BUILD_DIR)" ]; then \
echo "Cleaning $(BUILD_DIR)..."; \
rm -rf "$(BUILD_DIR)"/*; \
fi
## Good - Use temporary directories safely
TMP_DIR := $(shell mktemp -d)
build-temp:
@echo "Using temporary directory: $(TMP_DIR)"
cd "$(TMP_DIR)" && build.sh
cp "$(TMP_DIR)"/output ./
rm -rf "$(TMP_DIR)"
## Good - Set safe permissions
install:
install -m 755 -o root -g root bin/myapp /usr/local/bin/myapp
install -m 644 -o root -g root config/app.conf /etc/myapp/app.conf
install -m 600 -o root -g root secrets/api.key /etc/myapp/api.key # Restrictive
## Good - Verify file integrity
download-verify:
wget https://example.com/tool.tar.gz
@echo "$(EXPECTED_CHECKSUM) tool.tar.gz" | sha256sum -c || \
(echo "Checksum verification failed!"; rm tool.tar.gz; exit 1)
tar -xzf tool.tar.gz
Key Points:
- Always validate paths before destructive operations
- Use
mktempfor temporary files/directories - Set appropriate file permissions (least privilege)
- Verify checksums for downloaded files
- Never use wildcards with
rm -rf - Clean up temporary files in error conditions
Input Validation and Sanitization¶
Validate all external inputs:
## Bad - No validation
VERSION ?= $(shell git describe --tags)
deploy:
docker push myapp:$(VERSION) # ❌ What if VERSION contains malicious content?
## Good - Validate version format
VERSION ?= $(shell git describe --tags 2>/dev/null)
VERSION_REGEX := ^v[0-9]+\.[0-9]+\.[0-9]+$$
deploy:
@if [ -z "$(VERSION)" ]; then \
echo "Error: VERSION not set"; \
exit 1; \
fi
@if ! echo "$(VERSION)" | grep -Eq "$(VERSION_REGEX)"; then \
echo "Error: Invalid version format '$(VERSION)'"; \
exit 1; \
fi
docker push myapp:$(VERSION)
## Good - Sanitize user inputs
sanitize = $(subst ;,,$(subst &,,$(subst |,,$(1))))
USER_BRANCH ?= main
safe-checkout:
@$(eval SAFE_BRANCH := $(call sanitize,$(USER_BRANCH)))
@if ! git branch -r | grep -q "origin/$(SAFE_BRANCH)"; then \
echo "Error: Branch '$(SAFE_BRANCH)' does not exist"; \
exit 1; \
fi
git checkout "$(SAFE_BRANCH)"
Key Points:
- Validate all external inputs (environment variables, user args)
- Use regex patterns for format validation
- Sanitize inputs to remove dangerous characters
- Check for empty or undefined variables
- Verify resources exist before using them
- Use
$(error)for critical validation failures
Dependency Security¶
Secure external dependencies:
## Good - Pin dependency versions
NODEJS_VERSION := 20.10.0
TERRAFORM_VERSION := 1.6.5
install-node:
wget https://nodejs.org/dist/v$(NODEJS_VERSION)/node-v$(NODEJS_VERSION)-linux-x64.tar.gz
@echo "$(NODE_CHECKSUM) node-v$(NODEJS_VERSION)-linux-x64.tar.gz" | sha256sum -c
tar -xzf node-v$(NODEJS_VERSION)-linux-x64.tar.gz
## Good - Verify package integrity
NPM_PACKAGES := express@4.18.2 dotenv@16.3.1
install-deps:
npm ci # Uses package-lock.json for reproducible installs
npm audit --audit-level=high
@if [ $$? -ne 0 ]; then \
echo "Security vulnerabilities found!"; \
exit 1; \
fi
## Good - Use lock files
bundle-install:
@if [ ! -f Gemfile.lock ]; then \
echo "Error: Gemfile.lock not found"; \
exit 1; \
fi
bundle install --frozen # Fail if Gemfile.lock is out of date
Key Points:
- Pin all dependency versions
- Verify checksums for downloaded packages
- Use lock files for reproducible builds
- Run security audits (npm audit, bundle audit)
- Fail builds on high-severity vulnerabilities
- Keep dependencies updated
Least Privilege Execution¶
Run commands with minimal required privileges:
## Bad - Running as root unnecessarily
install:
sudo cp bin/myapp /usr/local/bin/ # ❌ Entire make runs as root
## Good - Use sudo only when necessary
install:
@echo "Installing binary (requires sudo)..."
@install -m 755 bin/myapp /tmp/myapp
@sudo mv /tmp/myapp /usr/local/bin/myapp
@echo "Installation complete"
## Good - Check for required permissions
docker-build:
@if ! docker ps > /dev/null 2>&1; then \
echo "Error: Docker daemon not accessible"; \
echo "Run: sudo usermod -aG docker $$USER"; \
exit 1; \
fi
docker build -t myapp:latest .
## Good - Run tests as non-root user
test:
@if [ "$$(id -u)" = "0" ]; then \
echo "Warning: Running tests as root is not recommended"; \
fi
npm test
Key Points:
- Never run make as root unless absolutely necessary
- Use
sudoonly for specific commands that require it - Check for required permissions before executing
- Warn when running as root
- Use service accounts with minimal permissions
- Document why elevated privileges are needed
Secure Build Artifacts¶
Protect build outputs:
## Good - Set restrictive permissions
ARTIFACT_DIR := ./dist
SECRETS_DIR := ./secrets
build:
mkdir -p "$(ARTIFACT_DIR)"
go build -o "$(ARTIFACT_DIR)/myapp"
chmod 755 "$(ARTIFACT_DIR)/myapp"
## Good - Generate checksums
release:
@cd "$(ARTIFACT_DIR)" && sha256sum * > SHA256SUMS
@gpg --armor --detach-sign "$(ARTIFACT_DIR)/SHA256SUMS"
## Good - Don't include secrets in artifacts
package:
@echo "Packaging application..."
@if find "$(ARTIFACT_DIR)" -name "*.key" -o -name "*.pem" | grep -q .; then \
echo "Error: Secrets found in artifact directory!"; \
exit 1; \
fi
tar -czf release.tar.gz -C "$(ARTIFACT_DIR)" .
Key Points:
- Set appropriate file permissions for artifacts
- Generate checksums for verification
- Sign critical artifacts with GPG
- Scan artifacts for accidentally included secrets
- Never commit build artifacts to version control
- Clean up temporary build files
Audit Logging¶
Log security-relevant operations:
## Good - Log deployments
deploy:
@echo "AUDIT: Deployment started at $$(date)" | tee -a deploy.log
@echo "AUDIT: User: $$USER" | tee -a deploy.log
@echo "AUDIT: Environment: $(DEPLOY_ENV)" | tee -a deploy.log
./deploy.sh "$(DEPLOY_ENV)"
@echo "AUDIT: Deployment completed at $$(date)" | tee -a deploy.log
## Good - Log errors
.ONESHELL:
.SHELLFLAGS = -ec
critical-operation:
@echo "Starting critical operation at $$(date)" >> audit.log
@trap 'echo "ERROR at $$(date): $$?" >> audit.log' ERR
./risky-operation.sh
Key Points:
- Log all deployments and critical operations
- Include timestamps, user, and environment
- Use
teeto log to both console and file - Log errors and failures
- Retain logs for compliance requirements
- Monitor logs for suspicious activity
Network Security¶
Secure network operations:
## Good - Use HTTPS for downloads
download-tools:
@echo "Downloading from trusted source..."
curl -sSL https://trusted-site.com/tool.sh | bash # ❌ Still risky!
## Better - Download and verify before executing
download-tools-safe:
curl -sSL -o tool.sh https://trusted-site.com/tool.sh
@echo "$(EXPECTED_CHECKSUM) tool.sh" | sha256sum -c
chmod +x tool.sh
./tool.sh
rm tool.sh
## Good - Use VPN for sensitive operations
deploy-prod:
@if ! ping -c 1 vpn.internal > /dev/null 2>&1; then \
echo "Error: VPN connection required for production deployment"; \
exit 1; \
fi
ssh -o StrictHostKeyChecking=yes prod-server "deploy.sh"
Key Points:
- Always use HTTPS for downloads
- Never pipe downloads directly to shell
- Verify checksums before execution
- Use VPN for production deployments
- Verify SSH host keys
- Restrict network access where possible
Common Pitfalls¶
Spaces Instead of Tabs¶
Issue: Make requires tabs for recipe indentation; spaces cause "missing separator" errors.
Example:
## Bad - Spaces instead of tabs
build:
gcc -o app main.c # ❌ Indented with spaces! Error: missing separator
Solution: Use tabs for recipe indentation.
## Good - Tab indentation
build:
gcc -o app main.c # ✅ Indented with tab
Key Points:
- Recipes MUST be indented with tabs
- Configure editor to show tabs vs spaces
- Variable assignments and comments can use spaces
- Use
.RECIPEPREFIX = >to change tab requirement (GNU Make 3.82+)
Forgetting .PHONY for Non-File Targets¶
Issue: Without .PHONY, Make won't run targets if files with same names exist.
Example:
## Bad - No .PHONY declaration
clean:
rm -rf *.o build/
## If a file named "clean" exists, this target won't run!
Solution: Declare non-file targets as .PHONY.
## Good - PHONY targets declared
.PHONY: clean test build all
clean:
rm -rf *.o build/
test:
go test ./...
build:
go build -o app
all: clean build test
Key Points:
- Always declare
.PHONYfor targets that don't create files - Common PHONY targets:
clean,test,install,run,all - PHONY targets run every time, regardless of files
- Place
.PHONYdeclarations at top of Makefile
Variable Expansion Timing Confusion¶
Issue: Mixing = (lazy) and := (immediate) assignment causes unexpected behavior.
Example:
## Bad - Unintended recursion
FLAGS = $(FLAGS) -Wall # ❌ Recursive! FLAGS refers to itself
build:
gcc $(FLAGS) main.c # Infinite expansion error
Solution: Use := for immediate expansion or += for appending.
## Good - Immediate assignment
FLAGS := -O2
FLAGS += -Wall # ✅ Appends to existing value
## Good - Conditional assignment
FLAGS ?= -O2 # Only set if not already defined
build:
gcc $(FLAGS) main.c
Key Points:
=: Lazy (recursive) expansion - expanded when used:=: Immediate (simple) expansion - expanded at assignment?=: Conditional assignment - only if not set+=: Append to existing value
Missing Dependencies¶
Issue: Targets don't rebuild when dependencies change, causing stale builds.
Example:
## Bad - No source file dependencies
app: main.o utils.o
gcc -o app main.o utils.o
main.o:
gcc -c main.c # ❌ Won't rebuild if main.c or header changes!
utils.o:
gcc -c utils.c
Solution: Specify all dependencies including headers.
## Good - Complete dependencies
HEADERS = main.h utils.h config.h
app: main.o utils.o
gcc -o app main.o utils.o
main.o: main.c $(HEADERS) # ✅ Rebuilds when source or headers change
gcc -c main.c
utils.o: utils.c utils.h # ✅ Specific dependencies
gcc -c utils.c
## Better - Auto-generate dependencies
-include $(SOURCES:.c=.d)
%.o: %.c
gcc -MMD -c $< -o $@
Key Points:
- List all files that affect the target
- Include header files in dependencies
- Use
-MMDflag to auto-generate.ddependency files - Missing dependencies cause inconsistent builds
Special Variables Misuse¶
Issue: Confusing $@, $<, $^, and $? leads to incorrect recipes.
Example:
## Bad - Wrong automatic variable
%.o: %.c
gcc -c $^ -o $< # ❌ Swapped! $^ is all prereqs, $< is first prereq
Solution: Use correct automatic variables.
## Good - Correct automatic variables
%.o: %.c
gcc -c $< -o $@ # ✅ $< = first prerequisite, $@ = target
## Common automatic variables:
## $@ = target name
## $< = first prerequisite
## $^ = all prerequisites
## $? = prerequisites newer than target
## $* = stem of pattern match
app: main.o utils.o config.o
gcc -o $@ $^ # ✅ $@ = app, $^ = all .o files
Key Points:
$@: Target filename$<: First prerequisite filename$^: All prerequisite filenames (space-separated)$?: Prerequisites newer than target$*: The stem (matched by%in pattern rules)
Anti-Patterns¶
❌ Avoid: Spaces Instead of Tabs¶
## Bad - Using spaces for indentation
build:
echo "Building..." # This will fail!
## Good - Using tabs
build:
echo "Building..."
❌ Avoid: Not Using .PHONY¶
## Bad - Without .PHONY, make won't run if 'clean' file exists
clean:
rm -rf build/
## Good - Using .PHONY
.PHONY: clean
clean:
rm -rf build/
❌ Avoid: Hardcoded Paths¶
## Bad - Hardcoded paths
build:
gcc -o /home/user/myapp main.c
## Good - Use variables
BIN_DIR = bin
build:
gcc -o $(BIN_DIR)/myapp main.c
❌ Avoid: Not Declaring Dependencies¶
## Bad - No dependencies declared
test:
go test ./...
## Good - Declare dependencies
test: build # test depends on build
go test ./...
build: $(wildcard *.go) # build depends on Go files
go build -o app main.go
❌ Avoid: Silent Failures¶
## Bad - Errors hidden
install:
-cp config.yaml /etc/app/ # '-' prefix ignores errors
## Good - Fail on errors
install:
cp config.yaml /etc/app/ # Will stop if copy fails
chmod 644 /etc/app/config.yaml
❌ Avoid: Not Using @ for Clean Output¶
## Bad - Shows all commands (noisy output)
build:
echo "Building application..."
go build -o app main.go
echo "Build complete!"
## Good - Use @ to hide commands
build:
@echo "Building application..."
@go build -o app main.go
@echo "Build complete!"
❌ Avoid: Recursive Make Without $(MAKE)¶
## Bad - Direct make call
deploy:
cd frontend && make build # ❌ Won't pass flags correctly
## Good - Use $(MAKE) variable
deploy:
$(MAKE) -C frontend build # ✅ Passes flags and parallel builds
Best Practices¶
Default Target¶
.DEFAULT_GOAL := help
help:
@echo "Available targets: build, test, clean"
build:
go build -o app main.go
test:
go test ./...
clean:
rm -f app
Self-Documenting Makefile¶
.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}'
build: ## Build the application
go build -o app main.go
test: ## Run tests
go test ./...
clean: ## Clean build artifacts
rm -f app
Tool Configuration¶
Makefile Linting with checkmake¶
Install and use checkmake to lint Makefiles:
## Install checkmake (Go)
go install github.com/mrtazz/checkmake/cmd/checkmake@latest
## Install checkmake (brew)
brew install checkmake
## Lint Makefile
checkmake Makefile
## Lint with specific rules
checkmake --config .checkmake Makefile
## Output as JSON
checkmake --format=json Makefile
.checkmake Configuration¶
## .checkmake
[minphony]
# Minimum percentage of PHONY targets
minPhonyTargets = 0.5
[phonydeclared]
# Require .PHONY declarations
requirePhonyDeclarations = true
[timestampexpanded]
# Allow timestamp expansion in targets
allowTimestampExpansion = false
[maxbodylength]
# Maximum lines in target body
maxBodyLength = 10
[minhelp]
# Minimum percentage of targets with help text
minHelpTargets = 0.3
EditorConfig¶
## .editorconfig
[Makefile]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.mk]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
VS Code Settings¶
{
"[makefile]": {
"editor.insertSpaces": false,
"editor.detectIndentation": false,
"editor.tabSize": 4
},
"files.associations": {
"Makefile*": "makefile",
"*.mk": "makefile",
"*.make": "makefile"
},
"makefile.configureOnOpen": true,
"makefile.launchConfigurations": [
{
"makeArgs": ["test"],
"makeDirectory": "${workspaceFolder}"
}
]
}
Pre-commit Hooks¶
## .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files
- repo: local
hooks:
- id: checkmake
name: Check Makefile
entry: checkmake
language: system
files: ^Makefile$|\.mk$
pass_filenames: true
Makefile Self-Documentation¶
Add help target to Makefile for self-documentation:
## Makefile with self-documentation
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help message
@echo 'Usage:'
@echo ' make [target]'
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / \
{printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: build
build: ## Build the project
go build -o bin/app .
.PHONY: test
test: ## Run tests
go test -v ./...
.PHONY: clean
clean: ## Clean build artifacts
rm -rf bin/
Makefile Testing with BATS¶
Test Makefile targets using BATS (Bash Automated Testing System):
#!/usr/bin/env bats
## test/makefile.bats
setup() {
# Run before each test
export TEST_DIR="$(mktemp -d)"
}
teardown() {
# Run after each test
rm -rf "$TEST_DIR"
}
@test "make build creates binary" {
run make build
[ "$status" -eq 0 ]
[ -f "bin/app" ]
}
@test "make test runs successfully" {
run make test
[ "$status" -eq 0 ]
}
@test "make clean removes artifacts" {
make build
make clean
[ ! -f "bin/app" ]
}
@test "make help shows usage" {
run make help
[ "$status" -eq 0 ]
[[ "$output" =~ "Usage:" ]]
}
Makefile Debugging¶
## Print all variables
make -p
## Dry run (show commands without executing)
make -n target
## Print debugging information
make -d target
## Trace target execution
make --trace target
## Warn about undefined variables
make --warn-undefined-variables target
## Print database of rules
make -p -f /dev/null
Makefile Include Pattern¶
Organize large Makefiles with includes:
## Makefile
.DEFAULT_GOAL := all
## Include sub-makefiles
include makefiles/build.mk
include makefiles/test.mk
include makefiles/deploy.mk
.PHONY: all
all: build test
## makefiles/build.mk
.PHONY: build
build:
go build -o bin/app .
## makefiles/test.mk
.PHONY: test
test:
go test -v ./...
## makefiles/deploy.mk
.PHONY: deploy
deploy:
./scripts/deploy.sh
Makefile Validation Script¶
#!/bin/bash
## scripts/validate-makefile.sh
set -euo pipefail
echo "Validating Makefile..."
## Check if Makefile exists
if [ ! -f "Makefile" ]; then
echo "ERROR: Makefile not found"
exit 1
fi
## Check for tabs (Make requires tabs)
if grep -P '^ [^\t]' Makefile > /dev/null; then
echo "ERROR: Found spaces instead of tabs in Makefile"
exit 1
fi
## Run checkmake if available
if command -v checkmake &> /dev/null; then
checkmake Makefile
else
echo "WARNING: checkmake not installed, skipping lint"
fi
## Dry run to check syntax
make -n --dry-run > /dev/null 2>&1 || {
echo "ERROR: Makefile has syntax errors"
exit 1
}
echo "✓ Makefile validation passed"
CI/CD Integration¶
GitHub Actions workflow for Makefile validation:
name: Validate Makefile
on:
pull_request:
paths:
- 'Makefile'
- '**.mk'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install checkmake
run: |
go install github.com/mrtazz/checkmake/cmd/checkmake@latest
echo "$HOME/go/bin" >> $GITHUB_PATH
- name: Lint Makefile
run: checkmake Makefile
- name: Validate syntax
run: make -n --dry-run
- name: Test help target
run: make help
References¶
Official Documentation¶
Tutorials¶
Status: Active