Managing project tools with Go
TOC
There are multiple ways to deal with non-application dependencies (i.e. “tools” that your project needs).
go tool
As of Go 1.24 (Feb 2025)
To add a new tool:
go get -tool golang.org/x/lint/golint
go get -tool github.com/mgechev/revive@latest
To run the tool:
go tool golint -h
go tool golang.org/x/lint/golint -h # in case of naming overlap
To see a list of all tools:
go tool
To update all tools:
go get -u tool
If you check the go.mod
you’ll see a new tool syntax:
module testing-tools
go 1.23.4
tool (
github.com/mgechev/revive
golang.org/x/lint/golint
)
Caveats and Issues
Now, there is a problem (sort of), which is that you’ll see a bunch of indirect dependencies showing up in the go.mod
.
This is because these are the dependencies that your “tools” need.
I’m less concerned about that as a side-effect of using the new go tools
feature, but I appreciate it’s not ideal.
My concern being: it’s more mental overhead.
You don’t know if these indirect dependencies are transient dependencies used by your application dependencies, or if they’re dependencies for the “tools” you’ve installed.
The reason I’m not usually that fussed by this is because I only really care about the “direct” dependencies, and those are always clear because they don’t have // indirect
following them.
So the following instructions are only relevant if you really care about this.
Multiple Module Files
There is another option on the table that we can use, and it doesn’t appear to be too much additional maintenance or mental overhead, which is great. But it does have a downside (see the IMPORTANT
note at the end of this section).
Essentially, the approach is to have a separate modfile for tools.
It means we’d have multiple files now, like this…
go.mod
go.sum
tools.mod
tools.sum
⚠️ IMPORTANT:
If you give thetools.mod
a unique module name, let’s saygo.mod
usesgithub.com/example/foo
, and so you maketools.mod
usegithub.com/example/foo/tools
then be aware that the use ofgo mod
isn’t going to make yourtools.mod
think it needs the module fromgo.mod
and it’ll add it as a dependency (this makes things weird in special cases), so it might be worth making the module name the same betweengo.mod
andtools.mod
.
To install a new tool:
# instead of...
go get -tool github.com/mgechev/revive
# we do...
go get -modfile=tools.mod -tool github.com/mgechev/revive
💡 TIP:
To remove a tool you can do the above but set the version to@none
.
And if we want to use that tool we have to make sure to specify the modfile:
$ go tool revive --version
go: no such tool "revive"
$ go tool -modfile=tools.mod revive --version
version 1.7.0
Having to specify the -modfile
flag isn’t a big issue as we already have go tool
abstracted inside the various Makefile targets, so we should only ever be calling a Makefile target (or in the case of stringer have it codified in the go generate directive in the code itself).
As far as updating tools, you can either do it a dependency at a time or all of them at once:
# instead of...
go get -u -tool github.com/mgechev/revive@latest
go get -u tool
# we do...
go get -u -modfile=tools.mod -tool github.com/mgechev/revive@latest
go get -u -modfile=tools.mod tool
Same for listing the installed tools:
# instead of...
go tool
# we do...
go tool -modfile=tools.mod
💡 TIP:
Can also trygo list -modfile=tools.mod tool
To verify the integrity of the tool dependencies:
go mod verify -modfile=tools.mod
Here’s an associated Makefile:
.PHONY: deps-app-update
deps-app-update: ## Update all application dependencies
go get -u -t ./...
go mod tidy
if [ -d "vendor" ]; then go mod vendor; fi
.PHONY: deps-outdated
deps-outdated: ## Lists direct dependencies that have a newer version available
@go list -u -m -json all | go tool -modfile=tools.mod go-mod-outdated -update -direct
TOOLS = \
cuelang.org/go/cmd/cue \
github.com/client9/misspell/cmd/misspell \
github.com/go-delve/delve/cmd/dlv \
github.com/mgechev/revive \
github.com/psampaz/go-mod-outdated \
github.com/stealthrocket/wasi-go/cmd/wasirun \
github.com/stern/stern \
github.com/tetratelabs/wazero/cmd/wazero \
golang.org/x/lint/golint \
golang.org/x/tools/cmd/stringer \
golang.org/x/tools/go/analysis/passes/nilness/cmd/nilness \
golang.org/x/vuln/cmd/govulncheck \
honnef.co/go/tools/cmd/staticcheck \
mvdan.cc/gofumpt \
.PHONY: tools
tools:
@$(foreach tool,$(TOOLS), \
if ! go tool -modfile=tools.mod | grep "$(tool)" >/dev/null; then \
go get -modfile=tools.mod -tool "$(tool)"@latest; \
fi; \
)
.PHONY: tools-update
tools-update:
go get -u -modfile=tools.mod tool
go mod tidy -modfile=tools.mod
⚠️ IMPORTANT:
This approach keeps the maingo.mod
andgo.sum
clean of any tool dependencies, but not the other way around. So thetools.mod
andtools.sum
will ultimately contain all the dependencies from the maingo.mod
(that is a side-effect of runninggo mod tidy -modfile=tools.mod
asgo mod
always consults the maingo.mod
, hence all of its dependencies end up in yourtools.mod
andtools.sum
).This is unavoidable. There is no way to get around it (trust me, I’ve tried 😅).
Now, this isn’t the end of the world as the
tools
directive is still at the top of thetools.mod
and is very clear as to what “tools” are installed, but yeah, you’ll also see a bunch ofrequire
directives (related to your main Go project) as well, unfortunately.One thing you could do, is only run the
go get -u -modfile=tools.mod tool
command, which would keep yourtools.mod
clean, and would only updatetools.sum
with the relevant updated dependencies. The problem with that is the old dependencies aren’t cleaned out. e.g. if you updated tool “foo” from version 1.0 to 2.0 then both versions appear in yourtools.sum
(this is why we havego mod tidy
to ensure only 2.0 is present in thetools.sum
). So one approach would simple be to manually clean up thego.sum
everytime after runninggo get -u -modfile=tools.mod tool
– it’s not that difficult as you just look for the new tool version added and remove the old one, but it’s a manual process and that sucks).
tools.go
🗒️ NOTE:
For more details on code generation in a general sense, refer to:
https://gist.github.com/Integralist/8f39eb897316e1cbeaf9eff8326cfa59
The following file internal/tools/tools.go
uses a build tag to avoid the dependencies being compiled into your application binary…
//go:build tools
// Package tools manages go-based tools that are used to develop in this repo.
package tools
import (
_ "github.com/nbio/cart"
_ "github.com/nbio/slugger"
_ "github.com/psampaz/go-mod-outdated"
_ "github.com/stealthrocket/wasi-go/cmd/wasirun"
_ "github.com/tetratelabs/wazero/cmd/wazero"
_ "golang.org/x/lint/golint"
_ "golang.org/x/tools/cmd/stringer"
_ "golang.org/x/vuln/cmd/govulncheck"
)
//go:generate go install github.com/nbio/cart
//go:generate go install github.com/nbio/slugger
//go:generate go install github.com/psampaz/go-mod-outdated
//go:generate go install github.com/stealthrocket/wasi-go/cmd/wasirun
//go:generate go install github.com/tetratelabs/wazero/cmd/wazero
//go:generate go install golang.org/x/lint/golint
//go:generate go install golang.org/x/vuln/cmd/govulncheck
//go:generate go install golang.org/x/tools/cmd/stringer
Notice the go:generate
comments? Yup, we invoke them like so (notice the -tags
flag):
tools: internal/tools/tools.go
go generate -v -x -tags tools ./internal/tools/...
go run
An alternative to this approach is to use go run
directly, which downloads tools to a cache but doesn’t install them and yet still gives you explicit versioning consistency across developer’s machines…
//go:generate go run golang.org/x/tools/cmd/stringer@v0.25.0 -type=Scope -linecomment
I then invoke go generation with:
.PHONY: go-gen
go-gen: ## Invoke go generate
@# The `-x` flag prints the shell commands that `go generate` runs.
go generate -v -x ./mustang/status/...
💡 TIP:
If you’re developing whilst offline, then one advantage the tools.go pattern has is that it works whilst offline because the tool is explicitly installed. But to work around that withgo run
you can setexport GOPROXY=direct
and as long as you have the module in your local cache you’ll be able to use it.