Makefiles for the Makefile-Lazy Go developer
Last weekend was FOSDEM and despite not being there in presence, I feel like a train hit me1. And it took little more than half of the first day until I saw the need to write a blog post. Denis Germain presented GoReleaser and how it can be used to replace Makefiles.
Of issue domains and purpose
Don’t get me wrong, I quite enjoyed Denis’ presentation. But at the same time, I disagree with the premise.
One of the underlying issues with the presentation I saw was the use of make as glorified autocomplete, which I take a bit of an issue with, at least in the way it was presented. The provided example for a suboptimal Makefile was:
# SPDX-License-Identifer: MPL-2.0
prepare:
go mod tidy
build: prepare
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/tifling -ldflags "-X main.Version=$$VERSION" main.go
dockerbuild:
docker build -t zwindler/tifling:$$VERSION --build-arg VERSION=$$VERSION . && docker build -t zwindler/tifling:latest --build-arg VERSION=$$VERSION .
dockerpush: dockerbuild
docker push zwindler/tifling:$$VERSION && docker push zwindler/tifling:latest
And I completely agree with Denis here: This Makefile is shit :D
I also agree that this Makefile is not necessary here. If your project can be
built by entering go build
, you don’t need a Makefile. So when do you need
one? When there are dependencies that have to be generated or built beforehand,
or you’re bulding different things, for example manpages, helper programs,
webinterfaces, etc.
One of the fundamental misunderstandings I think are at play here is that there
should be some kind of one-for-everything-tool, and I quite disagree. That’s
usually how you end up with fringe solutions nobody except you can use, and in
collaborative development that’s not a good thing to have. Use the right thing
for the job. Use make
for “making” your binary, use Go (or gcc-go, lol) to
build it, not some wrapper.
In the same manner, you don’t have to replace something (usually) easy to read and edit like a Makefile with the shitshow that is yaml. Neither with a Makefile but more complicated2, nor with 400 lines of config file. Sure, I want to write the same I would with a Makefile but in a syntax that’s known for breaking in the weirdest of ways. Don’t reinvent the wheel if you don’t have to and use a solution that has worked relatively well for almost 50 years now. Thanks to a smarter compiler, we don’t have to do the heavy lifting ourselves in Go.
That being said, turning a potentially error-prone, complicated, multistep
build procedure into a simple make
, is a satisfaction I can not describe. In
fact: It’s how I got into writing Makefiles. A younger (and less cynical) me
had to make sure even the intern could compile a program and as someone who
likes to rely on “simple” tools, make
was the obvious choice.
The added advantage you get later on is not filling up your $GOBIN
with an
absurd number of binaries. This might work well for small setups, but you
quickly run into issues where different projects (some of which might not be
yours) rely on different versions of tools.
But before showing you how to do that, we first have a Makefile to fix.
Extinguishing the dumpster-fire
While the provided Makefile is (unfortunately) an accurate representation of the average Go-Makefile, it is nonetheless terrible. Let’s begin by cleaning this thing up:
Don’t be prepared
First, let’s get rid of the prepare
target. Typing make prepare
is one
character longer than go mod tidy
, and the name is deceptive as we’re not
really preparing anything either.
diff --git a/Makefile b/Makefile
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,3 @@
-prepare:
- go mod tidy
-
-build: prepare
+build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/tifling -ldflags "-X main.Version=$$VERSION" main.go
This has the added benefit that build
is now our first target in the
Makefile, so we can just build it using make
and can omit the actual target.
Nice. Now we can address the elephant in the room: build
.
Building the application
diff --git a/Makefile b/Makefile
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,7 @@
+VERSION = $(shell git describe --tags --abbrev=0) || v0.0.1
+
-build:
+tifling:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/tifling -ldflags "-X main.Version=$$VERSIO
N" main.go
+ go build -o $@ -ldflags "-X main.Version=$(VERSION)"
dockerbuild:
docker build -t zwindler/tifling:$$VERSION --build-arg VERSION=$$VERSION . && docker build -t zw
First we got rid of the environment variables. These come from the environment and when we’re building during development, it makes sense to get something out of it that we can actually use. Compiling for x86 on ARM makes little sense during development.
We also put the version into a make
variable. It’s clunky to enter it every
time and having it defined in the environment does not really make sense
either. Why would I manually want to set what can be fetched from the
underlying git repo automatically?
Docker → CI
I’m a lazy ass. And I haven’t manually pushed a container in ages. Why would I, that’s what CI is there for!
So instead of putting this in our Makefile, let’s just throw it in the CI pipeline, no need for a make target. It also makes it more readable, in my humble opinion. (This is for Sourcehut CI, so adapt as needed)
- update-container: |
cd ~/uniview
if git describe --exact-match HEAD; then
docker build -t "c8n.io/mpldr/uniview:$(git describe --exact-match HEAD)" -t "c8n.io/mpldr/uniview:latest" -t "c8n.io/mpldr/uniview:devel" .
docker push "c8n.io/mpldr/uniview:$(git describe --exact-match HEAD)"
docker push "c8n.io/mpldr/uniview:latest"
docker push "c8n.io/mpldr/uniview:devel"
else
docker build -t "c8n.io/mpldr/uniview:$(git describe HEAD)" -t "c8n.io/mpldr/uniview:devel" .
docker push "c8n.io/mpldr/uniview:$(git describe --always HEAD)"
docker push "c8n.io/mpldr/uniview:devel"
fi
So let’s remove these targets as well.
This doesn’t work
Okay, so we’re left with 3 lines of Make rules:
VERSION = $(shell git describe --tags --abbrev=0) || v0.0.1
tifling:
go build -o $@ -ldflags "-X main.Version=$(VERSION)"
But this will only work once… that’s not good. make
is the ultimate
programmer and does as little as necessary. So if it sees the target tifling
existing, and it’s not depending on anything, it won’t do anything. You already
have a tifling
after all.
This obviously clashes with reality. So let’s add all Go files in our directory as dependencies. If any of them change, it’s time for a rebuild. Of course, we also want to rebuild if only a dependency is updated, so let’s also add the module files.
VERSION = $(shell git describe --tags --abbrev=0) || v0.0.1
gosrc = $(shell find * -type f -name '*.go') go.mod go.sum
tifling: $(gosrc)
go build -o $@ -ldflags "-X main.Version=$(VERSION)"
So we end up with 4 lines of Makefile and one target. Way better, easier to read, and easier to maintain.
Olivier Mengué has shared his way of listing source files on Mastodon, which has a few advantages over using find. So please take a look and give him a star!
A better Makefile
I like to keep my Makefiles simple. Macros tend to scare me and if you can read them without taking a university course on what Make is, its history, and the dark arts of macro-magic that’s a free bonus.
This is why Make is by some considered one of the dark arts… I think our Makefile should also follow the general “no magic” rule.
— Moritz Poldrack on the aerc-devel mailing list
Let’s instead use make
for what it is actually intended: Automating
multi-step build setups.
Generating sources
Ever used quicktemplate
? It’s awesome! But you have to regenerate the
go-file every time the template is modified. Wait… that’s perfect for Make!
balls: $(GO_SOURCES) Makefile
$(GO) build -ldflags "$(GO_LDFLAGS)" -trimpath $(GO_FLAGS) -v mpldr.codes/balls
frontend/%.qtpl.go: frontend/%.qtpl
$(GO) run github.com/valyala/quicktemplate/qtc -file $<
Now we are automatically generating the frontend file every time the respective
template is changed. And we don’t have to add the qtc
binary to our $PATH
either. The full version also regenerates the binary if only
a new template was added, but is not technically necessary.
Of course we are not limited to simple steps. Be it grpc, multifile OpenAPI clients, and more complex UIs. (Almost) Nothing is impossible.
You’re no .PHONY
Sometimes we have files we always have to rebuild (like the “more complex UI” from earlier), or there really is no file associated with the target, and we actually use it as autocomplete
Take aercs make fmt
and make gitconfig
, which
are just aliases/scripts. Here the commands are hard to remember and “make fmt
-ing right” is “more speaking”, than “go run mvdan.cc/gofumpt
@
tag
$(gofumpt_tag)
and -w
rite changes for .
the current dir”. Helping greatly
in lowering the required braincells for formatting your code. If we used
go fmt
, this Make target would likely not exist, because it is simply not
needed. (Remember the prepare
target?)
If you are using make
as a glorified shell script, that is of course alright
(though build
really is a bad target). As someone who forgets why he got
there when arriving at the toilet, I am all for shorter commands, but make them
useful and mark them as .PHONY
. Remember, if the target already exists,
make
doesn’t bother running it. By declaring it as .PHONY
we can make sure
our fmt
target is executed even if a file named fmt
is already in the
directory3.
Use GoReleaser
Wait, what? Wasn’t the point to not use GoReleaser? Absolutely not. GoReleaser is a great tool for compiling and uploading artifacts. And as always: Use the right tool for the job. Even better: you can just use GoReleaser from a Makefile. Just create a target for releases and use GoReleaser in there.
.PHONY: release
release:
go run github.com/goreleaser/goreleaser@v1.24.0 build
go run github.com/goreleaser/goreleaser@v1.24.0 publish
go run github.com/goreleaser/goreleaser@v1.24.0 announce
By combining this with a bit of CI-magic, we can now can have fully automated releases, simply by pushing a tag:
- release-version: |
cd ~/uniview
if git describe --exact-match HEAD; then
make release
else
complete-build
fi
Neat!
TL;DR
Don’t replace your Makefiles with GoReleaser, instead use GoReleaser to enhance
your release experience and use it through standard tools like make
to make
your workflow quicker and more robust. Tools have their specialties, and it’s
important to not hammer the nail with a wrench.