An Awful Introduction to Make

Feb 23, 2020

I don't know make very well. But I know enough to maybe help you get some use out of it The first thing you need to know about make is that a target is executed only if one of its prerequisites has a more recent modified timestamp.

For me, the gap between understanding that concept and leveraging it was embarrassingly large. I think the problem is that a lot of us are using higher level tools that already do things like incremental build and where compiling and linking are abstracted away.

If we take a step back and look at make from the point of view of a C programming, we can see how make is useful:

main.o: main.c
  gcc -c -o main.o main.c

hello: main.o
  gcc -o hello main.o

What happens when we run make hello? It'll only run the hello target if the modified time of main.o is newer than the modified time of the hello file. Notice that there's also a target for main.o with a prerequisite of main.c which will follow the same rule. The first time you run make hello it'll do what you expect: compile main.c into main.o and then generate the hello binary. If you run make hello again, nothing will happen (besides make telling you that 'hello' is up to date.). But if we modify main.c an then run make hello, the main.o and hello targets will be run.

The import thing to note here is that the dependencies flow across multiple levels. Now this is a very simple example. Instead of explicitly making main.c a prerequisite of main.o we could use patterns, e.g %.o: %.c.

The above is barely the tip of what we could do. But my goal isn't to document the depths of make's power (especially since I can't). What I want to do is show how this depedency resolution can be used for simple and common tasks that don't involve compiling and linking.

Let's take this one step at a time. All of our projects have a make t command (I like tea and I enjoying saying "make t"). This target just runs go test or mix test:

  mix test

Now, you might be thinking: but there's never a t file. Without a t file, our target will always run (which is what we want). But we can be more explicit and tell make that this is a phony target:

  mix test

Phony targets aren't just nice-to-have. If we did have a t file that had nothing to do with our t target, using .PHONY: t is how we tell make to ignore the file and just always run the target (without it, make would always tell us that t is up to date.)

In Elixir you define your library dependencies in a mix.exs file and it will generate a mix.lock file with specific versions. If someone adds a dependency or updates a version, you need to run mix deps.get to update your local environment. Knowing this, guess what the following does:

.deps: mix.exs
  mix deps.get
  echo 'generate by make' > .deps

t: .deps
  mix test

First, our t target will always run because it's a phony target. Phony targets can still have prerequisites though. Here, our .deps prerequisite is itself a target which has a prerequisite on the mix.exs file. The first time we run make t the .deps target will run (because the .deps file doesn't exist). This target runs mix deps.get and then generates the .deps file. As long as mix.exs isn't touched, the .deps target won't run (because the .deps file was modified after mix.exs). But if mix.exs is modified, then next time your run make t the .deps target will run and you'll get your updated dependencies.

We use this for library dependencies (as shown above), sql migrations, and generating models from protocol buffer definitions. It minimizes friction. Just pull and make t will work. (Ok, it doesn't work 100% of the time, but we also have a make fix that runs through a series of steps (such as clearing build folders) to try to reset a broken project environment).

There's a few other details that might be useful to know.

Lines that begin with tabs usually (always?) belong to a target. These are called recipes, but the important thing to know is that they're executed in a shell (a separate shell per line). By default, make uses /bin/sh. So a Makefile is really made up of Makefile-specific syntax as well as shell scripting. Honestly, I have no clue how to do complicated stuff with make, especially with respect to conditional execution and variables. For this reason, I try to keep my targets to one line (which might be a custom bash script that does all the complex stuff).

(there's a way to make all the lines share the same shell, but as far as I understand that needs to be enabled for the entire Makefile)

By default make will echo the recipe that's being execute. You can silence it by placing @ at the start of the reciple. Similarly, by default, if a recipe fails (non zero status code), the target stops. You can ignore errors on a per-recipe basis by placing - at the start of the recipe. Combined, you could do something like:

make fix:
  -@psql -X -d postgres -c "drop database app_test"
  @psql -X -d postgres -c "create database app_test"

Prerequisites don't have to be statically defined. For example, here's our proto target:

proto: $(shell find proto/schemas -name "*.proto")

The above uses make's shell function to execute our shell's find command. For simpler cases (where you don't needed to search nested directories for example), you should use the built-in wildcard function:

schema: $(wildcard *.sql)

You can pass named arguments (not sure what they're called) to make:

make t:
  mix test $(F)

Which can then be called with:

make t F=test/input_test.exs

Again, all of this is pretty basic stuff. But hopefully it's helpful. Also, it's worth pointing out that Make's documentation is excellent.