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)))orMAKEFILE_LISTto 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
makeis 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.