Skip to content

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: Makefile or makefile (prefer Makefile)
  • 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 eval with 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 .SILENT to prevent echoing sensitive commands
  • Read credentials from secure stores (Vault, AWS Secrets Manager)
  • Never commit secrets to version control
  • Use .env files (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 mktemp for 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 sudo only 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 tee to 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 .PHONY for targets that don't create files
  • Common PHONY targets: clean, test, install, run, all
  • PHONY targets run every time, regardless of files
  • Place .PHONY declarations 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 -MMD flag to auto-generate .d dependency 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