How to idiomatically handle file inclusion in Makefile

Summary

This incident stemmed from non‑portable relative include paths in a multi-level Makefile hierarchy. Each Makefile assumed a different working directory, causing include directives to resolve incorrectly depending on where make was invoked. The result was a missing file error for second.mk when building from deeper directories.

Root Cause

The root cause was path resolution based on the current working directory, not the location of the including file. GNU Make resolves include paths relative to:

  • The directory where make is invoked, unless
  • The included file itself uses $(dir $(lastword $(MAKEFILE_LIST))) or MAKEFILE_LIST to compute its own directory

Because the same first.mk was included from two different directory depths, its internal include ../dir4/second.mk was correct for one caller but wrong for the other.

Why This Happens in Real Systems

This is a classic Makefile pitfall because:

  • Relative paths are interpreted relative to the invocation directory, not the file’s directory
  • Shared include files are reused across multiple directory levels
  • Developers assume include paths behave like C #include, but Make does not work that way
  • Large build trees often evolve organically, accumulating fragile path assumptions

Real-World Impact

This type of issue commonly leads to:

  • Build failures depending on where make is run
  • Non-reproducible builds across CI, local dev, and automation
  • Hard-to-debug path errors that appear only in certain directories
  • Inconsistent include behavior when files are moved or reorganized

Example or Code (if necessary and relevant)

Below is the idiomatic fix: compute the directory of the current Makefile using MAKEFILE_LIST and include files relative to that directory.

mk_dir := $(dir $(lastword $(MAKEFILE_LIST)))
include $(mk_dir)/second.mk

This ensures second.mk is always resolved relative to the location of first.mk, regardless of where make is invoked.

How Senior Engineers Fix It

Experienced engineers avoid fragile relative paths by:

  • Using $(dir $(lastword $(MAKEFILE_LIST))) to compute the directory of the current file
  • Defining a canonical root directory and referencing everything relative to it
  • Avoiding deep relative paths (../../..) entirely
  • Centralizing include logic so each file resolves its own dependencies
  • Ensuring includes are location‑independent, making builds reproducible

Why Juniors Miss It

This issue is subtle because:

  • They assume Make resolves includes like compilers do
  • They don’t know about MAKEFILE_LIST, which is essential for robust includes
  • They test builds only from one directory, missing cross-directory failures
  • They underestimate how Make’s working directory affects path resolution
  • They rely on trial-and-error path tweaking instead of deterministic solutions

Key takeaway: Always resolve include paths relative to the including file, not the invocation directory.

Leave a Comment