Make is awesome! It’s simple, familiar, and compatible with everything. Unfortunately, editing a
Makefile can be challenging because it has a very terse and cryptic syntax. In this post, we will outline how we author them to get simple, yet powerful, build systems.
For the uninitiated, check out this gist by Isaac Schlueter. That gist takes the form of a heavily-commented Makefile, which makes it a great learning tool. In fact, I would recommend checking it out regardless of your skill level before reading the remainder of this post.
readability: documentation | dry
Here at Segment, we write a lot of code. One of our philosophies is that the code we write should be beautiful, especially since we’ll be spending literally hours a day looking at it.
By beautiful, we mean that code should not be convoluted and verbose, but instead it should be expressive and concise. This philosophy is even reflected in how we write a
We dedicate the top section of each
Makefile as a place to define variables (much like normal source code). These variables will be used to reduce the amount of code used in our recipes, making them far easier to read.
In node projects, we always rely on modules that are installed locally instead of globally. This allows us to give each project it’s own dependencies, giving us the room to upgrade freely without worrying about compatibility across our many other projects.
This decision requires more typing at first:
# globally installed $ eslint . # locally installed $ ./node_modules/.bin/eslint .
But it’s easily fixed by using
BIN := ./node_modules/.bin ESLINT ?= $(BIN)/eslint lint: @$(ESLINT) .
We use this same pattern frequently, as it helps to shorten the code written in a recipe, making the intention far more clear. This makes understanding the recipe much easier, which leads to faster development and maintenance.
Beyond just using variables for the command name, we also put shared flags behind their own variable as well.
BIN := ./node_modules/.bin UGLIFY ?= $(BIN)/uglify-js UGLIFY_FLAGS ?= --screw-ie8 build/%.min.js: build/%.js @$(UGLIFY) $(UGLIFY_FLAGS) $< > $@
This helps keep things dry, but also gives developers a hook to change the flags themselves if needed:
When writing code and interacting with developer tools, we seek to avoid noiseas much as possible. There are enough things on a programmer’s mind, so it’s best to avoid adding to that cognitive load unnecessarilly.
One example is “echoing” in Make, which basically outputs each command of your recipe as it is being executed. You may notice that we used the
@ prefix on the recipes above, which actually suppresses that behavior. This is a small thing, but it is part of the larger goal.
We also run many commands in “quiet mode”, which basically suppresses all output except errors. This is one case where we definitely want to alert the developer, so they can take the necessary action to correct it.
BIN := ./node_modules/.bin DUO ?= $(BIN)/duo DUO_FLAGS ?= --quiet --development default: build/index.js build/index.css build/%: % @$(DUO) $(DUO_FLAGS) $<
make, now we only will see errors that happened with the corresponding build. If nothing is output, we can assume everything went according to plan!
There are some target names that are so commonly used, they practically become a convention. While we haven’t invented most of the targets I will mention here, the main principle here is that using names consistently throughout an organization is important to improve the experience for developers new to a specific project.
Since we have a lot of web projects, the
build/ directory is often reserved as the destination for any files we are bundling to serve to the client.
This target is used to delete any transient files from the project. This generally includes:
build/directory (the generated client assets)
intermediary build files/caches
test coverage reports
Remote dependencies are not part of this process. (see clean-deps)
Depending on the size and complexity of a project, the downloaded dependencies can take a considerable amount of time to completely resolve and download. As a result, they are cleaned using a distinct target.
While Make will automatically assume the first target in a Makefile is the default one to run, we adopt the convention of putting a
default target in every
Makefile, just for consistency and flexibility.
For our projects, the
default target is usually synonymous with
build, as it is common practice to enter a project and use
make to kick off the initial build.
Runs static analysis (eg: JSHint, ESLint, etc) against the source code for this project.
This starts up the web server for the given project. (in the case of web projects)
This is exclusively for running the automated tests within a project. Depending on the complexity of the project, there could also be other related targets, such as
test-server. But regardless, the
test target will be the entry-point for a developer to run those tests.
All in all, Make is a powerful tool suitable for many projects regardless of size, tooling and environment. Other tools like Grunt and Gulp are great, but Make comes out on top for being even more powerful, expressive and portable. It has become a staple in practically all of our projects, and the conventions we follow have helped to create a more predictable workflow for everyone on the team.