A few years ago, I wrote about the power of automation, particularly in defining processes for side projects that get infrequent attention. It’s always refreshing to return to a project and see that I don’t have to re-learn the processes to build, test, or deploy code that I can barely remember writing in the first place.
My new standard is to ensure that not only are my projects leveraging automation, but that they’re automated consistently. Instead of a collection of shell scripts, CI configurations (like Github Actions or CircleCI), or how-to documentation, a solid Makefile is an ideal way to centralize the specifics of how to interact with a project.
If a process is complicated enough to warrant a custom shell script, I treat the Makefile as the entry point to the script. Instead of redefining commands in my CI spec, I’ll make sure any relevant CI processes are captured in the Makefile, creating new CI-specific targets as needed.
I like to group my targets together into logical sections, grouping related targets under the common prefixes. When appropriate, the prefix itself can act as a target that invokes those nested under its prefix. Below, I’ve created an example Makefile that I might define when starting to work on a Go microservice. Let’s go through each section.
|
|
Each executable is built using a particular target with the build/
prefix. In this example, I define two targets. The first, build/service
, compiles the primary application that’s being delivered by the project. The second target, build/convert
, builds another executable representing a tool that might be useful during development or testing, but is distinct from the application. Other examples targets I’ve defined in the past that would live in this section might be tools to migrate a database, generate test data, or a special build command invoking specific parameters for certain deployed environments or CI flows.
The top-level build
command is dependent on the build/service
and build/convert
targets, but note that it doesn’t define any commands itself. In this case, calling make build
will run each of its sub-targets and build all relevant executables.
|
|
Here, I’ve defined four testing targets typical to my Go projects as well as a catch-all target that will execute all of the above when invoked via make test
. The test/unit
target defines unit-level tests that (in Go, at least) live alongside the functionality they test in the internal/
or pkg/
directory. The test/int
target defines integration-level tests that live in the test/
directory.
Static analysis and linting commands are captured in the test/lint
and test/static
targets. Each of these has a specific invocation and relies on a third-party tool, so I’m happy to not have to re-learn the options to exclude the vendor/
directory from linting with revive
, for example. Of course, it’s possible that staticcheck
or revive
aren’t installed when I or a new collaborator joins a project ‐ more on that in the next section.
|
|
Here is where I define all the utilities specific to any particular project. In this example, I’m using Docker and docker-compose
to manage local development containers for the service and dependencies, like Postgres or Redis. The docker/up
and docker/down
targets handle starting and stopping the project containers in a docker-compose.yaml
file (not shown here).
The install
target handles installing the tools that this project relies on, particularly the third-party tools invoked by other targets within the Makefile, revive
and staticcheck
. This prevents me from having to track down the installation steps for each of these tools. If someone else is joining the project or if I’m developing on a new machine, make install
is the first order of business.
|
|
The help section is the coolest part of the entire Makefile. Setting .DEFAULT_GOAL := help
helpfully tells Make to assume the help
target when no other target is provided.
I’m using the ^[a-zA-Z_\/-]+:.*?## .*$$
regular expression to match the lines of the file that with a target definition, any potential dependency targets, and then a comment after ##
, all on the same line. Targets that don’t include a comment won’t match the regex, so they won’t be included in the help text — this can be useful for targets that aren’t intended to be invoked at the top-level by a person.
Matching lines are then forwarded to awk
, which selects the target name and comment string from it and prints a formatted line of help text using printf
. The \033[36m
format directive turns the terminal output blue to highlight the target name. The %-20s
directive pads the target name to 20 columns to neatly align the output. The next formatting directive, \033[0m
, resets the output formatting to the terminal default and %s
prints the target comment as help text.
Here’s what it looks like when it all comes together:
I’m never able to devote as much time to side projects as I’d like. Now, I can get distracted, forget everything about this project, and, when I return months later, I can simply run make help
to see all the relevant commands. I can build, test, run, or even deploy the project without actually remembering the commands needed to do so. This approach is just as helpful at work, where quality, standardized, and documented automation makes me and my colleagues more effective and self-sufficient.