Make Developing Easier by Building a Beautiful Makefile

Make Developing Easier by Building a Beautiful Makefile

Emmanuel Byrd
Emmanuel Byrd

September 20, 2022

Makefile is one of those tools that software developers use without thinking much about, and so it's not improved the same way as the code. You might have seen a 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

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

				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

				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

				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)

				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

	@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

				@echo "Hello World" >> greeting.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:

  1. A virtual environment activator
  2. Installing dependencies
  3. A Linter
  4. If available, an automatic Lint fixer (like autopep8 for Python)
  5. Running the tests once
  6. Running the tests in listening mode (like jest --watch or guard)
  7. Compiling (i.e. go build)
  8. Launching the server
  9. Launching the server in hot-reload mode (like Next.js' yarn dev)
  10. 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:

  1. Running the tests in verbose mode with a different target (e.g., make test-ci).
  2. 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.

GREEN := $(shell tput -Txterm setaf 2)
YELLOW := $(shell tput -Txterm setaf 3)
WHITE := $(shell tput -Txterm setaf 7)
RESET := $(shell tput -Txterm sgr0)

## Show 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


.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"

	@echo "Type your automated tests script here, using a verbose mode"