Make
or a Makefile
in the root folder when you joined a codebase, and you might even find that there are make
commands in your CI pipeline. Makefile is a powerful tool that has survived the passage of time because, in simple words, it makes the life of developers easier.
However, without knowing enough about it you might not be able to continue making other people's life easier, and might just stick with the file that already exists in your organization. Learning its basics is not too complex, and it will give you a lot of power to simplify commands and help others get to work in the codebase.
- Section 1: What is Makefile
- Section 2: Documenting Your Makefile
- Section 3: Make Everything Come Together
Section 1: What Is Makefile
Makefile
was initially designed to automate the creation (or making) of files, thus the name Make
file
. The idea is simple: add the name of the file that you want to create, named the target, followed by the shell commands that create it.
# Makefile
greeting.txt:
echo "Hello World" >> greeting.txt
Tip: Be careful when copy pasting this text! Indentation in a Makefile has to be done with tabs, not with spaces.
Add these contents into a Makefile
in an empty directory and call make greeting.txt
, and it will create a text file with the content Hello World. The target, greeting.txt
, is followed by a semicolon, and the next two lines are the recipe for creating that file. Running this target a second time will generate the following message:
make: `greeting.txt' is up to date.
That is because the file greeting.txt
, which was created in the first run, already exists. If the name of the Makefile target already exists as a file or directory, then Makefile will not execute the recipe. If you get a weird error when naming your target src
or test
, it is likely because you happen to have an existing directory named src/
or test/
.
Section 1.1: Makefile Prerequisites
Because creating some files will imply the previous creation of some other files, Makefile comes with prerequisites. For example:
# Makefile
greeting.txt:
echo "Hello World" >> greeting.txt
happy.txt: greeting.txt
echo $$(cat greeting.txt) "I am happy today." >> happy.txt
Tip: Notice the
$$(...)
syntax? Makefile uses the dollar sign$
for internal variables. Thus, it needs two dollar signs to mimic what we would execute in the terminal with only one:> echo $(cat greeting.txt) "..." >> happy.txt
.
The target happy.txt needs the prior existence of the file greeting.txt, which can be created using Makefile. If the prerequisite is missing (i.e., the greeting.txt file does not exist), it will call its target and then call the target happy.txt whether it exists or not. That is because, if the prerequisite has to be changed, then the current target should be expected to have a change as well. However, the reverse is not true: If the prerequisite exists but not the target, then it will just execute the target. It works in a cascade fashion.
Section 1.2: Makefile Arguments
Target calls can also take arguments, and those arguments will be set from the terminal in the form of argument=value
. For example:
# Makefile
greeting.txt:
echo "Hello World" >> greeting.txt
i-am.txt: greeting.txt
echo $$(cat greeting.txt) "I am $$(name)" >> i-am.txt
Now you can type in the terminal:
make i-am.txt name=John
And the generated i-am.txt
file will contain the words "Hello World I am John."
Section 1.3: Makefile Variables
When multiple recipes share some content, you can refactor them to be simpler by using a variable that is declared with the syntax variable := value
.
# Makefile
greeting_content := $$(cat greeting.txt)
greeting.txt:
echo "Hello World" >> greeting.txt
happy.txt: greeting.txt
echo $$(greeting_content) "I am happy today." >> happy.txt
i-am.txt: greeting.txt
echo $$(greeting_content) "I am $$(name)" >> i-am.txt
As it is a variable, greeting_content
and name
have to be accessed using Makefile's $(...)
syntax.
Plain strings will work in variables as well. For example: ignore_files := node_modules/ tmp/
allows using $(ignore_files)
to be used in any command where you want to stub the string node_modules/ tmp/
. If the first two sections felt trivial, this example is beginning to reveal how Makefiles can stack together in useful ways.
Section 1.4: Phony Targets
The definition of phony is something that is not genuine. Phony targets are targets that do not create a file. They are called phony because they are not doing what Makefile was supposed to be doing: creating a file. But we make our own rules, and that is fine.
Imagine you have a folder /test
in your root directory that contains all your test files. It makes sense to create a make test
target to execute its automated tests. In order to prevent creating a file each time you want to run your tests, add the following line somewhere in your Makefile:
# Makefile
.PHONY: test # Now the `test` target is not expected to create a file
test: # If you have a /test folder, this target can only be executed as a phony target
echo "Write your test script here"
Take a look at your most recently used Makefile in your organization, and you might notice that there is very little use of phony targets. Why is that? Because there is no file or folder that matches the target names, and their recipes do not create such files either. Is that bad news? Not really. Makefile is just making one extra step: checking that the file or folder exists, every time you call the given targets.
Section 1.5: Default Target
The default target can be called from the terminal with just make
. The default target is the first target in the makefile (not counting targets that start with a period). In the examples so far, the default target will be greetings.txt
. This behavior can be overriden with the following line:
# Makefile
.DEFAULT_GOAL := happy.txt
greeting.txt: # This is the first target
echo "Hello World" >> greeting.txt
happy.txt: greeting.txt # But now `happy.txt` is the default target
echo $$(cat greeting.txt) "I am happy today." >> happy.txt
If you type make
from the terminal, it will execute make happy.txt
.
Section 1.6: Cleaning Output
Makefile will print in the terminal each line of the recipe as it is executed. In the previous examples, you should see something like:
~ % make i-am.txt name=John
echo "Hello World" >> greeting.txt
echo $(cat greeting.txt) "I am John" >> i-am.txt
Each of those lines is part of the recipe of the target i-am.txt
. Prepending echo
with the symbol @
will prevent those lines from being printed:
# Makefile
greeting.txt:
@echo "Hello World" >> greeting.txt
happy.txt: greeting.txt
@echo $$(cat greeting.txt) "I am happy today." >> happy.txt
Now it will execute the targets silently.
Section 1.7: Calling Other Make Targets
Sometimes recipes include other targets. To call them, you need to use the MAKE
special variable. This prevents Makefile from recursively executing itself from outside its own context.
# Makefile
greeting.txt:
@echo "Hello World" >> greeting.txt
happy.txt:
$(MAKE) greeting.txt
@echo $$(cat greeting.txt) "I am happy today." >> happy.txt
Now the happy.txt
target executes make greeting.txt
as part of its recipe.
Section 2: Documenting Your Makefile
If you are using Makefile — for example, in a backend project — you may want to automate multiple things. Here are some of the automations that I love to have in my projects:
- A virtual environment activator
- Installing dependencies
- A Linter
- If available, an automatic Lint fixer (like autopep8 for Python)
- Running the tests once
- Running the tests in listening mode (like jest --watch or guard)
- Compiling (i.e. go build)
- Launching the server
- Launching the server in hot-reload mode (like Next.js' yarn dev)
- Handling the database: create, drop, migrate, rollback and seed (like rails db:create)
And going even further, by making Makefile useful in CI and CD pipelines:
-
Running the tests in verbose mode with a different target (e.g.,
make test-ci
). - Deploying to AWS or GoogleCloud.
The Makefile can start getting more complex incredibly fast, and it helps new developers on your team if you can document all of these targets that make everyone's life easier. I recommend adding a help
target that will print all the targets in the makefile with a helpful message.
Makefile does not have this functionality embedded into it, but it can be created using grep
, awk
, and $(MAKEFILE_LIST)
. There are many cool help targets that will help you keep your Makefile documented while allowing it to print the targets with their description.
This example prints a general purpose comment followed by each target and its documentation, which is added by appending ## Description
in the same line of each target.
# This is a regular comment, that will not be displayed
## ----------------------------------------------------------------------
## This is a help comment. Write general purpose documentation here!
## ----------------------------------------------------------------------
help: ## Show this help.
@sed -ne '/@sed/!s/## //p' $(MAKEFILE_LIST)
This other minimal example prints each target and its description in the same line.
help: ## show help message
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[$$()% 0-9a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
Another example prints a Usage section and a Targets section using different colors.
# COLORS
GREEN := $(shell tput -Txterm setaf 2)
YELLOW := $(shell tput -Txterm setaf 3)
WHITE := $(shell tput -Txterm setaf 7)
RESET := $(shell tput -Txterm sgr0)
TARGET_MAX_CHAR_NUM=20
## Show help
help:
@echo ''
@echo 'Usage:'
@echo ' $(YELLOW)make$(RESET) $(GREEN)<target>$(RESET)'
@echo ''
@echo 'Targets:'
@awk '/^[a-zA-Z\-\_0-9]+:/ {
helpMessage = match(lastLine, /^## (.*)/);
if (helpMessage) {
helpCommand = substr($$1, 0, index($$1, ":")-1);
helpMessage = substr(lastLine, RSTART + 3, RLENGTH);
printf " $(YELLOW)%-$(TARGET_MAX_CHAR_NUM)s$(RESET) $(GREEN)%s$(RESET)\n", helpCommand, helpMessage;
}
}
{ lastLine = $$0 }' $(MAKEFILE_LIST)
This last example is interesting for our purposes. The colors are variables declared inside the Makefile. Each of those variables will be a shell
execution by using the shell
function. Another way of setting variables is by using just =
as in the TARGET_MAX_CHAR_NUM
variable declaration. Then comes the comment that will be used as the documentation string, followed by the help
target with no prerequisites. All the magic happens inside help's recipe, which uses the @
syntax to avoid printing the execution lines in the terminal. It accesses the variables using $()
and ${}
syntax, which are syntactically similar. And finally, it uses the $(MAKEFILE_LIST)
special variable to obtain the name of the current file.
Section 3: Make Everything Come Together
This article provides enough knowledge to build a beautiful Makefile, and the basic tools to go above and beyond on your scripting skills and improve the ones you're already using.
There is a lot more to know about Makefile — including environment variables, changing the name of the file, or including other Makefiles. Most of the heavy lifting, though, will come from the shell scripting knowledge.
Making everything come together, this is an example of what I would be aiming for in my projects:
# Makefile
.DEFAULT_GOAL := help
.PHONY: help db-init db-create migrate db-migrate test test-ci
help: ## show help message
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[$$()% 0-9a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
db-init: ## Creates and migrates the database
$(MAKE) db-create
$(MAKE) db-migrate
db-create: ## Creates the database
@echo "Type your database creation script here"
migrate: db-migrate ## Alias for db-migrate
db-migrate: ## Applies the database migrations
@echo "Type your database migration script here"
test: ## Runs all the tests
@echo "Type your automated tests script here"
test-ci:
@echo "Type your automated tests script here, using a verbose mode"