How do I run Staticcheck the same way locally and in CI?
To run Staticcheck the same way on a laptop and in CI, pin one Staticcheck version and commit your staticcheck.conf files. Analyze every Go module with a Go version that the release supports, and keep the caches between runs. By hand, teams run go install with a pinned version on each laptop and use the Staticcheck GitHub Action in CI. Dagger meets the same requirements in one place. The official Staticcheck module runs Staticcheck as a check, and dagger check runs that check on a laptop and in Cloud Checks.
What a good Staticcheck setup needs
A Staticcheck setup that gives the same result on a laptop and in CI meets five requirements. Each one comes from the Staticcheck docs or from the go command:
- Pin the Staticcheck version. The Staticcheck docs tell you to pin it, because “new versions of Staticcheck introduce new checks that may break your build”.
- Commit the staticcheck.conf files. Staticcheck configuration files contain TOML and apply to the subtree of packages below them. When there are several, Staticcheck merges them, and a file deeper in the tree overrides the files above it.
- Analyze every Go module.
staticcheck ./...checks all packages that the pattern matches. The./...pattern does not match directories that hold their owngo.mod. You reach those packages only when you change into that module’s directory. - Use a supported Go version. “A given Staticcheck release supports the last two versions of Go” at the time of the release. Staticcheck targets the Go version in the
godirective ofgo.mod. - Keep the caches between runs. The Staticcheck GitHub Action “saves and restores Go’s build cache (in addition to Staticcheck’s own cache) to speed up future runs”.
The examples come from a repository with one staticcheck.conf at the root and a second one in tools/migrate. The root file keeps the default checks and turns off the package comment check, ST1000:
checks = ["inherit", "-ST1000"]
The tools/migrate directory is a separate Go module. Its staticcheck.conf also turns off S1003, with checks = ["inherit", "-S1003"].
Run Staticcheck by hand
On a laptop, most teams install Staticcheck with go install. The Staticcheck docs say that you can use a specific version, such as @2020.2.1, in place of @latest. Install the release that CI uses:
go install honnef.co/go/tools/cmd/staticcheck@2026.2.1
go: downloading honnef.co/go/tools v0.8.1
go: honnef.co/go/tools@v0.8.1 requires go >= 1.26.0; switching to go1.26.10
go: downloading go1.26.10 (darwin/arm64)
go: downloading golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc
go: downloading github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c
go: downloading golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678
go: downloading golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
go: downloading golang.org/x/sync v0.20.0
go: downloading golang.org/x/mod v0.35.0
This release needs Go 1.26 or newer to build. The laptop had Go 1.24, so the go command downloaded a newer toolchain. Each Staticcheck release has two names: 2026.2.1 is also v0.8.1. Confirm the version:
staticcheck -version
staticcheck 2026.2.1 (0.8.1)
Run it from the repository root:
staticcheck ./...
main.go:10:9: should use strings.Contains(s, "h") instead (S1003)
The run covers only the root Go module. go list shows the packages that ./... matches from the root:
go list ./...
example.com/lintdemo
To analyze tools/migrate, run staticcheck ./... again in that directory. There, its own staticcheck.conf turns off S1003, and Staticcheck reports nothing.
Run Staticcheck in a CI job
In GitHub Actions, the Staticcheck docs describe staticcheck-action. Its version input selects the release, and its default is latest. Its install-go input decides whether the action installs Go. The action also saves and restores the Go build cache and the Staticcheck cache. The docs list no other CI systems, so a job on another system runs the same go install and staticcheck ./... commands. A repository with several Go modules runs Staticcheck once in each module directory, often in a shell loop or a job matrix.
Where the hand-built setup strains
The steps above meet each requirement, but each one lives in more than one place:
- The version lives in several files. The
go installcommand on each laptop and the action’sversioninput must change together. If the action has noversion, it uses the latest release. - Each Go module needs its own run. In this repository, the root run did not analyze
tools/migrate. A new module needs a new directory in the loop or the matrix. - The release and the Go version must match. A release supports two Go versions. When a
go.modmoves to a newer Go, each laptop and each CI job needs a newer Staticcheck. - The cache stays on one machine. The action’s cache is for CI only, and each laptop keeps a cache of its own.
Meet the requirements with the Staticcheck module
The Dagger Staticcheck module meets these requirements with one check. A Dagger module is a package of functions that build and test a project. A check is a function that dagger check runs and reports as passed or failed. The project needs a Git repository and a Dagger workspace. Run dagger init from the Quickstart to create dagger.toml. This file holds the workspace configuration for everyone who uses the repository.
Install the module from the repository root:
dagger module install dagger.io/go/staticcheck
The command adds the module to dagger.toml under the name staticcheck:
[modules.staticcheck]
source = "dagger.io/go/staticcheck"
The command also creates dagger.lock. This file pins the Git ref of the module. The first check run adds the golang images that the check uses, so every machine uses the same versions. Commit it with dagger.toml. When dagger module recommend finds a staticcheck.conf, it suggests the module:
┃ staticcheck: found staticcheck.conf
┃
┃ dagger module install dagger.io/go/staticcheck
┃
┃ No modules were installed. Run the commands for the modules you select.
The module adds one check, lint. It runs once for each Staticcheck project. List it:
dagger check -l
MODULE STATICCHECK-PROJECT CHECK DESCRIPTION
staticcheck * lint Run Staticcheck in this project.
# Use --all to expand collections and list each item.
The sections below meet each requirement with this check.
Pin the Staticcheck version with Dagger
The version setting selects the Staticcheck release for every project, and its default is v0.8.1 (Staticcheck module reference). The module uses the v0.x.y name of a release.
List the settings:
dagger module settings staticcheck
MODULE KEY VALUE DESCRIPTION
staticcheck version Staticcheck release, e.g. "v0.8.1".
staticcheck goVersion Go version for every module, overriding…
staticcheck base Base image for Go containers. Must incl…
staticcheck includeExtraFiles Extra workspace files to mount for Go c…
staticcheck lint Project roots to lint, e.g. ["**", "!do…
Set the release:
dagger module settings staticcheck version v0.8.2
Dagger stores settings in dagger.toml under [modules.staticcheck.settings]. The pin applies to everyone who uses the repository, and to Cloud Checks. The version lives in one file.
Commit the staticcheck.conf files with Dagger
The module analyzes only the directories that hold a staticcheck.conf. A repository with no staticcheck.conf has nothing to analyze, so commit one at the repository root first. A Staticcheck project is a directory that holds a staticcheck.conf.
Staticcheck merges the configuration files itself, and the module keeps their directory structure. Thus checks = ["inherit", "-S1003"] in tools/migrate/staticcheck.conf has the same effect as with staticcheck ./... on your machine.
To turn off one Staticcheck check for a directory, use staticcheck.conf. To turn off a check for one line, use a //lint:ignore directive. The lint setting of the module decides which projects run. If a directory has its own staticcheck.conf but the module must not analyze it yet, exclude it. The setting is a list, so edit it directly in dagger.toml:
[modules.staticcheck.settings]
version = "v0.8.2"
lint = ["**", "!tools/migrate"]
Each pattern selects project roots by their path from the repository root. "**" matches every project. !path excludes that project and the projects below it, and an exclusion always wins. The excluded project does not show in the dagger check -l --all list, and Staticcheck does not analyze it.
Analyze every Go module with Dagger
A project covers each Go module below it. It also covers each package below it that a nested staticcheck.conf does not claim. You write no loop and no matrix. With --all, the list shows one row for each project:
dagger check -l --all
MODULE STATICCHECK-PROJECT CHECK DESCRIPTION
staticcheck . lint Run Staticcheck in this project.
staticcheck tools/migrate lint Run Staticcheck in this project.
The module finds the projects at or below the directory where dagger runs, plus the nearest project above it. To cover the whole repository, run dagger from the root. In this run, Staticcheck found the S1003 problem in the root module:
dagger check --staticcheck
== CHECKS == ✘ 1 failed
✘ dag://?check=staticcheck/projects/lint 8.3s ERROR
✘ withExec sh -c 'set -e\npattern=$1\nskips=$2\nshift 2\nIFS='\''\n'\''\nroot=$(pwd -P)\ndirs=$(go list -e -find -f '\''{{.Dir}}'\'' "$pattern")\npkgs=\nfor dir in $dirs; do\n keep=1\n for skip in $skips; do\n case "$dir/" in "$root/$skip"/*) keep= ;; esac\n done\n if [ -n "$keep" ]; then pkgs="$pkgs$dir$IFS"; fi\ndone\nif [ -z "$pkgs" ]; then echo "no packages under $pattern" >&2; exit 0; fi\nexec "$@" $pkgs' go-packages ./... tools/migrate staticcheck 0.6s ERROR
┃ main.go:10:9: should use strings.Contains(s, "h") instead (S1003)
! exit code: 1
== RUN LOCALLY ==
dagger check "dag://?check=staticcheck/projects/lint"
The report shows the diagnostic in Staticcheck’s own format, with the check ID. The RUN LOCALLY command runs the failed selection again. After the fix, both projects pass:
== CHECKS == ✔ 1 passed
✔ dag://?check=staticcheck/projects/lint 0.8s OK
To analyze one project, give its directory:
dagger check --staticcheck-project=tools/migrate
The module also analyzes test files. It does not run tests or generators.
Use a supported Go version with Dagger
The module analyzes each Go module in golang:<version>-alpine, for the version in that module’s go.mod. Staticcheck then targets that same version. If every Go module must use the same Go toolchain, set goVersion. If each go.mod already asks for the version you want, skip this setting.
dagger module settings staticcheck goVersion 1.26
This setting is separate from the version setting of the Go module. That setting applies to tests and generators.
Each project runs on a copy of the repository that Dagger assembles. The copy holds the Go sources, embedded assets, the staticcheck.conf files, and local replace targets. Most projects need nothing more. Two settings cover the other cases:
includeExtraFilesadds files that the source scan does not find, as patterns from the repository root, such as["shared-assets/**"]. The scan follows//go:embed, but it does not follow other file reads.basegives Staticcheck a container of your own, for example one with the C libraries that cgo packages need. The container must have a Go toolchain, and the module ignoresgoVersionwhenbaseis set. If the container does not have Staticcheck, the module installs it. A function in your own module can build this container through module wiring. The golangci-lint guide shows this pattern for the same setting.
Keep the caches between runs with Dagger
The module keeps the Go module, build, and Staticcheck caches between runs (Staticcheck module reference). It mounts them as cache volumes, directories that persist across runs on the Dagger engine. You write no cache key. On a laptop, the caches live in the local engine. In Cloud Checks, they live on Dagger’s Cloud Engines. How do I share the Go build cache across CI runs and runners? covers how Cloud Engines share their cache.
Run it locally and with Cloud Checks
On a laptop, dagger check runs the Staticcheck check with the other checks in the workspace. In CI, use Cloud Checks. Cloud Checks run your checks on Dagger’s Cloud Engines after each push and pull request, with no CI workflow file. Commit the workspace files:
git add dagger.toml dagger.lock staticcheck.conf
git commit -m "Run Staticcheck with Dagger"
Connect the GitHub repository to Dagger Cloud, then push:
dagger cloud checks on
git push -u origin HEAD
The command gets the repository from the Git origin remote. In an interactive terminal, it offers to sign you in and to connect GitHub if you have not done that yet. Cloud Checks run the same staticcheck/projects/lint check, with the same pinned version and the same configuration. dagger workspace activity shows the recent runs.
Bottom line: To run Staticcheck the same way locally and in CI, pin its version and commit your staticcheck.conf files. Analyze every Go module with a supported Go version, and keep the caches between runs. The dagger.io/go/staticcheck module meets these requirements in one check, and dagger check runs it on a laptop and in Cloud Checks.
Related guides
- How do I run golangci-lint the same way locally and in CI?: the same requirements for golangci-lint, which also runs the Staticcheck checks.
- How do I run Go tests, lint, and go generate the same way locally and in CI?: the Go module for tests and
go generate, next to the linter. - How do I share the Go build cache across CI runs and runners?: how the Go caches persist on laptops and Cloud Engines.