Makefile Conventions
Use Make as the universal command interface. All project
operations are accessible via make, regardless of the
underlying implementation language.
Workflow
Investigate the project — Before writing targets, check:
- Does a Makefile already exist? Read it.
- What scripts, tools, or build steps does the project use?
- Is there an existing
mk/directory?
Decide placement — If the target belongs to an existing concern group, add it there. If it starts a new concern, create a new modular makefile. See makefile-structure for the full modular pattern.
Write the target — Follow these conventions:
- Declare every target
.PHONYunless it produces a file - Add a
## Descriptioncomment for the help system - Delegate complex logic to scripts
- Use
@prefix for clean output - Quote variable references:
"$(VAR)"
- Declare every target
Update help — Add the new target to the appropriate concern group in
make helpoutput.Verify — Run
make helpto confirm the target appears. Run the target to confirm it works.
Complete Example
Root Makefile pattern — includes domain modules and provides grouped help:
.DEFAULT_GOAL := help
include mk/test.mk
include mk/deploy.mk
.PHONY: help
help: ## Show this help
@echo "Project - Make Commands"
@echo "======================"
@echo ""
@echo "Testing:"
@echo " make test - Show test help"
@echo " make test sh - Run shellcheck"
@echo ""
@echo "Development:"
@echo " make check - Check prerequisites"
For the full modular delegation pattern (domain .mk
files, subdirectory Makefiles, subcommand delegation), see
makefile-structure.
Conventions
Structure
- One main
Makefileat the project root - Modular makefiles in
mk/for each concern domain - Domains with subcommands get
mk/<domain>/Makefile .DEFAULT_GOAL := helpso baremakeshows help- Never use
%: @:catch-all patterns. Use conditional explicit subcommand declarations instead (see Subcommand Delegation below).
Naming
- Targets: lowercase, hyphenated for multi-word
- Subcommands: space separation (
make test sh) - Variables: UPPER_CASE
Help system
makeormake helpshows all commands grouped by concernmake <domain>shows domain-specific help- When a domain has 2 or more subcommands, bare
make <domain>must show subcommand help. Do not make it execute a default action.
Delegation
Makefiles are the interface, not the implementation:
deploy:
@./scripts/deploy.sh "$(ENV)"
Subcommand delegation
When a domain uses $(MAKE) -C for space-separated
subcommands (make db migrate), Make sees migrate as a
separate goal and errors. Solve this with conditional
explicit targets — not a catch-all:
.PHONY: db
db:
@$(MAKE) -C mk/db $(filter-out $@,$(MAKECMDGOALS))
# Only activate when 'db' is on the command line
ifneq ($(filter db,$(MAKECMDGOALS)),)
.PHONY: migrate rollback
migrate rollback:
@:
endif
This ensures:
make db migrate— works (migrate is a known no-op)make db typo— errors (typo has no rule)make typo— errors (conditional is inactive)
Never use %: @: — it swallows all unknown targets
silently, making typos invisible.
Example Scenario
User: "Add a make target for running database migrations"
- Reads existing Makefile — finds
mk/structure - No existing
db.mk— createsmk/db.mkandmk/db/Makefilewithmigrate,rollback,status - Adds
include mk/db.mkto main Makefile - Adds Database section to
make helpoutput - Verifies:
make db migrateworks
Common Failures
- No help text — targets without descriptions are
undiscoverable. Always update
make help. - Logic in Makefiles — complex bash in a target is hard to debug. Delegate to scripts.
- Catch-all swallows errors —
%: @:silently succeeds for ANY unknown target, including typos within domains (make skills typo). Never use a blanket catch-all. Instead, declare valid subcommands explicitly inside conditional blocks.