Bash & Shell Scripting for Engineers

The Rule Model

Makefiles look simple and feel arbitrary. You see one in every professional repo. You run make build or make test. It works. Then you try to add your own target, you use four spaces instead of a tab, and you get *** missing separator. Stop. — a cryptic error from a tool designed in 1977 that still uses tab characters as its grammatical backbone.

Once you understand the rule model — targets, prerequisites, recipes, and the "is this file up to date?" check — Makefiles stop being arbitrary and start being logical. This lesson is the rule model, end to end.

KEY CONCEPT

A Makefile is a declarative dependency graph. You declare what outputs exist, what inputs they depend on, and how to build them. make walks the graph, rebuilds anything outdated, and skips anything still current. That is the whole idea.


The shape of a rule

A rule has three parts:

target: prerequisite1 prerequisite2
	recipe line 1
	recipe line 2
  • Target: the thing being built (usually a filename).
  • Prerequisites (deps): what it depends on. If any is newer than the target, rebuild.
  • Recipe: the commands to run. Each line starts with a TAB character (not spaces).

A concrete example:

app: main.o utils.o
	gcc -o app main.o utils.o

main.o: main.c
	gcc -c main.c

utils.o: utils.c
	gcc -c utils.c

Running make app:

  1. Make checks if app exists and if main.o and utils.o are older than app.
  2. If main.o is missing or older than main.c, it rebuilds main.o first.
  3. Same for utils.o.
  4. Then it runs the recipe for app.

On a second make app with no changes: make: 'app' is up to date. Nothing runs. That is the value proposition.


The tabs-vs-spaces rule

Recipe lines must start with a tab character, not spaces. This is the single most common reason Makefiles fail for new users.

# WRONG — spaces before gcc
build:
    gcc main.c

# CORRECT — tab before gcc
build:
	gcc main.c

Most editors hide the distinction. If you get *** missing separator, the answer is almost always "you have spaces where tabs belong."

How to insert a tab in your editor

  • VS Code: "Makefile" file type automatically handles tabs. Or set "editor.insertSpaces": false for .mk/Makefile.
  • Vim: :set noexpandtab for Makefiles.
  • Most editors: a .editorconfig file can enforce:
[Makefile]
indent_style = tab
WARNING

The tab rule is the single silliest thing about Make. It exists because Make is 50 years old. Accept it; configure your editor; move on.


How make decides to rebuild

Make is file-timestamp based. A target is considered up-to-date if:

  1. The target file exists.
  2. Every prerequisite is older than the target.

If either is false, Make rebuilds the target.

main.cmtime: 10:01utils.cmtime: 10:05main.omtime: 10:02 > 10:01 OKutils.omtime: 10:03 < 10:05 STALEappneeds rebuildMake walks the DAG bottom-up, rebuilds anything with stale prerequisites.utils.c changed → utils.o stale → app stale → rebuild utils.o, then app.

This is what makes Make fast on large projects: it only rebuilds what changed.


.PHONY — targets that aren't files

A common use case: make test, make clean, make install. These aren't files. They're commands disguised as targets.

test:
	go test ./...

clean:
	rm -rf build/

Problem: if a file called test or clean exists in the directory, Make sees the target as "up to date" and does nothing.

Fix: declare them .PHONY:

.PHONY: test clean

test:
	go test ./...

clean:
	rm -rf build/

.PHONY tells Make "this target is not a file; always run it."

Every command-like target you add should be .PHONY. Most production Makefiles look like:

.PHONY: build test lint clean install

build:
	...

test:
	...
PRO TIP

Put all your .PHONY targets in one .PHONY: build test lint clean ... line near the top of the Makefile. One source of truth.


Default target

Running make with no arguments runs the first target in the file. This is the "default target" by convention:

.PHONY: help

# First target -> this is what `make` with no args runs.
help:
	@echo "targets: build test lint clean"

build:
	...

test:
	...

Many Makefiles put a help target first so bare make prints usage.

You can override with .DEFAULT_GOAL:

.DEFAULT_GOAL := build

Now make with no args builds, regardless of target order.


Echoing and silencing

By default, Make prints each recipe line before running it:

build:
	echo building
	go build ./...

Running make build:

echo building
building
go build ./...

Suppress the echo with @:

build:
	@echo building
	@go build ./...
building

Much cleaner. Use @ for any recipe line whose command is self-explanatory or noisy.

Running without echo entirely

make -s build    # silent mode, suppresses all echoes

Continuing on error — - prefix

By default, if a recipe line fails (non-zero exit), Make aborts:

clean:
	rm -rf build/
	rm -rf dist/    # if build/ didn't exist and rm failed, this line doesn't run

Prefix with - to ignore errors:

clean:
	-rm -rf build/
	-rm -rf dist/

Now both run regardless. Useful in clean targets where missing files aren't a real failure.

Modern alternative: rm -rf already tolerates missing files (-f = force / no errors). The - prefix matters more for commands that actually might fail.


Phony alias targets

A common pattern: a short alias for a long target:

.PHONY: b build

b: build

build:
	go build ./...

make b is a shorter alternative to make build. The b target has no recipe — it just depends on build.


Running multiple targets

make clean build test

Runs each in order. Useful in CI pipelines and one-off commands.


Pattern: self-documenting Makefile

You can make make help automatically list targets with comments. The idiomatic pattern:

.PHONY: help
help: ## Show this help
	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)

.PHONY: build test clean

build: ## Build the application
	go build ./...

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

clean: ## Remove build artifacts
	rm -rf build/

Running make help:

  build                Build the application
  test                 Run all tests
  clean                Remove build artifacts

Any target with a ## description comment appears automatically. Add new targets; help updates itself.

This is in every good Makefile. We'll cover more of it in the next lesson.


Order-only prerequisites

Sometimes a prerequisite should exist but shouldn't trigger a rebuild. For example, a build directory:

build/app: main.c | build
	gcc -o $@ $<

build:
	mkdir -p build

The | separates normal prerequisites from order-only prerequisites. build must exist before build/app can be made, but changes to the build directory (e.g. mtime) don't force a rebuild of build/app.

Without |, Make would rebuild every time the directory's mtime changed (which happens every time a file inside it is modified).


Automatic variables

Inside a recipe, Make provides several special variables:

  • $@ — the target name
  • $< — the first prerequisite
  • $^ — all prerequisites (space-separated)
  • $? — prerequisites newer than the target
  • $* — the stem (used in pattern rules)

Example:

%.o: %.c
	gcc -c -o $@ $<

Reads: "to build any .o, use its .c as source. Output to $@ (the target), from $< (the first prereq)."

Replaces many hand-written rules. We'll cover pattern rules more in the next lesson.


What NOT to do in Makefiles

1. Don't use .PHONY for file targets

# WRONG — app is a file, not a command
.PHONY: app
app: main.o
	gcc -o app main.o

This forces app to rebuild every time, defeating the cache.

2. Don't chain shell commands with separate lines if they need shared state

Each recipe line is a separate shell invocation. So:

run:
	cd /tmp
	pwd     # prints your CWD, not /tmp

Chain them:

run:
	cd /tmp && pwd

Or use a .ONESHELL: directive:

.ONESHELL:
run:
	cd /tmp
	pwd     # now this is /tmp

.ONESHELL: makes all recipe lines in that target run in one shell invocation.

3. Don't put complex shell logic inline

# Hard to read
deploy:
	if [ "$(ENV)" = "prod" ]; then \
	  echo "deploying to prod"; \
	  kubectl apply -f manifests/prod; \
	else \
	  echo "deploying to dev"; \
	  kubectl apply -f manifests/dev; \
	fi

Better: extract to a shell script.

deploy:
	scripts/deploy.sh $(ENV)

Makefiles are for dependency graphs and trivial commands. Anything with branching belongs in a separate script.


The include directive

Split large Makefiles into parts:

# Main Makefile
include build.mk
include deploy.mk
include test.mk

Each file can contain its own rules and variables. Gets used in big monorepos.


Debugging a Makefile

make -n (dry run) shows what Make would run without running it:

make -n build
# gcc -c main.c
# gcc -c utils.c
# gcc -o app main.o utils.o

make -d (debug mode) shows how Make is walking the dependency graph:

make -d build 2>&1 | head -20
# Reading makefiles...
# Updating goal targets....
# Considering target file 'build'.
# ...

Noisy but useful for "why is this target rebuilding when I expect it not to?"


Full example

# Simple Go project Makefile

BINARY := myapp
GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*')

.PHONY: help build test lint clean install

.DEFAULT_GOAL := help

help: ## Show this help
	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)

build: $(BINARY) ## Build the binary

$(BINARY): $(GO_FILES)
	@echo "building $@"
	@go build -o $@ .

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

lint: ## Run linters
	@golangci-lint run

clean: ## Remove build artifacts
	@rm -f $(BINARY)
	@echo "cleaned"

install: $(BINARY) ## Install to /usr/local/bin
	@install -m 0755 $(BINARY) /usr/local/bin/

Reads clean. Has a self-documenting help. Uses a file target for the binary (caches correctly). Uses phony targets for commands.


Quiz

KNOWLEDGE CHECK

You add a test target to your Makefile: test: go test ./.... You run make test and it works. Someone else on your team creates a file called test in the repo. Now make test says make: test is up to date. Why?


What to take away

  • A Makefile is a dependency graph. Targets depend on prerequisites. Make rebuilds what's stale.
  • Tab characters before recipe lines. Not spaces. Get your editor right.
  • .PHONY targets are commands, not files. Declare them or Make will skip the recipe when a same-named file exists.
  • @ prefix silences the command echo. Use liberally for clean output.
  • - prefix ignores recipe errors. Useful in clean targets.
  • Automatic variables: $@ (target), $< (first dep), $^ (all deps), $* (stem).
  • .DEFAULT_GOAL := help to set the bare-make behavior.
  • Pair ## comment at the end of each target with a help-generating awk line for a self-documenting Makefile.
  • Complex logic belongs in shell scripts, not Makefile recipes.
  • Debug with make -n (dry run) and make -d (full trace).

Next lesson: variables, functions, and patterns — $(...) and $(shell ...) and the := vs = vs ?= distinction.