Makefile Guidelines¶
Write Makefiles that are correct under Make's own staleness model, not just "usually work." A target whose recipe silently gets skipped is worse than one that fails loudly.
.PHONY and Name Collisions¶
The generated Makefile declares .PHONY: phony once and lists phony as a prerequisite on
every target (mytarget: phony), so each target's own recipe always reruns without maintaining a
project-wide .PHONY list by hand.
This convention has a gap: it only forces the target's own recipe to always run. It does
nothing for a different target that depends on it. If a target's name collides with a real file
or directory in the repo, Make compares that other target's prerequisite freshness against the
colliding path's real, on-disk mtime — which does not necessarily change just because the tree
beneath it did. The result: a dependent target's recipe can be silently skipped while make
still reports success.
Any target whose name could collide with a real path in the repo — build, test, docs,
dist, image, or the literal value of $(BUILD_DIR)/$(REPORTS_DIR) — needs an explicit
.PHONY: <name> in addition to (not instead of) the target: phony convention.
Generator-Owned vs. User-Owned¶
The generated Makefile is generator-owned — never edit it directly, repo update overwrites
it. It ends with include Makefile.local, a separate, user-owned file: add project-specific
targets there instead. This gives an extension point without fighting repo update's
regeneration.
When to Extract Shell Logic into scripts/*.sh¶
A Makefile recipe body is never linted by anything — extracting it into a real .sh file is what
brings it under bash_coding.md's mandatory ShellCheck-in-CI requirement. Extract to
scripts/*.sh when any of the following apply, roughly in priority order:
- Lintability: the shell logic is non-trivial and would benefit from ShellCheck.
- Error handling: a pipe on one recipe line silently swallows an earlier command's exit code
unless
.SHELLFLAGSsetspipefail(see below). Once that's set project-wide, this reason is largely mitigated. - Real control flow: loops, multi-branch conditionals,
trap-based cleanup, argument parsing — beyond a simplecmd1 || cmd2fallback. - Reuse: identical logic needed by more than one target.
- Standalone testability/runnability: the logic is useful to run or test outside
make. - Line count: as a last-resort tie-breaker only, when none of the above clearly applies — roughly 15 lines of actual shell statements (excluding comments/blank lines; a backslash-continued command counts as one logical line).
None of these are about line count first. A purely linear ~10-statement recipe with no branching stays inline; a real script with actual control flow gets extracted regardless of whether a shorter version would technically fit inline.
The deeper principle is: use existing infrastructure and tooling before hand-rolling it yourself. Three concrete anti-patterns to avoid:
- Re-implementing in a Makefile or CI script something a CLI tool or the CI platform already does natively.
- Installing packages at pipeline runtime instead of using a pre-built image — see
ci_cd.md's rule on installing tools in pipeline jobs. - Hand-rolling a package-registry push via a complex script when the build tool (Conan, Maven,
npm, Go) or a single
curlcall already does it.
This is a refinement of general_coding.md's scripts/ red-flag rule: that guideline owns the
warning against over-using scripts/ and reinventing tooling; this section owns the concrete
criteria for when extracting to scripts/ is the right call. general_coding.md's
boilerplate-simple rule for S (Service) project types still applies on top of this — the
extraction criteria above matter most for CLI-tool, library, firmware, and infrastructure-repo
Makefiles, less for a typical business service's.
SHELL and .SHELLFLAGS¶
Make runs each recipe line via a fresh, non-interactive shell invocation with none of
bash_coding.md's set -euo pipefail applied by default. The generated Makefile sets
SHELL := bash and .SHELLFLAGS := -eu -o pipefail -c once, in its shared header, giving every
recipe that behavior for free — never redeclare it per recipe.
A recipe that defers a command's exit status past a diagnostic or report-copying step (so the
report is still produced on failure) cannot use a bare CMD; STATUS=$?; ...; exit $STATUS
sequence under -e: the bare CMD aborts the shell immediately on failure, before STATUS is
even captured. Use CMD || STATUS=$?; ...; exit "${STATUS:-0}" instead — a command on the left
of || is exempt from -e's immediate-exit behavior.
Smaller Rules¶
- Recipe lines are indented with a literal tab, never spaces — a classic footgun, especially if an editor or formatter silently converts them.
- Avoid recursive
$(MAKE)calls where an in-Makefile dependency would do — the mono-repo root is the legitimate exception, since it genuinely needs to invoke each entry's own Makefile. - Quote variable expansions, e.g.
"$(CURDIR)", consistent withbash_coding.md's quoting rule.
Directives¶
- Any target whose name could collide with a real path (
build,test,docs,dist,image, or the literal value of$(BUILD_DIR)/$(REPORTS_DIR)) needs an explicit.PHONY: <name>in addition to thetarget: phonyconvention - Generated
Makefileis generator-owned, never edit it; add project-specific targets toMakefile.localinstead - Extract recipe shell logic to
scripts/*.shfor lintability, proper error handling, real control flow, reuse, or standalone testability — not primarily by line count - Use existing tooling before hand-rolling it: don't reimplement what a CLI/CI platform already
does, don't install tools at pipeline runtime (
ci_cd.md), don't hand-roll a registry push a build tool already does general_coding.md'sscripts/red flag and boilerplate-simple rule forSproject types still apply — this guideline's extraction criteria is the complementary "when to extract" halfSHELL := bashand.SHELLFLAGS := -eu -o pipefail -care set once in the shared header; never redeclare per recipe; useCMD || STATUS=$?(notCMD; STATUS=$?) to defer a status past a diagnostic step under-e- Tabs, not spaces, for recipe lines
- Avoid recursive
$(MAKE)outside the mono-repo root - Quote variable expansions:
"$(CURDIR)"