Skip to content

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 .SHELLFLAGS sets pipefail (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 simple cmd1 || cmd2 fallback.
  • 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 curl call 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 with bash_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 the target: phony convention
  • Generated Makefile is generator-owned, never edit it; add project-specific targets to Makefile.local instead
  • Extract recipe shell logic to scripts/*.sh for 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's scripts/ red flag and boilerplate-simple rule for S project types still apply — this guideline's extraction criteria is the complementary "when to extract" half
  • SHELL := bash and .SHELLFLAGS := -eu -o pipefail -c are set once in the shared header; never redeclare per recipe; use CMD || STATUS=$? (not CMD; 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)"