As of now, unlike Python or NodeJS, Go doesn’t allow you to specify your development dependencies separately from those of the application. However, I like to specify the dev dependencies explicitly for better reproducibility.
While working on a new CLI tool1 for checking dead URLs in Markdown files, I came across
this neat convention: you can specify dev dependencies in a tools.go
file and then exclude
them while building the binary using a build tag.
Here’s how it works. Let’s say our project foo
currently has the following structure:
foo
├── go.mod
├── go.sum
└── main.go
The main.go
file contains a simple hello-world function that uses a 3rd party dependency
just to make a point:
package main
import (
"fmt"
// Cowsay is a 3rd party app dependency
cowsay "github.com/Code-Hex/Neo-cowsay"
)
func main() {
fmt.Println(cowsay.Say(cowsay.Phrase("Hello, World!")))
}
Here, Neo-cowsay
is our app dependency. To initialize the project, we run the following
commands serially:
go mod init example.com/foo # creates the go.mod and go.sum files
go mod tidy # installs the app dependencies
Now, let’s say we want to add the following dev dependencies: golangci-lint2 to lint the
project in the CI and gofumpt3 as a stricter gofmt
. Since we don’t import these tools
directly anywhere, they aren’t tracked by the build toolchain.
But we can leverage the following workflow:
- Place a
tools.go
file in the root directory. - Import the dev dependencies in that file.
- Run
go mod tidy
to track both app and dev dependencies viago.mod
andgo.sum
. - Specify a build tag in
tools.go
to exclude the dev dependencies from the binary.
In this case, tools.go
looks as follows:
// go:build tools
package tools
import (
// Dev dependencies
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
_ "mvdan.cc/gofumpt"
)
Above, we’re importing the dev dependencies and assigning them to underscores since we won’t
be using them directly. However, now if you run go mod tidy
, Go toolchain will track the
dependencies via the go.mod
and go.sum
files. You can inspect the dependencies in
go.mod
:
// go.mod
module example.com/foo
go 1.21.6
require (
github.com/Code-Hex/Neo-cowsay v1.0.4 // app dependency
github.com/golangci/golangci-lint v1.55.2 // dev dependency
mvdan.cc/gofumpt v0.5.0 // dev dependency
)
// ... transient dependencies
Although we’re tracking the dev dependencies along with the app ones, the build tag
// go:build tools
at the beginning of tools.go
file will instruct the build toolchain to
ignore them while creating the binary.
From the root directory of foo
, you can build the project by running:
go build main.go
This will create a binary called main
in the root directory. To ensure that the binary
doesn’t contain the dev dependencies, run:
go tool nm main | grep -Ei 'golangci-lint|gofumpt'
This won’t return anything if the dev dependencies aren’t packed into the binary.
But if you do that for the app dependency, it’ll print the artifacts:
go tool nm main | grep -Ei 'cowsay'
This prints:
1000b6d40 T github.com/Code-Hex/Neo-cowsay.(*Cow).Aurora
1000b6fb0 T github.com/Code-Hex/Neo-cowsay.(*Cow).Aurora.func1
1000b5610 T github.com/Code-Hex/Neo-cowsay.(*Cow).Balloon
1000b6020 T github.com/Code-Hex/Neo-cowsay.(*Cow).Balloon.func1
...
For some weird reason, if you want to include the dev dependencies in your binary, you can
pass the tools
tag while building the binary:
go build --tags tools main.go
However, this will most likely fail if any of your dev dependencies aren’t importable.
Here’s an example4 of this pattern in the wild from the Kubernetes repo.
While it works, I’d still prefer to have a proper solution instead of a hack. Fin!
Recent posts
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing
- Behind the blog