Building web applications with Make
Make is a build automation tool originally designed to compile source code into executable programs. However, because it is such a proven, flexible and versatile tool, it is also a great fit for other applications. I have been using Make to automate build tasks for web applications for a few years now with great satisfaction, and in this article I will show some of its interesting features and some makefile snippets I commonly use in the projects I work on.
Introduction
Historically, a "build" in the context of software development referred to the process of compiling source code into machine code that could be executed. However, with the popularity of interpreted languages (such as JavaScript, Python and PHP) that don't require compilation in order to run, together with modern practices such as continuous integration, the definition of a software build has been expanded to include running automated tests, static analysis tools and other quality assurance steps. Also, in the context of web applications, even an application written mainly in an interpreted programming language may still contain frontend source code (such as JavaScript and CSS) which needs to be minified, concatenated or otherwise preprocessed before it is deployed to production.
As the complexity of a software project grows, it becomes more and more cumbersome to perform all these steps by hand. Not only do they need to be documented so everyone does it the same way, but they may also change over time as the project evolves. Forgetting a step or executing a wrong command can cause unexpected results. To speed up the build process, reduce the risk of human errors and allow for changes over time, the build process should be automated and the automation scripts should be committed as part of the project's source code.
Although in its simplest form this could be a shell script containing a list of commands required to build the application, it is common practice to use a dedicated build automation tool. Some of the more popular options include Apache Ant and Apache Maven (both popular for Java development), Gradle (Java), Grunt (JavaScript), Gulp (JavaScript), Phing (PHP), Rake (Ruby), and Make.
Introduction to Make
Make has been created in 1976 and has since become a widespread tool. It is actually a family of tools, as there are a couple of different implementations of Make, such as BSD Make, GNU Make and Microsoft nmake, each with their own enhancements and features. In this article I will focus on GNU Make, the standard implementation of Make on Linux and OS X.
Make uses makefiles to understand the relationships between source files, output
files, and the commands that are required to create these output files. Make
will look for a file called Makefile
in the current directory (unless another
filename is specified).
Based on the information provided in the makefile, Make will automatically determine whether an output file (called target) is out of date and needs to be rebuilt by comparing its file modification time with that of its source files (prerequisites). If the target doesn't exist yet, it will always be built.
When you run Make, you can provide an optional list of target names that should be built:
make [TARGET ...]
If you don't specify a target, Make will build the default target (more about this later).
Rule syntax
The syntax of a basic rule looks like this:
target [target ...]: [prerequisite ...]
recipe
A target is usually the name of an output file that can be created by executing the recipe. A rule can have multiple targets, which can be useful when multiple files can be created with the same (or similar) command.
A prerequisite is a file that is used as input to create a target. As I noted earlier, Make compares the timestamps of the target and its prerequisites to determine whether the target is up to date or should be rebuilt. A target can have zero, one or many prerequisites.
A recipe is the command that Make should execute to build the target. A recipe can also consist of multiple commands, which may be spread over multiple lines. Note that each recipe line should be indented with a tab character.
Installing dependencies using a package manager
A common build task for modern web applications is to install third-party
dependencies using a package manager, such as Composer or NPM.
My first (naive) approach to install dependencies using Make was to have an
install
target in my Makefile to execute the install commands of the different
package managers. I would run make install
whenever I checked out a project
for the first time or after switching branches. The install
target would look
like this:
install:
composer install
npm install
Because a file or directory called install
would never be created, this was
effectively a phony target
(although I didn't know that term by then) and Make would rerun these commands
every time I ran make install
, even if there was nothing to install or update.
I also had to always remember running this before any other Make targets,
because none of my other targets had install
as their prerequisite.
My first improvement was to split up the install
target and create targets
named after the directories containing dependencies (vendor
, node_modules
,
etc.), with the dependency and lock files as prerequisites:
vendor: composer.json composer.lock
composer install
node_modules: package.json package-lock.json
npm install
This worked nicely because Make would now compare the timestamp of the package directory with that of the dependency files, and only execute the recipe when the directory didn't exist or was out of date with one of the dependency files. I also started to add these targets as prerequisites to other rules that depended on (up-to-date) packages in order to run.
Later, I realised that I could drop the file listing the requirements
(composer.json
/ package.json
) and only use the lock file as the
prerequisite. The idea is that the lock file will change whenever a dependency
is installed or updated. The file listing the requirements often also contains
other metadata, which can be changed without requiring an update of the
installed packages. So, my rules now look like this:
vendor: composer.lock
composer install
node_modules: package-lock.json
npm install
I'm currently experimenting to further improve these rules by replacing them with pattern rules that match with any file within the directory, so I can use them as prerequisites for targets that depend on a specific file from a package. I'm not really sure about this move yet, but I will update this article if it happens to work out (or not).
As a final remark: if a prerequisite is missing and there is no rule to create
it, Make will fail. So for projects (mainly libraries) where I add the lock
files to the .gitignore
, I often use Make's
wildcard
function
to treat the lock file as an "optional prerequisite":
vendor: $(wildcard composer.lock)
composer install
Note that this optional prerequisite is not really a Make concept. It's just a way to have a prerequisite that will not let Make fail when it doesn't exist.
Using a default target
The documentation of GNU Make lists a number of conventions for writing
Makefiles for GNU programs.
Not all of them can be applied to the context of web application development,
but I've adopted some that make sense for web applications.
One of these is to have every makefile contain an
all
target,
which the documentation describes as:
Compile the entire program. This should be the default target. (...)
Being the default target refers to the fact that Make, when called without
arguments, will build the first target that appears in the makefile. By adopting
this convention I can enter any project, run make
(or make all
), and I have
a working application! Depending on the specific requirements of the project I
add prerequisites to the all
target so that dependencies are installed, assets
are compiled, etc:
.PHONY: all
all: vendor public/app.js public/app.css
The .PHONY: all
tells Make that all
should be treated as a
phony target,
meaning a target that is not really the name of a file. This ensures that Make
will execute the recipe, even if a file or directory with the same name happens
to exist already.
Compiling Sass or LESS files
When I work on a fairly simple project that requires some Sass but not a
full-blown Webpack setup, I prefer to run the sass
executable directly.
Compiling Sass files (or LESS for that matter) is a good use case for Make
because of the clear relationship
between source (Sass) and output (CSS) files, which allows you to take the most
advantage of Make's out of date calculation:
app.css: app.scss
sass app.scss app.css
Now when you run make app.css
, Make will only compile the Sass file when it
is newer than the compiled CSS.
To avoid repetition and make this rule more flexible, we can use automatic variables that contain the names of the target and the prerequisite:
app.css: app.scss
sass $< $@
$<
contains the name of the first prerequisite (in this case app.scss
),
and $@
contains the name of the target, which in this case is app.css
.
In real-life applications my Sass files often import other Sass files, which can either be my own (partials) or maintained by a third party (for example, the Bootstrap Sass files). To ensure that when these are updated my output CSS is rebuilt, I list them as prerequisites as well:
app.css: sass/app.scss sass/_*.scss node_modules
sass $< $@
Note that this is why I'm using the $<
variable (which contains only the first
prerequisite): I want Make to rebuild app.css
if any of the prerequisites
are newer than the target, but I only want the first prerequisite
(sass/app.scss
) to be passed as an argument to the sass
executable.
The last examples assumed that there is only a single CSS file to be built. However, in some cases I want to create a generic rule that can be used to build multiple targets. In these cases pattern rules are quite useful:
%.css: sass/%.scss sass/_*.scss node_modules
sass $< $@
A pattern rule is a rule where the target contains the %
character. The %
character can match any nonempty string. If a prerequisite also contains the %
character, it will be replaced with the part of the target that matched the %
character (called the stem). For example, when calling make print.css
with
the above rule, its first prerequisite will be sass/print.scss
.
Last but not least, sass
accepts a number of options which can be used to
change the generated CSS. For instance, when building for production I might
want to minify the generated CSS, while during development I would rather want
expanded CSS and source mapping.
By creating a variable SASSFLAGS
and using that in the recipe, I have a recipe
that can be reused but produces different output for development and production:
SASSFLAGS = --style expanded
%.css: sass/%.scss sass/_*.scss node_modules
sass $(SASSFLAGS) $< $@
This variable can be overridden when building for production, in order to get
minified output. This can be done by either setting the variable on the command
line when calling Make (i.e. make SASSFLAGS='--style compressed
--sourcemap=none' app.css
) or by setting a target-specific variable (more about
that later). The only issue I haven't really been able to solve yet (without
building assets for production in a separate directory or giving them different
filenames) is that when the output file already exists, Make will not re-run the
recipe.
Creating favicons from source images
Again, when working on a small project that doesn't require a complex JavaScript
asset build system, I sometimes just want to generate a set of favicons from a
source image. To do so, I use convert
(part of
ImageMagick) to resize the input image into
16x16, 32x32 and 48x48 pixel PNG images which are padded with a background color
to make them square, which are then combined into a favicon.ico
file using
icotool
(part of the icoutils package):
favicons = public/favicon.ico public/favicon-16x16.png public/favicon-32x32.png public/apple-touch-icon.png
all: public/app.js public/app.css $(favicons)
public:
mkdir -p $@
public/favicon-%.png: images/portrait.png | public
convert $< -resize $* -background white -gravity center -extent $* $@
public/apple-touch-icon.png: images/portrait.png | public
convert $< -resize 180x180 -background white -gravity center -extent 180x180 $@
public/favicon.ico: public/favicon-16x16.png public/favicon-32x32.png public/favicon-48x48.png | public
icotool --create --output=$@ $^
At the top of the makefile I create a variable favicons
which holds the names
of the favicon files I need to generate, so I can use that as the prerequisites
for another target (such as all
).
I then have the various rules for creating the PNG and ICO files.
In these rules I use two automatic variables
I haven't discussed yet:
$*
, which contains the stem from my pattern rule (the part of the target
that matches with the %
character), and $^
which contains the names of all
prerequisites (with spaces between them).
The public
prerequisite is an
order-only prerequisite
(denoted by the pipe symbol |
between the normal prerequisites and order-only
prerequisites). This means that the build
directory will be created if it
doesn't already exist, but the files inside it don't have to be recreated
whenever the timestamp of the directory changes.
Of course this example can be improved to generate even more favicon files to support higher resolution icons that are used by different browsers and operating systems. The FAQ from RealFaviconGenerator.net contains a good list of recommended favicon files.
Compiling frontend assets with Webpack
When I do work on a project that uses Webpack or a similar tool to build frontend assets, I still like to add these tools to my Makefile instead of running them directly. This helps integrating them in the rest of the application's build process (so I can build the whole application with only one command) while at the same time making sure that they will only run when one of the source files has actually changed.
assets = public/main.css public/main.js public/favicon.ico
$(assets): sass/* src/*.js images/* node_modules
node_modules/.bin/webpack
The assets
variable conveniently contains all assets that need to be built for
this project, and can be used as prerequisite for other targets such as the
default all
target.
Building a deployable tar archive
In order to create a build artifact that can be deployed to a remote server,
I again use one of the standard targets
from GNU's Makefile conventions: dist
. Running make dist
should create a
tarball containing all the files required to run the application in production.
I then use Ansible to copy this archive to the server, extract it into a new
release directory, switch symlinks, etc.
sources = config/ public/ src/ templates/
version = $(shell git describe --tags --dirty --always)
build_name = my-app-$(version)
build_dir = build/my-app-$(version)
# (other targets)
.PHONY: dist
dist: SASSFLAGS = --style compressed --sourcemap=none
dist: all
-rm -rf $(build_dir)
mkdir -p $(build_dir)
ln --symbolic --relative $(sources) composer.json composer.lock $(build_dir)
composer install --working-dir=$(build_dir) --no-dev
rm $(build_dir)/composer.json $(build_dir)/composer.lock
tar --create --dereference --gzip --file=build/$(build_name).tar.gz --directory=build $(build_name)
-rm -rf $(build_dir)
At the top of the Makefile, I set a couple of variables which can be reused
throughout the file. sources
contains a list of source files and/or
directories. version
runs a Git command to describe the current state of the
workspace. Especially when using tags this is very useful, as it will return the
most recent tag and some other information to identify this build. build_name
contains a name for this build based on the name of the project and the value of
version
, and build_dir
contains the name of a temporary directory which will
be archived to produce the build artifact. This allows to filter development
dependencies out of the build artifact without affecting the current working
directory.
Remember how I created a SASSFLAGS
variable in order to pass different options
to Sass depending on whether I was building for development or production? Using
a target-specific variable value,
I can override the value specifically for the dist
target, and this value is
even passed to rules that are prerequisites of dist
. This way when I
run any other target the global value of SASSFLAGS
applies, but when I run
make dist
the value is overwritten with one that results in compressed CSS
without source maps.
The recipe itself contains the following steps:
- The temporary directory for this build is removed (if it exists already) and
re-created. Note that the dash in front of the
rm
command tells Make to ignore any errors, so Make will not fail if the directory does not exist. - Symbolic links are created to the files and directories listed in the
sources
variable. - Composer is used to install the production dependencies inside the build
directory. Afterwards, the
composer.json
andcomposer.lock
files are removed. - A gzip-compressed tarball is created containing the build directory and its
contents (the
--dereference
flag is used to follow the symlinks we made earlier, instead of having actual symlinks in the archive). - The temporary directory is cleaned up.
To prevent the the contents of the build directory and build artifacts from
accidentally being committed to Git, the build
directory is included in the
.gitignore
file of the project.
Running automated tests
Although this normally doesn't result in files being created, I find it very
convenient to also include test targets in my Makefiles. For most projects I
create a test
target to run the full test suite, which has more fine-grained
targets as its prerequisites. This way I can easily run the whole suite with
make test
or run a smaller part of the test suite, for instance
make unit-tests
:
.PHONY: test lint unit-tests acceptance-tests coding-standards security-tests
test: lint unit-tests acceptance-tests coding-standards security-tests
lint: vendor
vendor/bin/parallel-lint $(sources)
bin/console lint:twig templates/
vendor/bin/phpstan analyse --level=7 $(sources)
unit-tests: vendor
vendor/bin/phpunit
acceptance-tests: vendor
vendor/bin/behat -v
coding-standards: vendor
vendor/bin/phpcs -p --colors
security-tests: vendor
vendor/bin/security-checker security:check
npm audit
Of course all these targets are phony targets. Note that for every target I list the required prerequisites, so I can always run the tests with a single command even if I just did a fresh checkout of the code.
Cleaning up
A last convention I've adopted from GNU's Makefile conventions is to always
include a clean
target in my Makefiles. Although the targets in the Makefile
should always contain the right prerequisites to ensure that it will be rebuilt
when it gets out of date, it can sometimes just be very convenient to remove all
the files that were built by the Makefile and start with a clean slate.
.PHONY: clean
clean:
rm -rf build node_modules vendor $(assets)
The Makefile conventions also list other cleanup targets
(uninstall
, distclean
, mostlyclean
and maintainer-clean
), but I haven't
really found a use case for these yet in my projects so I haven't implemented
them yet.