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
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
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 ] ...
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).
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!
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 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
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
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:
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.
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:
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:
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.
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:
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:
As clean is the earliest dependency without any dependencies of its own, the execution order starts with it as desired.
Most of the time, it is necessary to pass parameters to Make in order to control certain aspects and make it more dynamic.
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.
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.
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:
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.