Makefile Best Practices
GNU Makefiles are a convenient way for last-mile automation across multiple tool sets. We used to rely more heavily on Makefiles, but have since transitioned our usage predominantly into Atmos itself. That said, here is a collection of some of the best practices we’ve amassed over the years from extensively leveraging Makefiles.
Avoid using Evals
The use of $(eval ...)
leads to very confusing execution paths, due to the way make
evaluates a target. When make
executes a target, it preprocesses all $(....)
interpolations and renders the template. After that, it executes, line-by-line each command in the target.
Namespace targets
Over time, the number of targets in a Makefile
will grow. We recommend namespacing all targets.
For example:
docker/build:
docker build -t example/test .
Use /
as a target namespace delimiter
When naming target names, we recommend using /
as the delimiter rather than :
or -
. Further more, we recommend sticking all targets within a namespace into a separate file. E.g. Makefile.docker
for all targets that begin with docker/
.
For example, stick this in Makefile.docker
docker/build:
docker build -t example/test .
Avoid using :
in target names
While it's possible to use :
as the delimiter in target names, there is a big gotcha: it breaks target dependencies.
For example:
docker\:deps:
docker pull example/base-image
docker\:build: docker:deps
docker build -t example/test
In this example, make
will silently ignore calling the target dependency of docker:deps
. Escaping the target dependency (e.g. docker\:deps
) has no effect.
Use include
Avoid sticking every target in the same Makefile
for the same reason we don't stick all code in the same source file. We typically recommend adding something like this to the top of our Makefile
:
-include tasks/Makefile.*
The leading
-
tellsmake
not to error if thetasks/
folder is empty.
Define sane defaults for environment variables
No one likes to pass 20 arguments to make
. Set sane defaults for all variables using the ?=
operator.
For example:
DOCKER_TAG ?= latest
Pass Environment Variables like Function Arguments
The nice thing about make
is it will automatically export all arguments in key=value
notation as environment variables. This let's us call make
targets like functions.
e.g.
make docker/build DOCKER_TAG=dev
Write small targets
Make is an excellent language for gluing together various tools in your toolchain. It's an easy trap to stick an entire bash
script inside of a target. From experience, these targets become error prone and difficult to maintain for anyone but a seasoned make
programmer.
Instead, stick complex logic inside of shell scripts and call those shell scripts from a target.
Use target dependencies
A target can have dependencies called automatically prior to executing the target. If anyone of the dependencies fails, the execution aborts and the target will not be called.
For example:
deps:
@which docker
build: deps
@docker build -t example/test .
Use standard target names in root Makefile
The entry-level Makefile
should define these standard targets across all projects. This makes it very easy for anyone to get started who is familiar with make
.
deps
build
install
default
all
IMPORTANT: All leading whitespace should be tabbed (^T
)
Help Target
Our standard help
target. This will automatically generate well-formatted output for any target that has a ###
comment preceding it.
Simply add this code snippet to your Makefile
and you'll get this functionality.
## This help screen
help:
@printf "Available targets:\n\n"
@awk '/^[a-zA-Z\-\_0-9%:\\]+/ { \
helpMessage = match(lastLine, /^## (.*)/); \
if (helpMessage) { \
helpCommand = $$1; \
helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \
gsub("\\\\", "", helpCommand); \
gsub(":+$$", "", helpCommand); \
printf " \x1b[32;01m%-35s\x1b[0m %s\n", helpCommand, helpMessage; \
} \
} \
{ lastLine = $$0 }' $(MAKEFILE_LIST) | sort -u
@printf "\n"
Default target
Add this to the top of your Makefile
to automatically call help
if no target passed.
default: help