Makefiles for the Makefile-Lazy Go developer


makefiles-for-the-makefile-lazy-go-developer.exe
created (updated in this commit)
Tags:

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 -write 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

a fusion of make and 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.


  1. Just what happens with 20 hours of interesting talks crammed into two days. ↩︎

  2. Programmers: Solving problems that have long been solved, again. But different. But still kinda the same. ↩︎

  3. For whatever reason that might be, but you know development: It can be crazy. ↩︎


Do you know better? Have a comment? Great! Let me know by sending an email to ~mpldr/public-inbox@lists.sr.ht


If you feel like it, you can Liberapay receiving, or GitHub Sponsors.
Unless stated otherwise the texts of this website are released under CC-BY and code-snippets are released into the public domain.
© Moritz Poldrack

RSS Feed available I am sponsoring the letter @. Yes, that's a thing. This website's content doesn't need AI to be stupid! Website Status