Manage Go tools with Go
In almost any code, there comes a time when you need to use external tools for certain functionality. For example, you may want to use golangci-lint
to lint your code, generate mocks with mockgen
, or run your DB schema migrations with sql-migrate
.
There are few ways to achieve this: some projects use Makefile
to install dependencies, some other use docker compose
and run tools by running containers with mounted volumes.
Today, I want to share a method that is known within Go community, but is still not frequently used.
To understand how it works, let’s start from scratch and install golangci-lint
with Go:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run
The first command installs the latest
version of golangci-lint
and the second command executes it (you will need to have $GOPATH/bin
in your PATH
env).
Of course, you can also install specific version of the binary:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.53.3
golangci-lint run
While the second option is better, as you use specific version of the tool, it’s still cumbersome to use. For example, what happens when new version of golangci-lint
comes out and you want to update? You and all your teammates will need to update the version manually. Even with Makefile
, you will need to run a make
command to install the latest version. This problem is even more prominent if you have multiple repositories that all use same tools (you will not always have time to update all of them at once).
This problem can be fixed with docker
or docker compose
, but Docker is slow and it doesn’t integrate well with go
commands.
Luckily, since Go 1.17 go run
command accepts an optional version suffix, which lets us run any Go binary without installing it locally:
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.53.3 run
This is already an improvement, because this line can now be put in e.g. Makefile
and it will work for any repository letting the developers forget about the problems of keeping their local version in sync with the version needed by the project. One person can update the version and it will be automatically used by every team member once they checkout the main branch.
You can also use it with go generate
command:
//go:generate go run github.com/vektra/mockery/v2@v2.32.0 --name HTTPListener
type HTTPListener func(addr string, handler http.Handler) error
This is great, however for large repositories, it may be a bit repetitive. go run
downloads the code for the binary from the internet and just runs it, which could have massive security implications. I like to improve from here by using an older pattern, or as I call it, “the tools
pattern”.
This is how it looks like in practice (this assumes this file is saved under tools/tools.go
):
//go:build tools
// To install everything from this file, run:
// go generate -tags tools tools/tools.go
package tools
import (
_ "github.com/cosmtrek/air" //go:generate go install github.com/cosmtrek/air
_ "github.com/daixiang0/gci" //go:generate go install github.com/daixiang0/gci
_ "github.com/golangci/golangci-lint/cmd/golangci-lint" //go:generate go install github.com/golangci/golangci-lint/cmd/golangci-lint
_ "github.com/vektra/mockery/v2" //go:generate go install github.com/vektra/mockery/v2
_ "gotest.tools/gotestsum" //go:generate go install gotest.tools/gotestsum
_ "mvdan.cc/gofumpt" //go:generate go install mvdan.cc/gofumpt
)
Let me explain what happens. First, we add //go:build tools
comment which instruments Go to include this file in a package only when tools
tag is explicitly specified.
Then, we specify the package name tools
and a list of imports. Each import path is aliased to _
(because we don’t actually use it in the file) and a path to the binary that you want to use. I also add //go:generate
comment with go install
command to install the binary in that line. Notice the go install
command does not specify the version this time.
With this pattern, Go is smart enough to include these packages in go.mod
file and thus version them. We can then vendor those, or validate that none of the packages have been modified (with go mod verify
) which improves security and repeatability in CI. Because I also add //go generate
comments, all tools can be installed with go generate -tags tools tools/tools.go
line. Go knows which version to install. Have I mentioned I call it the tools
pattern?
go generate
commands can then skip the version:
//go:generate go run github.com/vektra/mockery/v2 --name HTTPListener
type HTTPListener func(addr string, handler http.Handler) error
Now your Go tools can be installed and versioned by Go. Isn’t that great?
Caveats
It would be dishonest to not mention a few things that are not great about this approach:
-
go.mod
andgo.sum
become polluted with dependenciesDepending on how much you care about keeping your dependency list clean, this may not be desired.
If you prefer to not pollute the
go.mod
file, you still may use thego run tool@version
approach and skip thetools
pattern. It will let you version the dependencies, but is a bit more cumbersome to maintain. -
You run self-built binary
Because the dependency versions are resolved by
go.mod
file in projects repository, the locally compiled binary may by slightly different than the one officially distributed.I’ve personally never had any problems with it, but tools such as
golangci-lint
that heavily rely on dependency versions for functionality may behave slightly differently if you update packages only selectively. Personally, I’ve never experienced any problem like that.Some tools, even go as far as to print a warning. For example PlanetScale’s CLI,
pscale
, prints:!! WARNING: You are using a self-compiled binary which is not officially supported. !! To dismiss this warning, set PSCALE_DISABLE_DEV_WARNING=true
-
Doesn’t work with non-Go binaries
Sometimes, you just need to have a tool that is not build with Go. For example, when using gRPC,
protoc-gen-go
is used which can be versioned with this method, however, its dependency,protoc
cannot, as it’s not a Go program.Unfortunately, it means that for some repositories, you’d still need to maintain an alternative way to version binaries. Depending on the use case, it may still be worth it.
-
You need to configure your editor to use it
By default, most editors will not run
go run ...
for you so it requires a bit of configuration. The good news, is that once it’s done, it will work for any repository that uses this pattern. For most tools, it’s not required anyway.
Summary
I really like this pattern as it allows me to forget about managing versions of the tools I use in my projects. Additionally, tools like Dependabot or Renovate can help automate this process and keep your tools (and other dependencies) up to date. In short, I like that it “just works”.
There’s an open proposal in Go’s Github repository to track tool dependencies in go.mod
. This proposal is meant to improve the experience of managing tool dependencies with Go, but it’s unclear when, or how, it will be implemented.