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@latest
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.
goimports and deadcode were go install ...@latest from my
laptop script, so the version depended on the day I last
ran it. Two CI boxes provisioned a month apart ran different linters.
My CI runners now install neither, so a check that calls either one
bare fails with command not found. Each repo pins them. How a repo
pins them depends on whether another module imports the repo.
An app pins them as tool directives:
go get -tool golang.org/x/tools/cmd/goimports@v0.45.0
go get -tool golang.org/x/tools/cmd/deadcode@v0.45.0
I pin x/tools at the version the module already requires. Then the
tool lines add a few indirect requires and change no dependency the
app ships.
A library runs them through go run at a version instead:
go run golang.org/x/tools/cmd/goimports@v0.45.0 -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 imports the library. go run pkg@version
reads no requirement from go.mod, so the library's graph stays as
small as its imports. The version lives in the Checkfile instead.
gopls stays an install, pinned by a version variable in the runner
provisioning script. As a tool directive it adds 23 requires, and its
hints change between versions more than any other check.
How they run
The commands live in two places:
AGENTS.mdlists the checks with a rule for each: run X if Y files touched. See ai/agents.- sockeye runs them in CI, one check per
Checkfileline.
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. A
library's go run form does the same with a first run of -h whose
output it throws away.
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
A push is done when its longest check is done. In one repo, the web package's tests are one check, about 77 test-seconds, and they cannot split further. The rest of the suite is about 150 test-seconds, split into shards by package.
I pick the shard count from measured per-package test times, not by guess. A shard longer than the fixed check sets the pace. A shard shorter than it costs nothing. With two shards, the largest was 81.5 test-seconds, over the fixed check. With three, it was 67.3, the first count under it. Four would give 49.0 and save nothing, because the fixed check still sets the pace. A fourth shard would also take a fifth runner slot on a box with four, and another share of the runner's Postgres connections.
I measure again when the suite grows. The counts in the shard scripts had gone stale.
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.
I run it periodically:
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
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