CtrlK
BlogDocsLog inGet started
Tessl Logo

pantheon-ai/makefile-validator

Comprehensive toolkit for validating, linting, and optimizing Makefiles. Use this skill when working with Makefiles (Makefile, makefile, *.mk files), validating build configurations, checking for best practices, identifying security issues, or debugging Makefile problems.

Overall
score

100%

Does it follow best practices?

Validation for skill structure

Overview
Skills
Evals
Files

best-practices.mdreferences/

Makefile Best Practices

Comprehensive guide to writing professional, maintainable, and efficient Makefiles.

Table of Contents

  1. Essential Special Targets
  2. File Organization
  3. Target Declarations
  4. Variable Management
  5. Recipe Best Practices
  6. Dependency Management
  7. Performance Optimization
  8. Portability
  9. Documentation
  10. Security
  11. Advanced Patterns

Modern Makefile Header (Recommended)

For modern, robust Makefiles, start with this recommended preamble from Jacob Davis-Hansson:

# Modern Makefile Header
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules

Explanation:

SettingPurpose
SHELL := bashUse bash instead of /bin/sh for modern shell features
.ONESHELL:Run entire recipe in single shell (enables multi-line scripts)
.SHELLFLAGS := -eu -o pipefail -cStop on errors (-e), undefined vars (-u), pipe failures
.DELETE_ON_ERROR:Delete target on recipe failure (prevents corrupt builds)
--warn-undefined-variablesAlert on undefined Make variable references
--no-builtin-rulesDisable built-in implicit rules for faster builds

Note: This preamble is for GNU Make 4.0+. For maximum portability, use a simpler header.

Essential Special Targets

GNU Make provides several special targets that should be used in professional Makefiles.

.DELETE_ON_ERROR (Critical)

Always include .DELETE_ON_ERROR: at the top of your Makefile. This ensures partially built targets are deleted when a recipe fails, preventing corrupt builds.

# CRITICAL: Delete target on recipe failure
.DELETE_ON_ERROR:

# Rest of Makefile follows...

Why it matters:

  • Without this, a failed build leaves a partial/corrupt file
  • Next make run sees the file exists and skips rebuilding
  • Results in broken builds that are hard to debug

From GNU Make Manual: "This is almost always what you want make to do, but it is not historical practice; so for compatibility, you must explicitly request it."

Exception: Use .PRECIOUS to protect specific targets that should be preserved even on error:

.DELETE_ON_ERROR:
.PRECIOUS: expensive-to-rebuild.dat

.PHONY (Always Required)

Declare non-file targets as phony to avoid conflicts and improve performance:

.PHONY: all build clean test install

.ONESHELL (For Multi-line Recipes)

Run entire recipe in a single shell invocation:

.ONESHELL:

deploy:
 set -e
 echo "Deploying..."
 cd /app
 git pull
 ./restart.sh

Without .ONESHELL, each line runs in a separate shell, so cd has no effect on subsequent lines.

.SUFFIXES (For Performance)

Clear built-in suffix rules to speed up builds:

# Disable all built-in suffix rules
.SUFFIXES:

# Only keep rules you need (optional)
.SUFFIXES: .c .o

Why: GNU Make has ~90 built-in implicit rules. Clearing them speeds up rule resolution.

Complete Special Targets Header

# Modern Makefile Header
.DELETE_ON_ERROR:
.SUFFIXES:

.PHONY: all build clean test install deploy

# Your targets follow...

File Organization

Directory Structure

# Organized Makefile structure
.PHONY: all clean test install

# Variables section
PROJECT := myapp
VERSION := 1.0.0
BUILD_DIR := build
SRC_DIR := src

# Include external makefiles
include config.mk
include rules/*.mk

# Default target (should be first)
all: build test

# Build targets
build: $(BUILD_DIR)/$(PROJECT)

# ... more targets

Modular Organization

Use include for large projects:

# Main Makefile
include config/variables.mk
include rules/build.mk
include rules/test.mk
include rules/deploy.mk

.PHONY: all
all: build test

Namespace Targets

Use / as delimiter for namespaced targets:

# Good: Namespaced targets
.PHONY: docker/build docker/push docker/clean
docker/build:
 docker build -t $(IMAGE) .

docker/push:
 docker push $(IMAGE)

docker/clean:
 docker rmi $(IMAGE)

# Avoid: Flat namespace
.PHONY: docker-build docker-push docker-clean

Target Declarations

Always Declare .PHONY

Declare targets that don't create files as phony:

# GOOD: Proper .PHONY declarations
.PHONY: all clean test install build deploy

all: build test

clean:
 rm -rf $(BUILD_DIR)

test:
 go test ./...

# BAD: Missing .PHONY - causes issues if files named 'clean' or 'test' exist
clean:
 rm -rf build

test:
 go test ./...

Organize .PHONY Declarations

# Group related phony targets
.PHONY: all build clean
.PHONY: test test-unit test-integration
.PHONY: install uninstall
.PHONY: docker/build docker/push docker/clean

# Or use a single declaration (mbake can organize this)
.PHONY: all build clean test test-unit test-integration install uninstall

Default Target

First target is the default (or use .DEFAULT_GOAL):

# Method 1: First target is default
.PHONY: all
all: build test

# Method 2: Explicit default goal
.DEFAULT_GOAL := build

.PHONY: build test
build:
 go build -o app

test:
 go test ./...

Variable Management

Variable Assignment Operators

Choose the right operator for your use case:

# Simple assignment (=) - Recursive expansion (evaluated when used)
CFLAGS = -Wall $(OPTIMIZE)
OPTIMIZE = -O2
# CFLAGS will expand to: -Wall -O2 (recursive)

# Immediate assignment (:=) - Expanded immediately (RECOMMENDED for most cases)
BUILD_TIME := $(shell date +%Y%m%d-%H%M%S)
VERSION := 1.0.0
# Evaluated once, avoids repeated shell calls

# Conditional assignment (?=) - Set only if not already defined
CC ?= gcc
PREFIX ?= /usr/local
# Allows environment variable override

# Append (+=) - Add to existing value
CFLAGS := -Wall
CFLAGS += -Wextra
CFLAGS += -O2
# CFLAGS = -Wall -Wextra -O2

Use := for Most Variables

# GOOD: Immediate expansion (predictable, faster)
BUILD_DIR := build
SRC_FILES := $(wildcard src/*.c)
TIMESTAMP := $(shell date +%s)

# AVOID: Recursive expansion (unpredictable, slower)
BUILD_DIR = build
SRC_FILES = $(wildcard src/*.c)  # Re-evaluated every time!
TIMESTAMP = $(shell date +%s)    # Shell called multiple times!

Sane Defaults with ?=

# Allow user/environment override
CC ?= gcc
CXX ?= g++
PREFIX ?= /usr/local
DESTDIR ?=
VERBOSE ?= 0

# Usage:
# make                  # Uses defaults
# make CC=clang         # Override CC
# PREFIX=/opt make      # Override via environment

Variable Naming

# GOOD: Clear, consistent naming
PROJECT_NAME := myapp
BUILD_DIR := build
SOURCE_FILES := $(wildcard src/*.c)
COMPILER_FLAGS := -Wall -Wextra -O2

# AVOID: Unclear abbreviations
PROJ := myapp
BDIR := build
SRCS := $(wildcard src/*.c)
FLAGS := -Wall

Recipe Best Practices

Use Tabs, Not Spaces

# GOOD: Tab character (required)
build:
 @echo "Building..."
 go build -o app

# BAD: Spaces (will fail)
build:
    @echo "Building..."
    go build -o app

Note: Makefiles require TAB characters for recipes. Configure your editor to use tabs for Makefiles.

Error Handling

# Method 1: Prefix with @ to suppress echo, - to ignore errors
clean:
 @echo "Cleaning build artifacts..."
 -rm -rf $(BUILD_DIR)
 @echo "Done!"

# Method 2: Use || for conditional error handling
build:
 mkdir -p $(BUILD_DIR) || exit 1
 go build -o $(BUILD_DIR)/app || exit 1

# Method 3: Use set -e for strict error handling
test:
 @set -e; \
 echo "Running tests..."; \
 go test ./...; \
 echo "All tests passed!"

# Method 4: Check exit codes explicitly
deploy:
 @./scripts/deploy.sh
 @if [ $$? -ne 0 ]; then \
  echo "Deployment failed!"; \
  exit 1; \
 fi

Multi-line Recipes

# Use backslash for line continuation
build: $(SOURCES)
 @echo "Building $(PROJECT)..."; \
 mkdir -p $(BUILD_DIR); \
 $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(PROJECT) $(SOURCES); \
 echo "Build complete!"

# Or use .ONESHELL for easier multi-line scripts
.ONESHELL:
test:
 echo "Running tests..."
 for file in tests/*.sh; do
  bash $$file
 done
 echo "All tests passed!"

Silent vs Verbose Output

# Use @ to suppress command echo
.PHONY: build
build:
 @echo "Building..."
 @$(CC) $(CFLAGS) -o app $(SOURCES)

# Optional verbose mode
VERBOSE ?= 0
ifeq ($(VERBOSE),1)
 Q :=
else
 Q := @
endif

build:
 $(Q)echo "Building..."
 $(Q)$(CC) $(CFLAGS) -o app $(SOURCES)

# Usage:
# make build           # Silent
# make build VERBOSE=1 # Verbose

Dependency Management

Specify Dependencies Correctly

# GOOD: Proper dependency chain
app: $(OBJECTS)
 $(CC) -o $@ $^

%.o: %.c %.h
 $(CC) $(CFLAGS) -c $< -o $@

# BAD: Missing dependencies - app won't rebuild when headers change
app: $(OBJECTS)
 $(CC) -o $@ $^

%.o: %.c
 $(CC) $(CFLAGS) -c $< -o $@

Auto-generate Dependencies (C/C++)

# Automatic dependency generation
DEPDIR := .deps
DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d

%.o: %.c $(DEPDIR)/%.d | $(DEPDIR)
 $(CC) $(DEPFLAGS) $(CFLAGS) -c $< -o $@

$(DEPDIR):
 @mkdir -p $@

# Include generated dependency files
-include $(patsubst %,$(DEPDIR)/%.d,$(basename $(SOURCES)))

Order-Only Prerequisites

Use | for prerequisites that shouldn't trigger rebuilds:

# Regular prerequisites trigger rebuild
$(BUILD_DIR)/app: $(SOURCES)
 $(CC) -o $@ $^

# Order-only prerequisites (directories) don't trigger rebuild
$(BUILD_DIR)/app: $(SOURCES) | $(BUILD_DIR)
 $(CC) -o $@ $^

$(BUILD_DIR):
 mkdir -p $@

# Without |, updating BUILD_DIR timestamp would trigger app rebuild
# With |, app only rebuilds when SOURCES change

VPATH for Source Organization

# Search for prerequisites in multiple directories
VPATH = src:include:tests

# Or use vpath for specific patterns
vpath %.c src
vpath %.h include
vpath %.test tests

# Now Make will find files in these directories
app: main.o utils.o
 $(CC) -o $@ $^

# Make will find src/main.c and src/utils.c automatically

Performance Optimization

Use .PHONY for Performance

# GOOD: Phony targets skip implicit rule search
.PHONY: clean test install

clean:
 rm -rf $(BUILD_DIR)

# BAD: Without .PHONY, Make checks for file existence
clean:
 rm -rf $(BUILD_DIR)

Parallel Builds

# Enable parallel builds (use -j flag)
# make -j8 build    # 8 parallel jobs

# For sequential targets, use .NOTPARALLEL
.NOTPARALLEL: deploy

deploy: build test
 ./scripts/deploy.sh

# Or use order-only prerequisites for partial ordering
build-frontend: | build-backend
 npm run build

Intermediate File Cleanup

# Mark intermediate files for auto-deletion
.INTERMEDIATE: $(OBJECTS)

# Or mark files to keep through one build
.SECONDARY: $(OBJECTS)

# Delete on error (recommended)
.DELETE_ON_ERROR:

# Example: .o files cleaned after linking
app: main.o utils.o
 $(CC) -o $@ $^
# main.o and utils.o auto-deleted after successful build

Avoid Redundant Shell Calls

# BAD: Shell called every time variable is used
DATE = $(shell date +%Y%m%d)
VERSION = $(shell git describe --tags)

target1:
 echo $(DATE)  # Shell called here

target2:
 echo $(DATE)  # Shell called again!

# GOOD: Use := for one-time evaluation
DATE := $(shell date +%Y%m%d)
VERSION := $(shell git describe --tags)

target1:
 echo $(DATE)  # Expands to cached value

target2:
 echo $(DATE)  # Same cached value

Portability

POSIX Shell Compatibility

# GOOD: POSIX-compatible commands
.PHONY: install
install:
 mkdir -p $(DESTDIR)$(PREFIX)/bin
 cp -f app $(DESTDIR)$(PREFIX)/bin/
 chmod 755 $(DESTDIR)$(PREFIX)/bin/app

# AVOID: Bashisms or GNU-specific features
install:
 mkdir -p $(DESTDIR)$(PREFIX)/bin
 cp app $(DESTDIR)$(PREFIX)/bin/  # Missing -f for portability

Cross-Platform Variables

# Detect operating system
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
 PLATFORM := linux
 EXE_EXT :=
endif
ifeq ($(UNAME_S),Darwin)
 PLATFORM := macos
 EXE_EXT :=
endif
ifeq ($(OS),Windows_NT)
 PLATFORM := windows
 EXE_EXT := .exe
endif

# Use platform-specific settings
APP := app$(EXE_EXT)

Avoid Hard-Coded Paths

# BAD: Hard-coded paths
install:
 cp app /usr/local/bin/
 cp docs/app.1 /usr/share/man/man1/

# GOOD: Use variables for paths
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
MANDIR ?= $(PREFIX)/share/man

install:
 install -d $(DESTDIR)$(BINDIR)
 install -m 755 app $(DESTDIR)$(BINDIR)/
 install -d $(DESTDIR)$(MANDIR)/man1
 install -m 644 docs/app.1 $(DESTDIR)$(MANDIR)/man1/

Documentation

Comment Your Makefiles

# Project: MyApp
# Description: Build system for MyApp project
# Author: Your Name
# Version: 1.0.0

# Configuration variables
PROJECT := myapp
VERSION := $(shell git describe --tags 2>/dev/null || echo "dev")

# Build directories
BUILD_DIR := build
SRC_DIR := src

# Compiler settings
CC := gcc
CFLAGS := -Wall -Wextra -O2

# Default target: Build and test the application
.PHONY: all
all: build test

# Build the main application binary
.PHONY: build
build: $(BUILD_DIR)/$(PROJECT)
 @echo "Build complete: $(BUILD_DIR)/$(PROJECT)"

# Run all test suites
.PHONY: test
test:
 @echo "Running tests..."
 @./scripts/run-tests.sh

Help Target

# Provide a help target
.PHONY: help
help:
 @echo "Available targets:"
 @echo "  make build    - Build the application"
 @echo "  make test     - Run tests"
 @echo "  make clean    - Remove build artifacts"
 @echo "  make install  - Install to $(PREFIX)"
 @echo ""
 @echo "Variables:"
 @echo "  PREFIX=$(PREFIX)"
 @echo "  CC=$(CC)"

# Or auto-generate from comments
.PHONY: help
help:
 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
  awk 'BEGIN {FS = ":.*?## "}; {printf "  %-20s %s\n", $$1, $$2}'

build: ## Build the application
 @go build -o app

test: ## Run all tests
 @go test ./...

clean: ## Remove build artifacts
 @rm -rf $(BUILD_DIR)

Security

Avoid Hardcoded Credentials

# BAD: Hardcoded secrets
deploy:
 curl -H "Authorization: Bearer sk-1234567890" https://api.example.com/deploy

# GOOD: Use environment variables
deploy:
 @if [ -z "$$API_TOKEN" ]; then \
  echo "Error: API_TOKEN not set"; \
  exit 1; \
 fi
 curl -H "Authorization: Bearer $$API_TOKEN" https://api.example.com/deploy

Validate Input Variables

# Validate critical variables
.PHONY: deploy
deploy:
 @if [ -z "$(ENV)" ]; then \
  echo "Error: ENV not specified (prod|staging|dev)"; \
  exit 1; \
 fi
 @if [ "$(ENV)" != "prod" ] && [ "$(ENV)" != "staging" ] && [ "$(ENV)" != "dev" ]; then \
  echo "Error: Invalid ENV=$(ENV)"; \
  exit 1; \
 fi
 @echo "Deploying to $(ENV)..."
 ./scripts/deploy.sh $(ENV)

Safe Variable Expansion

# BAD: Unsafe variable expansion
clean:
 rm -rf $(BUILD_DIR)/*  # Dangerous if BUILD_DIR is empty or /

# GOOD: Validate before dangerous operations
.PHONY: clean
clean:
 @if [ -z "$(BUILD_DIR)" ] || [ "$(BUILD_DIR)" = "/" ]; then \
  echo "Error: Invalid BUILD_DIR"; \
  exit 1; \
 fi
 rm -rf $(BUILD_DIR)/*

# BETTER: Use safer patterns
BUILD_DIR := build  # Never empty
clean:
 @test -d $(BUILD_DIR) && rm -rf $(BUILD_DIR)/* || true

Advanced Patterns

Pattern Rules

# Pattern rule for object files
%.o: %.c
 $(CC) $(CFLAGS) -c $< -o $@

# Multiple pattern rules
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
 @mkdir -p $(dir $@)
 $(CC) $(CFLAGS) -c $< -o $@

# Static pattern rules
$(OBJECTS): %.o: %.c
 $(CC) $(CFLAGS) -c $< -o $@

Automatic Variables

# $@ - Target name
# $< - First prerequisite
# $^ - All prerequisites
# $? - Prerequisites newer than target
# $* - Stem of pattern rule

build/%.o: src/%.c
 @mkdir -p $(dir $@)        # Directory of target
 $(CC) -c $< -o $@          # First prereq to target
 @echo "Built $@"            # Target name

# Example:
# build/main.o: src/main.c
#   $@ = build/main.o
#   $< = src/main.c
#   $* = main

Functions

# Built-in functions
SOURCES := $(wildcard src/*.c)
OBJECTS := $(patsubst src/%.c,build/%.o,$(SOURCES))
HEADERS := $(shell find include -name '*.h')

# String manipulation
UPPERCASE := $(shell echo $(PROJECT) | tr '[:lower:]' '[:upper:]')
VERSION_MAJOR := $(word 1,$(subst ., ,$(VERSION)))

# Custom functions
define compile_template
$(1): $(2)
 $(CC) $(CFLAGS) -c $$< -o $$@
endef

$(foreach src,$(SOURCES),$(eval $(call compile_template,$(patsubst %.c,%.o,$(src)),$(src))))

Conditional Compilation

# Debug vs Release builds
DEBUG ?= 0

ifeq ($(DEBUG),1)
 CFLAGS := -g -O0 -DDEBUG
 BUILD_TYPE := debug
else
 CFLAGS := -O2 -DNDEBUG
 BUILD_TYPE := release
endif

build:
 @echo "Building $(BUILD_TYPE) version..."
 $(CC) $(CFLAGS) -o app $(SOURCES)

Summary Checklist

  • .DELETE_ON_ERROR: declared at top (critical)
  • All non-file targets declared as .PHONY
  • Tabs used for recipe indentation (not spaces)
  • Variables use := for immediate expansion
  • Sane defaults with ?= for user override
  • Dependencies properly specified
  • Error handling in critical recipes
  • Default target documented and listed first
  • No hardcoded credentials or paths
  • Help target provided
  • Parallel build safety considered
  • Intermediate files managed (.INTERMEDIATE/.SECONDARY)
  • Comments explain complex logic
  • Portable commands used (POSIX compatible)
  • Variables validated before dangerous operations
  • .SUFFIXES: considered for disabling built-in rules

Additional Resources

Install with Tessl CLI

npx tessl i pantheon-ai/makefile-validator@0.1.0

references

bake-tool.md

best-practices.md

common-mistakes.md

SKILL.md

tile.json