4 Levels of How To Use Makefile

I love Makefile. Today, I use Makefile for most of the projects that I am working on. You may have seen Makefile in many open-source projects on GitHub (e.g. this). Like me, you have probably wondered what Makefiles are and what they do.

There is a bazillion of incredible Makefile tutorials out there. My goal is to get you interested enough to start using Makefile in under 5 minutes. By the end, you should know enough to start using and self-learn about Makefile.

TL;DR: read Level 1 & Level 2

What Are Make and Makefile

In short, Makefile is a special format file with rules that tells the GNU Make utility tool (i.e. make) how to execute commands that run on *nix. Typically, Make is used to compile, build, or install the software.

While Makefile is commonly used to compile C or C++, it is NOT limited to any particular programming language. You can use Make for all sorts of stuff:

  • Execute a chain of commands to set up your dev environment
  • Automate build
  • Run test suites
  • Deployments, etc.

Why Use Makefile

Compiling source code can be cumbersome, especially when you have to include multiple source files.

At its core, Makefile is a utility for writing and executing a series of command-line instructions for things like compiling code, testing code, formatting code, running code, etc.

Basically, it helps to automate your development workflows into simple commands (make build, make test, make format, make run).

Also:

  • make is preinstalled at most *nix systems that you can find
  • make is programming language/framework agnostic

Alright enough talking, let’s get to the real deal.

A Quick Guide to Makefile

While going through the levels below, I highly encourage you to create a Makefile (note: always name it Makefile) and try things out yourself.

Level 1: “Tell me all I need to know”

At this level, you’ll learn the basics of Makefile; probably all you ever need to know to benefit from it.

Suppose you have a project using that uses Docker. To build and run your app iteratively using a Docker container, you would typically:

  1. Run docker build
  2. Make sure there are no running container
  3. Run docker run
  4. Repeat

To do this yourself:

docker build -t image-name . --build-arg ENV_VAR=foo
docker stop container-name || true && docker rm container-name || true
docker run -d -e ANOTHER_VAR=bar--name container-name image-name

What a hassle! There is a lot to remember and type. On top of that, many chances to make silly mistakes.

Sure, you could just run all 3 commands every time you make changes. That would work. However, it is just so inefficient. Instead, we could write a Makefile just like the below:

all: build stop run # build -> stop -> run

build:
	@docker build -t image-name . --build-arg ENV_VAR=foo

stop:
	@docker stop container-name || true && docker rm container-name || true

run:
	@docker run -d -e ANOTHER_VAR=bar--name container-name image-name

Now to build and run a new Docker image, all you need is a single make all command. In the case above, we can just call make because all is the first rule (note: the first rule is selected by default).

To summarize, a rule within a Makefile generally looks like this:

# run `make <target>` to run this rule
<target>:	<prerequisite 1> <prerequisite 2> <prerequisite N> # a comment block is prefixed with a "#"
	<command 1>
	<command 2>
	<command N>

Takeaways for Level 1:

  1. <target> — typically file name; could be any name
  2. <command> — typically *nix commands/steps used to make the <target>. These MUST start with a TAB character
  3. <prerequisite> — optional. This tells make that each prerequisite must exist before the commands are run. Therefore, prerequisites run in order of 1 to N (in the example above).
  4. What is the @ syntax for? If a command line starts with @, the echoing of that command is suppressed (reference)
  5. The first <target> is selected by default when running just make

That’s it. Simple right?

Now go forth and make a Makefile!

Level 2: “Cool, but I need a little bit more”

The use of variable substitution is rather common when it comes to all aspects of programming. Makefile is not exempted.

So, how to use environment variables (with default)?

Suppose you want to docker build your app with different build arguments (e.g. ENV_VAR), here’s how your Makefile would look like this:

NAME := my-app
DOCKER := $(shell command -v docker 2> /dev/null)
ENV_VAR := $(shell echo $${ENV_VAR-development}) # NOTE: double $ for escaping
ENV_ANOTHER_VAR ?= bar

.PHONY: build
build:	## build docker image based on shell ENV_VAR
	@if [ -z $(DOCKER) ]; then echo "docker is missing."; exit 2; fi # tip
	@docker build -t $(NAME) . --build-arg ENV_VAR=$(ENV_VAR)

Takeaways for Level 2:

  1. .PHONY — by default make assumes your <target> is a file. So if they are not, just mark them with .PHONY in case you have a filename that is the same as <target> (reference and read this for more info)
  2. Declare a Makefile variable with = or := syntax (reference)
  3. := — to execute a statement once
  4. = — to execute a statement every time. An example use case is when you need a new date value every time you call a function.
  5. ?= — set the Makefile variable only if it's not set or doesn't have a value (reference)
  6. Finally, you can use an environment variable to check if a command exists too as shown in the if statement above
  7. Be careful about adding in-line comments as below. You may end up adding extra space to your variable:
FOO = /my/path/to # <- a white space!

Bonus:

  • echo $${ENV_VAR-development} sets ENV_VAR shell variable based on your current shell environment with a default value to development.
  • Alternatively, make allows you to pass variables and environment variables from the command line, e.g. ENV_VAR=development make build

By now, you probably have everything you need to create a Makefile for small and medium-sized projects.

Now go forth and make awesomeness with Makefile!

Level 3: “Now, show me the fancy stuff”

This is probably my favorite part. Everyone loves a good help message, right? Right…?

Let’s create a useful help target in case our users need help on how to use Make in our project:

.DEFAULT_GOAL := help

.PHONY: help
help:
	@echo "Welcome to $(NAME)!"
	@echo "Use 'make <target>' where <target> is one of:"
	@echo ""
	@echo "  all	run build -> stop -> run"
	@echo "  build	build docker image based on shell ENV_VAR"
	@echo "  stop	stop docker container"
	@echo "  run	run docker container"
	@echo ""
	@echo "Go forth and make something great!"

all: build stop run

.PHONY: build
build:
	@docker build -t image-name . --build-arg ENV_VAR=foo

.PHONY: stop
stop:
	@docker stop container-name || true && docker rm container-name || true

.PHONY: run
run:
	@docker run -d -e ANOTHER_VAR=bar--name container-name image-name
  • .DEFAULT_GOAL — remember how I said the first rule is selected by default when you run just make? You can override that with this
  • With this, you can simply run make to display the help message every time

However, every time you add a new target to your Makefile, you’ll need to add a new line of echo.

Now, how does a self-documenting Makefile help message sound to you?

Modify your help target as follow (modify accordingly):

.DEFAULT_GOAL := help

.PHONY: help
help:  ## display this help message
	@awk 'BEGIN {FS = ":.*##"; printf "\\nUsage:\\n  make \\033[36m<target>\\033[0m\\n\\nTargets:\\n"} /^[a-zA-Z_-]+:.*?##/ { printf "  \\033[36m%-10s\\033[0m %s\\n", $$1, $$2 }' $(MAKEFILE_LIST)

Next, add comments with the ## tag to print the comments as part of the help message:

all: build stop run	## run build -> stop -> run

.PHONY: build
build:	## build docker image
	@docker build -t image-name . --build-arg ENV_VAR=foo

.PHONY: stop
stop:	## stop running container
	@docker stop container-name || true && docker rm container-name || true

.PHONY: run
run:	## run docker container
	@docker run -d -e ANOTHER_VAR=bar--name container-name image-name

Bam! Here you have it, a nice self-documenting help message:

$ make

Usage:
  make <target>

Targets:
  help        display this help
  all         run build -> stop -> run
  build       build docker image
  stop        stop running container
  run         run docker container

Credits: I would like to thank the author of make help - Well documented Makefiles for this.

Level 4: “Your article does not provide the level of detail I need!”

Here are some Makefile references that I’ve curated that I highly recommend you check out; I owe my thanks to all of them:

Final Thoughts

Even if you’re writing good documentation, chances are Makefile is much more reliable. The beauty of Makefile is that it’s simply a rigorous way of documenting what you’re already doing.

Contributing to an open-source project? Refer to the Makefile to identify the development workflow.

The Current State of Makefile

First appearing in 1976, the use of Makefile is known to have its quirks.

Some of the most common complaints about Make is that it has rather complicated syntax. As a result, you’ll find people who absolutely love or hate Makefile.

I think this kind of utility gets very personal. Everyone has their favorite. I for one am less interested in having to download yet another fancy program/tool/framework/library for many other reasons.

Makefile “just works”.

Hosted on Digital Ocean.