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.

Build

1
2
3
4
5
6
7
build: build/service build/convert ## Build all executables.

build/service: ## Build the service executable.
    go build -o out/service cmd/service/main.go

build/convert: ## Build the conversion tool executable.
    go build -o out/convert cmd/convert/main.go

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.

Test

 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.PHONY: test
test: test/unit test/int test/static test/lint ## Run all tests.

.PHONY: test/unit
test/unit: ## Run unit tests.
    go test ./internal/... ./pkg/...

.PHONY: test/int
test/int: ## Run integration tests.
    go test ./test/...

.PHONY: test/lint
test/lint: ## Run linter.
    revive -set_exit_status -exclude vendor/... -exclude cmd/... ./...

.PHONY: test/static
test/static: ## Run static analysis.
    staticcheck ./...

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.

Docker and Other Tools

28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
.PHONY: docker/up
docker/up: ## Start service and dependency containers.
    docker-compose up

.PHONY: docker/down
docker/down: ## Stop service and dependency containers.
    docker-compose down

.PHONY: install
install: ## Install development and testing tools.
    # Install revive for `make lint`.
    go install github.com/mgechev/revive@v1.2.4

    # Install staticcheck for `make staticcheck`.
    go install honnef.co/go/tools/cmd/staticcheck@v0.3.3

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.

Help Text

44
45
46
47
48
49
.DEFAULT_GOAL := help
.PHONY: help
help: ## Print Makefile help text.
    @grep -E '^[a-zA-Z_\/-]+:.*?## .*$$' $(MAKEFILE_LIST) \
    | awk 'BEGIN {FS = ":.*?## "}; \
        {printf "\033[36m%-20s\033[0m%s\n", $$1, $$2}'

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.