GNU Make Recipes: Understand the Shell Behind Each Line
Control GNU Make recipe state, shell choice, failure detection, dollar escaping, and .ONESHELL behavior without relying on accidental process reuse.
A Makefile recipe is a small program assembled by two interpreters: Make expands its variables and functions, then a shell executes the resulting recipe text. GNU Make normally starts a separate shell for each logical recipe line. That means shell state such as the current directory, local variables, traps, and shell options does not automatically carry from one recipe line to the next. The recipe may look like a multiline shell script to a person reading the Makefile while behaving like several independent shell invocations.
The production-grade fix is not to add set -e everywhere and hope. First decide whether the rule is one shell program or a set of independent commands. For the former, use a checked-in script or GNU Make’s .ONESHELL feature with deliberate failure handling. For the latter, make every line independently correct and express dependencies with Make prerequisites rather than implicit shell state.
Each logical recipe line normally gets a shell
Consider this recipe:
report:
cd build
printf '%s\n' 'generated' > report.txt
Under GNU Make’s default execution model, the cd runs in one shell and the printf in another. The second shell starts in Make’s working directory, not in the directory selected by the first line. Likewise, a variable assigned on one line is not a shell variable on the next line. To make the directory change apply to a command, combine them into one logical recipe line:
report:
cd build && printf '%s\n' 'generated' > report.txt
The && makes the second command conditional on cd succeeding. That is materially safer than cd build; printf ..., which can write to the wrong directory if the change fails. A backslash-newline can continue a logical recipe line, but Make processes the recipe text before the shell sees it. Review what reaches the shell, especially when Make variable references and shell variables appear together.
Make variables use $(NAME) or ${NAME} syntax and are generally expanded before the shell runs. A shell variable such as $HOME must usually be written as $$HOME in a recipe so Make reduces $$ to a literal $. The shell then expands the variable in its own process. This also applies to $@, $?, and other shell variables that are not intended to be Make automatic variables. Make’s $@ means the target, so confusing the two expansion layers can silently construct the wrong command.
Choose a shell as part of the build contract
On Unix-like systems, GNU Make uses /bin/sh for recipes by default. The environment variable named SHELL is not automatically used as the recipe shell in the same way a user’s interactive shell setting might suggest. Set SHELL in the Makefile when a rule deliberately requires Bash, and set .SHELLFLAGS to the options that interpreter should receive. Do not write Bash arrays, [[ ... ]], pipefail, or process substitution into a recipe that GNU Make will send to a generic /bin/sh.
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
check:
printf '%s\n' "$${BASH_VERSION:?Bash is required}"
The example has two separate choices: the interpreter path and its flags. -e is useful but is not a universal exception mechanism. Bash’s errexit behavior has contexts where a failing command does not terminate execution, including conditional lists and some function-call contexts. Use && or explicit if blocks for dependencies, and capture statuses where a step must continue collecting diagnostics.
Make’s $(shell command) function is a different execution point from a recipe. Make evaluates it while expanding a variable or rule, captures its standard output, and removes trailing newlines from that result. It does not run in the shell process that will later execute the recipe, and its output is not an argv array. Keep side-effecting operations out of parse-time expansion: a variable may be expanded more than once, a dry run may still cause expansion, and parallel builds make hidden ordering particularly difficult to reason about. For a command that belongs to a target’s lifecycle, put it in the recipe or a dedicated prerequisite rather than hiding it inside a Make function.
Changing SHELL can affect developer machines and CI differently if a tool path is absent. Prefer POSIX syntax where that is sufficient, document a Bash minimum when a Bash feature is required, and make the toolchain requirement fail clearly. GNU Make’s .SHELLFLAGS are also not a portable feature across every make implementation. A project that supports BSD make or another implementation needs a separate compatibility decision instead of assuming GNU extensions.
Use .ONESHELL only when one shell is the intended model
GNU Make’s .ONESHELL: special target passes all recipe lines for each target to a single shell invocation and preserves newlines between them. That enables local variables, cd, traps, and shell functions to persist across lines. It can also reduce shell startups for recipes with many lines. It is a GNU Make feature, not a POSIX make guarantee.
.ONESHELL:
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
package:
work=$$(mktemp -d)
trap 'rm -rf -- "$$work"' EXIT
cd "$$work"
/usr/bin/tar -xf "$(SOURCE_ARCHIVE)"
/usr/bin/make -C source package
This model makes the recipe’s state intentional: one temporary path, one cleanup trap, and a current directory reused by later lines. The code still must validate SOURCE_ARCHIVE and the command’s assumptions. Also note that $(SOURCE_ARCHIVE) is expanded by Make, while $$work becomes a shell variable reference after Make processes the recipe.
.ONESHELL changes failure behavior. Without it, Make generally observes the exit status of each recipe line and stops on a failing line unless the line is prefixed with - or errors are otherwise ignored. With one shell, Make sees the shell’s final exit status; an early failure can be hidden if a later command succeeds. Adding -e helps in common cases but does not make every compound shell program fail-fast. Use explicit || exit, &&, or status checks at critical transitions and keep cleanup paths from overwriting the original failure status.
The special recipe prefixes @, -, and + have their own behavior. With .ONESHELL, GNU Make only interprets those prefixes on the first recipe line; later lines are passed to the shell, subject to documented compatibility behavior for POSIX-style shells. Do not rely on adding @ to every internal line to hide output. Put an initial @: or structure the target in a way that makes the intended echo policy clear, then inspect make --trace or a dry run.
Do not confuse shell state with Make dependency state
A shell variable is process-local state. A Make prerequisite is a dependency relationship in the build graph. If one target needs an output produced by another, declare the prerequisite and file relationship instead of setting an environment variable in one target and expecting another recipe to inherit it. Make may run independent targets in parallel; shell state cannot provide ordering between those rules.
Similarly, a recipe that mutates the current directory should not rely on the caller’s shell having cd-ed into a particular location. Make launches commands from its working directory, which can be changed using -C or influenced by included makefiles and recursive builds. Use an explicit working-directory command inside the same logical shell line, or compute stable paths from Make variables. Print the paths in diagnostic mode before allowing a cleanup or publish operation to use them.
Avoid fragile line-level error suppression. A leading - tells Make to ignore that recipe line’s nonzero result; it does not mean “ignore only the expected missing-file case.” Prefer an explicit conditional such as if test -e path; then ...; fi or a command-specific error check. When a build should always collect independent diagnostics, record each result and return a deliberate aggregate status rather than continuing silently.
Test the recipe in the same execution model
make -n displays many commands without running them, which is useful for examining variable expansion but is not a complete semantic test. It can still execute certain Make functions or recursive make behavior, so treat dry-run output as a review aid rather than a sandbox. Use make --warn-undefined-variables, make --trace, and a disposable fixture for diagnosis. Confirm which shell path and flags the build actually invokes when recipe behavior differs between local and CI environments.
Test failure cases deliberately: a missing working directory, an unavailable executable, a failing command in the middle of an .ONESHELL recipe, a failing final command, a path containing spaces, and an unset shell variable. Verify that cleanup runs and that the build returns nonzero on the failures that matter. For artifacts, write to a temporary destination and rename only after validation so a failed recipe cannot publish a partial result.
Recursive Make adds another process boundary. A sub-make may inherit jobserver descriptors and selected variables, but it does not inherit the parent recipe shell’s current directory or local shell variables. Use $(MAKE) rather than a literal make in recursive recipes so GNU Make can propagate relevant invocation options and coordinate parallelism. Avoid exporting a shell’s entire mutable environment as an undocumented API; pass only the values the sub-build needs through Make variables or explicit environment assignments. When -j is active, ensure prerequisite relationships express every ordering requirement before relying on a parent recipe to sequence targets.
For reproducibility, record the GNU Make version and selected shell in CI output, and run the same target from both the repository root and an out-of-tree build directory if the project supports one. A recipe that succeeds only because it reads a relative file from the developer’s current directory has an undeclared dependency. Prefer paths derived from Make variables and source locations, then test with a clean checkout and a clean build directory. The goal is not to force every rule into one shell; it is to make the process boundary, state lifetime, and failure contract visible to the person maintaining the build.
If the recipe grows into a complex program with branches, loops, signal handling, or reusable functions, move it into a checked-in script and invoke it with an explicit interpreter. Make is strongest when expressing targets, prerequisites, and incremental rebuild logic. Shell is strongest when expressing process orchestration. Keeping that boundary visible makes failures easier to reproduce and prevents an accidental shell process model from becoming part of the build system.
Related:
- Bash Pipeline Status: pipefail, PIPESTATUS, and Reliable Error Checks
- How to Write Robust, Portable POSIX Shell Scripts
Sources: