Makefiles
Use make as a language-agnostic task runner — targets, prerequisites, recipes, .PHONY, automatic variables ($@, $<, $^), and pattern rules.
- Write a Makefile with targets, prerequisites, and recipes
- Mark targets as .PHONY to prevent conflicts with real files
- Use automatic variables $@, $<, and $^ in recipes
- Write pattern rules to handle classes of files
- Use make as a task runner for non-C projects
Most people encounter make as a C build tool. But make is better understood as a dependency-aware task runner: it checks what needs to be done, runs only the necessary steps, and provides a standard interface for any project's common operations. The pattern is so useful that Python projects, Rust projects, and infrastructure-as-code repositories all use Makefiles for their build, test, lint, and deploy targets — with no C in sight.
Basic structure
A Makefile consists of rules:
target: prerequisites
recipeThe recipe is a shell command (or series of commands) that creates or updates the target. Recipes must be indented with a tab, not spaces — this is the most common Makefile error.
# Simplest Makefile
hello:
echo "Hello, world"
clean:
rm -f *.o outputRun a specific target with make target. Running make with no arguments runs the first target.
.PHONY: tasks, not files
By default, make checks whether a file named target exists. If it does, make thinks the target is up to date and skips it. Mark non-file targets as .PHONY to prevent this:
.PHONY: build test lint clean deploy
build:
go build ./...
test:
go test ./...
lint:
golangci-lint run
clean:
rm -rf dist/
deploy: build test
./deploy.shThe deploy: build test line means "run build and test first, then run deploy's recipe." If build fails, make stops before running test or deploy.
Makefile as a project's front door. In many open-source projects, the first thing a contributor does is run make or read the Makefile. A well-structured Makefile documents the available operations (make help is a common convention) and ensures every developer uses the same commands.
Variables
Makefile variables are set with = or :=:
# = is lazily evaluated (re-evaluated each use)
# := is eagerly evaluated (evaluated once at definition time)
VERSION := 1.2.3
BINARY := myapp
OUTPUT := dist/$(BINARY)-$(VERSION)
build:
go build -o $(OUTPUT) .
.PHONY: buildUse $(VAR) to expand a variable (or ${VAR} — both work in make, unlike Bash).
Automatic variables
Inside a recipe, make provides several automatic variables that refer to the current rule's target and prerequisites:
| Variable | Meaning |
|---|---|
$@ | The target name |
$< | The first prerequisite |
$^ | All prerequisites (space-separated, deduplicated) |
$* | The stem matched by a % pattern rule |
dist/myapp: main.go config.go utils.go
go build -o $@ $^
# ^^ ^^
# | all prerequisites
# the target (dist/myapp)Pattern rules
A % wildcard creates a rule that applies to a class of targets. This is how C build systems compile every .c file to a .o file:
# Compile any .c file to a .o file
%.o: %.c
gcc -c -o $@ $<
# Convert any Markdown file to HTML
docs/%.html: docs/%.md
pandoc -o $@ $<For a non-C example: a project that wants to minify every .js file in src/ to dist/:
SRCS := $(wildcard src/*.js)
OUTS := $(SRCS:src/%.js=dist/%.min.js)
all: $(OUTS)
dist/%.min.js: src/%.js
terser $< -o $@
.PHONY: allA practical task-runner Makefile
This pattern works for almost any project:
.DEFAULT_GOAL := help
.PHONY: help install test lint build clean
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
install: ## Install dependencies
pip install -r requirements.txt
test: ## Run the test suite
pytest tests/
lint: ## Run the linter
ruff check .
build: ## Build the package
python -m build
clean: ## Remove build artifacts
rm -rf dist/ build/ *.egg-info __pycache__The help target uses grep + awk to extract targets and their ## comment descriptions, printing a self-documenting command reference.
Tab vs space errors are cryptic. If you see Makefile:5: *** missing separator. Stop., a recipe line is indented with spaces instead of a tab. Most editors can be configured to use tabs for Makefiles specifically; many (VS Code, Vim) do this automatically.
Check your understanding
- 1.You have a target named "clean". There is also a file named "clean" in the directory. Running make clean does nothing. What fixes this?
- 2.In a recipe, $@ refers to which value?
- 3.Recipe lines in a Makefile can be indented with either tabs or spaces.
Do it yourself
# Create a minimal task-runner Makefile
mkdir -p /tmp/makefile-demo && cd /tmp/makefile-demo
cat > Makefile << 'EOF'
.PHONY: hello greet clean
hello:
echo "Target: $@"
greet: hello
echo "Hello from the greet target"
echo "Prerequisite was: $<"
clean:
echo "Nothing to clean in this demo"
EOF
make hello
make greet
make clean
make # runs the first target (hello)Where to go next
Makefiles give any project a consistent, discoverable interface. The next lesson — CI shell scripts — shows how to write shell scripts that work reliably in automated CI/CD environments: portable shebangs, strict mode, idempotent steps, and secret handling.
Cron and Scheduling
Schedule scripts with crontab -e, understand the 5-field cron syntax, use @reboot and @daily shortcuts, and account for cron's minimal environment.
CI Shell Scripts
Write portable, robust CI scripts with the right shebang, set -euo pipefail, idempotent steps, environment-variable secrets, and matrix build patterns.