A new contributor clones the repository, and the first question is always the same: how do I actually run this? A good Makefile answers that in one command, and stops the answer living in five scattered README sections.
Make is old, and most of what is written about it is either a compiler tutorial from 1998 or a warning to use something newer. Neither is much help if what you want is mundane and valuable: a single, discoverable entry point to a project that works the same whether the codebase is Python, Go, or a pile of shell scripts. make install, make lint, make test, make build — the same four verbs on every repository your team owns. That uniformity is the point. It is also where teams get the mechanics subtly wrong in ways that bite later.
The Makefile as the project’s front door
Treat the Makefile as an interface, not a build system. The contributor should not need to know that linting is ruff, that tests run under pytest, or that the build calls a Python module. They need to know that make lint exists and does the obvious thing. When a project migrates from one tool to another — and it will — the interface stays put and only the recipe underneath changes. That indirection is worth more than it looks: it is the same discipline that makes a pre-commit baseline and a CI pipeline call identical commands, so the check a developer runs locally is exactly the one that runs in the pipeline.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
The whole thing fits on a page. Here is a complete, working Makefile for a typical Python project, with the self-documenting help target that makes it discoverable.
# Makefile — single entry point for the project
.DEFAULT_GOAL := help
.PHONY: help install lint test build clean
# Fail fast: strict bash, one shell per recipe
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
VENV := .venv
PY := $(VENV)/bin/python
install: ## Create the virtualenv and install dependencies
python3 -m venv $(VENV)
$(PY) -m pip install --upgrade pip
$(PY) -m pip install -r requirements.txt
lint: ## Run static analysis and formatting checks
$(PY) -m ruff check .
test: ## Run the test suite
$(PY) -m pytest -q
build: ## Build the distributable artefact
$(PY) -m build
clean: ## Remove build artefacts and caches
rm -rf dist build .pytest_cache
help: ## Show this help
@awk 'BEGIN {FS = ":.*## "; printf "Available targets:\n"} \
/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' \
$(MAKEFILE_LIST)
The self-documenting help target
Run make with no arguments and you get the help output, because .DEFAULT_GOAL := help makes that the default. The trick is the double-hash comment convention. Every target that a human should call carries a ## description on its recipe line. The help target then runs a one-line awk programme over $(MAKEFILE_LIST) — the list of Makefiles Make has read — extracting each target name and its description into an aligned, colourised list.
Two details matter. The field separator FS = ":.*## " splits each line at the colon-then-comment marker, so the target name lands in $$1 and the description in $$2. The doubled dollar sign is not a typo: a single $ is a Make variable reference, so anything meant for the shell or awk must be written $$. Internal targets you do not want advertised simply omit the ## comment and never appear. The documentation lives one space away from the code it describes, which means it stays accurate — a description that sits next to the recipe is far harder to leave stale than a paragraph in a README.
Getting .PHONY right
This is the failure mode that catches everyone at least once. Make is, at heart, a tool for building files. A target named test is, to Make, a promise to produce a file called test. If a file or directory named test exists in the repository — and in a great many projects, a test/ directory does — Make looks at it, decides it is already up to date, and prints make: 'test' is up to date. without running anything. The recipe silently does not execute. In CI, that reads as a green build that tested nothing.
.PHONY: help install lint test build clean is the fix. It tells Make that these targets are not files, so it must run their recipes every time, regardless of what exists on disk. Declare every target that represents an action rather than a produced file. The habit worth building is to add a target’s name to the .PHONY line in the same edit that creates the target — because the day it collides with a real filename is the day you least expect it, and the failure is quiet.
Variables, .ONESHELL and the tab that isn’t a tab
By default, Make runs each line of a recipe in its own separate shell. So a recipe that changes directory on one line and runs a command on the next does not do what it reads as doing — the second line starts back where it began. .ONESHELL: changes that: the whole recipe runs in a single shell invocation, so state carries across lines the way anyone reading it would expect. Pairing it with SHELL := bash and .SHELLFLAGS := -eu -o pipefail -c means a recipe aborts on the first failing command instead of ploughing on and reporting success. Both features are well established; .ONESHELL has been in GNU Make since 3.82, and there is no reason not to use it on any modern install, GNU Make 4.4 included.
Use := for variable assignment, not =. The := form expands once, immediately; the plain = form is recursive and re-expands every time the variable is used, which is both slower and a source of genuinely baffling bugs when a variable references itself. And the one that ends careers with a text editor: recipe lines must be indented with a literal tab, never spaces. An editor that silently converts tabs to spaces produces the infamous *** missing separator error. Configure the repository’s .editorconfig to keep tabs in Makefiles and the problem disappears.
None of this is clever, and that is exactly why it works. A Makefile that anyone can drive with make help, that runs its recipes reliably because .PHONY is honest, becomes the same uniform interface across every repository a team maintains — the sort of consistency that quietly improves delivery flow because nobody loses twenty minutes working out how to run the project. The best build tooling is the kind a new contributor never has to ask about.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.