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:

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

← All articles