go / checks

I run five checks before committing Go code:

go tool goimports -local "$(go list -m)" -w .
go vet ./...
go test ./...
go tool deadcode -test ./...
gopls check -severity=hint ./*.go

The checks run fast to slow. gopls is last, and it loads the whole workspace.

goimports

goimports formats code like gofmt and also adds and removes imports.

The -local flag groups imports into three sections: standard library, third-party, and local module.

import (
    "fmt"
    "net/http"

    "github.com/someone/pkg"

    "mymodule/internal/foo"
)

go vet

go vet reports likely mistakes such as printf format errors.

go test

go / test covers the go test flags, assertions, and fixtures.

deadcode

deadcode finds functions that are never called. It analyzes the whole program from main, so it works only on executables. The -test flag includes test binaries in the analysis.

I remove an unreachable function or write a test that reaches it.

The -test flag helps most in a project with an entry point deadcode cannot analyze, such as WASM.

gopls

gopls, the Go language server, reports diagnostics beyond go vet: modernizers that flag older idioms, and hints like an unchecked bufio.Scanner.Err. I run it as a check so these show up before review.

go install golang.org/x/tools/gopls@v0.23.0

With -severity=hint, a hint fails the check. gopls check takes file paths, so I pass ./*.go.

On a large repo, checking every file is slow. There I scope the check to the files a branch changed against main:

git diff --name-only --diff-filter=d origin/main...HEAD -- '*.go' |
  xargs gopls check -severity=hint

A Go or gopls upgrade can add hints to untouched files. A full scan catches those:

git ls-files -z '*.go' | xargs -0 gopls check -severity=hint

Pinned tools

A check is reproducible only when every machine runs the same tool version. Go 1.24 added the tool directive for this. My SQL formatter uses it:

go get -tool github.com/croaky/pgfmt/cmd/pgfmt
go tool pgfmt -c <pkg>/queries/*.sql

The version lives in go.mod, so go tool builds the same version on my laptop and in CI, with nothing on $PATH. An upgrade is a commit that also reformats what the new version formats differently.

My CI runners install none of goimports, deadcode, and govulncheck, so a check that calls one bare fails with command not found. Each repo pins them as tool lines. Where the lines go depends on whether another module requires the repo.

An app pins them in go.mod:

go get -tool golang.org/x/tools/cmd/goimports@v0.52.0
go get -tool golang.org/x/tools/cmd/deadcode@v0.52.0
go get -tool golang.org/x/vuln/cmd/govulncheck@v1.9.0

A go get -tool can raise a requirement that the app also builds with. I read the go.mod diff, and I keep the raise when the tests pass.

A library pins them in a second file, tools.mod:

go get -modfile=tools.mod -tool golang.org/x/tools/cmd/goimports@v0.52.0
go tool -modfile=tools.mod goimports -local "$(go list -m)" -l .

A tool line in a library's go.mod puts x/tools in the module graph of every app that requires the library. tools.mod names the same module, but only a command with -modfile=tools.mod reads it. So the library's graph stays as small as its imports, and tools.sum holds the checksum of each tool. The tool itself then reads go.mod, so govulncheck scans the versions that the library ships. I also use tools.mod in an app that must keep a dependency at an older version than the tools need.

I do not run go mod tidy -modfile=tools.mod. It copies the requirements of go.mod into tools.mod. I add or update a tool only with go get -modfile=tools.mod -tool.

go run pkg@version also pins a version, and the checksum database verifies the download. But no file in the repo records the checksum, and an update is an edit to a string in the Checkfile.

gopls stays an install. The runner provisioning script and my laptop script both name its version, so the editor and CI report the same hints. As a tool directive it adds 23 requires, and its hints change between versions more than any other check.

The same rule covers the binaries I download. The runner provisioning script names a version and a SHA-256 for Go, dprint, tree-sitter, tsgo, and chrome-headless-shell, and fails unless the archive matches the hash. My laptop script does the same for dprint and tsgo. A version names a release, and the hash names the bytes.

How they run

The commands live in two places:

deadcode and gopls exit 0 whether or not they find anything, so a check reads their output as the verdict and fails on any line:

deadcode: go tool -n deadcode >/dev/null && go tool deadcode -test ./... 2>&1 | awk '1; END { exit (NR > 0) }'

That makes the check count go: downloading lines too. A runner whose module cache does not hold the tool prints them while go tool builds it. Then the check fails with no dead code found. go tool -n builds the tool first and prints only its path, which the check discards.

On my two largest repos, a push does not run deadcode or gopls. Each loads and type-checks the whole module. On a push, the two took more runner time than the tests. They run as daily_deadcode and weekly_gopls against main instead (see govulncheck for the prefixes). So a dead function or a hint can land on main, and a later change fixes it. A change that deletes code runs soc scheduled daily_deadcode on its head before the merge. go vet stays on each push, because it finds bugs and a warm build cache makes it fast.

Project-specific checks live alongside these. One repo's Checkfile also runs nullscan. A check can also read the test run's output rather than run its own. My template render coverage check reads the -v output of the go test job, so the suite runs once.

Shards

The longest check sets the push's duration. I split test packages into shards, using measured test times. Each shard should finish before the longest check that cannot split. More shards then add load without shortening the push. The runner's slots and database connections limit how many can run together.

I measure again when the suite grows.

govulncheck

govulncheck checks the Go Vulnerability Database and reports only vulnerabilities the code reaches. Dependabot cannot filter that way. See Filippo Valsorda's Turn Dependabot Off.

sockeye runs it weekly in CI through Checkfile. An app pins it as a tool directive:

weekly_govulncheck: go tool govulncheck ./...

A library runs it from tools.mod, for the reason in pinned tools:

weekly_govulncheck: go tool -modfile=tools.mod govulncheck ./...

The weekly_ prefix tells sockeye to skip the check on change pushes, which keeps them fast. Sockeye runs the check on Sunday at 02:00 UTC against main.

I pin the tool and not its data. govulncheck reads the live vulnerability database on every run, so the weekly scan still finds an advisory published after the commit. Because change pushes skip it, I change the govulncheck version in a commit and run soc scheduled <change> weekly_govulncheck on that change to see the new version pass before I merge.

Fuzz tests

Go's native fuzzing finds inputs that a table test does not list. My largest web app has fuzz targets for its small text functions that take outside input: a domain parser, a CSV cell escape, a LIKE pattern builder, and a whitespace collapse. A daily_ check finds every Fuzz function and runs each one for five minutes:

daily_fuzz: ./bin/fuzz-all 5m

The daily_ prefix works like weekly_: sockeye skips it on pushes and runs it at 02:00 UTC each day against main. A fuzz run that finds a failure writes the input under testdata/fuzz/. I commit that file with the fix, so go test runs it as an ordinary case on every push after.

Latest dependencies

I also test against the newest dependency versions:

go get -u -t ./...
go mod tidy
go test ./...
git checkout go.mod go.sum  # restore pinned versions

← All articles