# Make - Portability Traps
A Makefile that runs on one machine fails on another for predictable reasons. macOS ships make 3.81, recipes default to `/bin/sh`, and the tools that recipes call differ between BSD and GNU variants. Pin the interpreter, run gates with GNU make where the project needs it, and prefer the portable form of each command.
## Key points
- macOS stock make 3.81 cannot parse `$(shell …)` constructs. It also mishandles `.ONESHELL` `@` prefixes: a `$` variable resolves empty and the recipe fails with a confusing `command not found`. A build made this way can produce an artifact that differs from the GNU make output. Run repo gates with GNU make (`gmake`). Stock-make failures look like repo bugs.
- Without a `SHELL` override, make runs recipes with `/bin/sh`. On Ubuntu CI this interpreter is dash. A `[[ -f … ]]` guard then fails as `command not found` and falls through, so the recipe repeats its work on every run. Fresh CI checkouts mask this failure.
- `sed` pipe chains behave the same on macOS BSD sed and GNU sed. `sed -i` does not. The in-place flag takes a different argument on each implementation. Use pipe chains for edits that must run on both platforms.
- ShellCheck reads a Makefile as shell. It parses `ifndef` and `ifeq` directives as shell commands, flags substitution-reference loops as unused variables, and rejects the `@(cd …)` silence prefix. Explicit per-target command lists were the only Makefile form that passed this static analysis.
- A recipe line that ends in `; \` and a next line with a bare `fi` form a shell syntax error. Make joins recipe lines into one `sh -c` command. The target then silently never runs, and green CI checks do not catch it.
- Pass a regex through as `-run '$(value VAR)'`. Plain `$(VAR)` silently drops a trailing `
anchor. A dropped anchor breaks a disjoint test-shard partition: `^(TestFoo|TestBar)` then also matches `TestFooBar`.
- Guard a clone target with an existence check on the `.git` directory, for example `if [ ! -d "$(DIR)/.git" ]`. The guard makes the target idempotent. Clone test repositories with `git clone --depth 1 --no-tags` when they need neither history nor tags.
- `make clean && make check` is the gate for generated resources. It proves that Make dependencies recreate the ignored artifacts from a clean tree. A dirty tree can hide a missing dependency.
## How to apply
1. Run repo gates with `gmake` on macOS. Do not trust stock make 3.81 with `.ONESHELL` or `$(shell …)`.
2. Set `SHELL` in the Makefile. Recipes then run the interpreter you choose, not `/bin/sh`.
3. Replace `sed -i` edits with `sed` pipe chains.
4. Write each recipe line as a complete shell command. Do not end a line in `; \` when the next line starts with `fi`.
5. Pass regexes with `$(value VAR)`, never `$(VAR)`.
6. Give clone targets a `.git` existence guard and use `--depth 1 --no-tags`.
7. Gate generated resources with `make clean && make check`.
8. Prefer explicit per-target command lists when a checker reads the Makefile.
## Observations
- [tooling] macOS make 3.81 mishandles `.ONESHELL` recipes and cannot parse `$(shell …)`. Use GNU make for repo gates. #make (source: agent sessions, 2026-09)
- [shell] Recipes default to `/bin/sh`, which is dash on Ubuntu CI. Pin `SHELL` so guards do not fall through. #make (source: agent sessions, 2026-10)
- [portability] `sed` pipe chains are portable between BSD sed and GNU sed. `sed -i` is not. #make (source: agent sessions, 2026-03)
- [static-analysis] ShellCheck reads make directives as shell. Explicit per-target command lists pass. #make (source: agent sessions, 2026-08)
- [make] A recipe line that ends in `; \` before a bare `fi` makes the target silently never run. #make (source: agent sessions, 2026-11)
- [testing] `$(value VAR)` keeps a trailing `
anchor that plain `$(VAR)` drops. #make (source: agent sessions, 2026-07)
- [git] A `.git` existence guard makes clone targets idempotent. `--depth 1 --no-tags` fits test repositories. #make (source: agent sessions, 2025-09)
- [build] `make clean && make check` proves that Make dependencies recreate generated resources. #make (source: agent sessions, 2026-08)
## Relations
- relates_to [[resources/documentation/Shell - BSD and GNU Tools on macOS]] (the same BSD/GNU tool split makes `sed` and other recipe commands non-portable)
- relates_to [[resources/documentation/Shell - Zsh and Bash Differences]] (make recipes run `/bin/sh` unless you pin `SHELL`, so shell dialect differences apply inside recipes)
- relates_to [[resources/documentation/CI - Failure Triage and Silent Non-Runs]] (several make traps fail silently and stay hidden behind green CI)
- relates_to [[resources/documentation/Go/Go - Toolchain Pinning and the Build Cache]] (pin the make implementation instead of trusting the system default, same as the Go toolchain)
## Sources
Personal agent-session notes, September 2025 to November 2026