The make tool has been around since 1976 and is a prime example of mature software. In this article, I will give you an introduction to using it as a CI/CD tool. All examples are available on Github

A few facts about make

Make was invented by Stuart Feldman in 1976 at Bell Labs, initially, as a build automation tool for executable programs. Even today, it is built into Unix, Linux and MacOS.

For Windows, it can be installed and used via Chocolatey, Cygwin, Make for Windows or as part of the Windows Subsystem for Linux.

...] Makefiles were text files, not magically encoded binaries, because that was the Unix ethos: printable, debuggable, understandable stuff.

Stuart Feldman, „The Art of Unix Programming“, Eric S. Raymond, 2003

Pros

  • Is is a proven solution that is available everywhere.
  • It is consistent across local and CI builds.
  • It is relatively easy to learn.
  • Never out of sync with documentation since it can act as one (if it is well-written).

Cons

  • It can be hard to master.
  • Its syntax is dependent on tabs and spaces.
  • Bugs can be tricky to figure out.

Parts of a makefile

Naming and invocation

Make is the name of the tool that runs makefiles. When invoked, it looks for a file named "makefile" or "Makefile" without any extension. It is also possible to specify a custom file name, however, this is out of scope of this basic overview.

Make returns a status code of 0 when everything ran successfully, making it nicely usable in CI/CD contexts.

Make is usually invoked like this:

make [ -f makefile ] [ options ] ... [ targets ] ...

Rules & Recipes

Rules

A make rule typically creates (and checks) a target file. If a target file already exists, it may or may not be recreated depending on its timestamp or the existence of depencencies. I will explain this a little later in this article.

Also, a rule can be the name of an action that does not produce a target file (this is the most useful behavior when make is used in test automation).

Recipes

A recipe is an action or a set of actions that make invokes depending on a rule.

One thing to be very careful about when writing recipes is that each line has to be started with a tab. That means that your editor of choice needs to distinguish between tabs and spaces when saving the file!

Rules & Recipes Example

dependency.txt:
    @echo "Hello world" > dependency.txt
    @echo "File created"

This example has one rule which is called dependency.txt so make assumes that the recipe belonging to this rule creates a file of the same name.

In this case, the string "Hello world" is written to the dependency.txt file and "File created" is printed afterwards.

However, this is only performed if this file does not exists yet as shown in the video below:

  • The first run creates the file.
  • The second run skips the rule and notifies us that dependency.txt is already up to date.

Prerequisites

The power of make can be seen when rules depend on one another because usually, files are created based on other files.

To mark that rules depend on other rules, we can add one or more rules after the colon: rule: dependency

Prerequisites Example

final.txt: dependency.txt
    echo "This is the final file!" > final.txt
    cat dependency.txt >> final.txt

dependency.txt:
    echo "Hello world" > dependency.txt

In this case, we have two rules

  • dependency.txt (creating the dependency.txt file like before) and
  • final.txt (which creates the final.txt file using the contents of dependency.txt)

We specify the dependency by adding the rule name after the colon: final.txt: dependency.txt.

This means "for creating the final.txt, we need to run dependency.txt first".

The video below illustrates that:

  • The first invocation creates dependency.txt and final.txt
  • The second run does not create anything since final.txt already exists
  • However, when deleting the dependency.txt file and running it again, make registers it is missing, recreates it again and - most importantly - also recreates final.txt as the dependency is now newer.

.PHONY

Having Make keep track of files and dependencies is great. But often times, when make is used for other cases, we don't want this. Imagine some rule that just runs some code which does not create a file (e.g. "test" for running some automated tests).

This is perfectly fine, until there is a file called "test" in the directory. In this case, make would stop running this rule since it assumes that it was initially there to create a file called test in the first place.

To prevent this, we can mark all rules that Make should not consider when checking for files as .PHONY. With this, we basically tell Make that it should not expect this rule to create any files at all.

.PHONY Example

final.txt: dependency.txt
    echo "This is the final file!" > final.txt
    cat dependency.txt >> final.txt

dependency.txt: clean
    echo "Hello world" > dependency.txt

.PHONY: clean
clean:
    rm -f dependency.txt
    rm -f final.txt

This code is the same as above but adds a rule called clean that removes the previously created files dependency.txt and final.txt.

In this case, the flow is:

  • clean is run first (since it does not have any dependencies)
  • dependency.txt is run next (since it depends on clean)
  • final.txt is run last (as it depends on dependency.txt)

Here, clean is marked as .PHONY because it does not create any files and should not be skipped if there ever is a file called clean in the directory.

This video shows the following:

  • The first invocation removes existing files and creates dependency.txt and final.txt
  • The second run does the exact same thing since both target files are removed at the start so Make does not skip any rules

Build Order

As we have seen, the build order of Make is closely tied to how the rules depend on each other, not necessarily about the order of the rule specification in the make file. That can lead to some confusion as seen below.

Build Order Example 1

clean:
    @echo "Clean up old files"

build: clean
    @echo "Build application"

deploy: build
    @echo "Deploy application to stage server"

test: deploy
    @echo "Test stage deployment"

.PHONY: clean build deploy test

This script contains four rules that depend on each other like this:

  • clean depends on no other rule
  • build depends on clean
  • deploy depends on build
  • test depends on deploy

In this case, all rules are marked as .PHONY so they are not skipped in case a similarly named file exist.

When running make, it only executes clean in this case because this is the first rule in the file and it does not have any other dependencies that Make needs to worry about.

This can be seen in the video below:

This is something to be cautious about.

To fix this, we could specify the rule that Make should run first like this

make test

In this case, make would consider this the start rule and work out the necessary dependencies and run order. However, this is not really desired as users of this file need to be aware of this. So this required additional documentation that we wanted to avoid in the first place.

A simpler solution is to flip around the order of rules like this:

test: deploy
    @echo "Test stage deployment"

deploy: build
    @echo "Deploy application to stage server"

build: clean
    @echo "Build application"

clean:
    @echo "Clean up old files"

.PHONY: clean build deploy test

Now, Make will again check the test rule first and work its way backwards through the dependency chain:

  • it will not execute test before executing deploy
  • it will not execute deploy before executing build
  • it will not execute build before executing clean

As clean is the earliest dependency without any dependencies of its own, the execution order starts with it as desired.

Parameters

Most of the time, it is necessary to pass parameters to Make in order to control certain aspects and make it more dynamic.

Parameters Example

This example assumes a test rule that should run an end-to-end test in a specific browser:

BROWSER ?= firefox

test:
   @echo "Testing with browser ${BROWSER}"

.PHONY: deploy test

Here, we specify a BROWSER variable with a default value of "firefox" in case Make is invoked without any further parameters. If we want to override this, we can just pass this as an environment property like so:

make BROWSER=chrome

This can be seen in the video below. Here, the first invocation is without a parameter, the second overrides the default.

Tip: Generated Help

If your makefile is more complex and might have different start rules that could be invoked by potential users, it is good practice to provide help.

This rather cryptic shell command in the help rule does this automatically by grabbing all comments above the rules and combining them to a nice description for the users:

help:
    @awk '/^#/{c=substr($$0,3);next}c&&/^[[:alpha:]][[:alnum:]_-]+:/{print substr($$1,1,index($$1,":")),c}1{c=0}' $(MAKEFILE_LIST) | column -s: -t

Also, if you put this help rule on top of the file, this is shown automatically on the default Make invocation. This can be an additional level of safety because it prevents blindly running the file and executing something undesired.

Help Example

The following make file illustrates this:

# Show this help.
help:
    @awk '/^#/{c=substr($$0,3);next}c&&/^[[:alpha:]][[:alnum:]_-]+:/{print substr($$1,1,index($$1,":")),c}1{c=0}' $(MAKEFILE_LIST) | column -s: -t

# Test project.
test:
    @echo "This would start a test"

.PHONY: help test

This is how it looks for a user:

Conclusion

Despite its age, Make is a great tool to automate complex tasks by a simple, yet powerful, wrapper. It makes it easier to refactor pipelines without touching how the invocation is handles and can greatly ease the learning curve when switching from one technology to the other.

Sometimes, it pays off not looking at the newest technologies first but checking out proven stable solutions that can do the same job in a better way.

Previous Post Next Post