CI that keeps up

How do I run golangci-lint the same way locally and in CI?

To run golangci-lint the same way on a laptop and in CI, pin one golangci-lint version and commit a v2 configuration file. Lint every Go module, give the linter code that compiles, and keep its cache between runs. By hand, teams install a pinned binary on each laptop and use golangci-lint-action or a similar step in CI. Dagger meets the same requirements in one place. The official golangci-lint module runs the linter as a check, and dagger check runs that check on a laptop and in Cloud Checks.

What a good golangci-lint setup needs

A golangci-lint setup that gives the same result on a laptop and in CI meets five requirements. Each one comes from the golangci-lint docs or from the go command:

  1. Pin the golangci-lint version. The golangci-lint docs recommend “installing a specific version of golangci-lint” in CI. A new release can add or change linters, and then all builds fail at the same time.
  2. Commit a v2 configuration file. golangci-lint looks for .golangci.yml, .golangci.yaml, .golangci.toml, or .golangci.json. If it finds none in the project, it looks in your home directory, which is different on each machine. For the version key of a v2 file, “The only possible value is “2”.“
  3. Lint every Go module. The ./... pattern does not match directories that hold their own go.mod. You reach those packages only when you change into that module’s directory.
  4. Give the linter code that compiles. The golangci-lint FAQ says that “the code to analyze should compile”. For cgo, it says “ensure all required system libraries are installed”. golangci-lint also supports only the Go versions up to the one that compiled it.
  5. Keep the cache between runs. The first run “caches type information. All subsequent runs will be faster”, in the words of the FAQ.

The examples come from a repository with golangci-lint configuration files at the root, in services/api, and in legacy. Each of these three directories is a Go module, and a fourth Go module in tools/migrate has no configuration file of its own.

Run golangci-lint by hand

On a laptop, most teams install a pinned binary with the install script and run golangci-lint run. The golangci-lint docs recommend the binary and say that go install and tool directive installs “aren’t guaranteed to work”. Install the release that CI uses:

curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.4
golangci-lint version
golangci-lint has version 2.11.4 built with go1.26.1 from 8f3b0c7e on 2026-03-22T17:35:14Z

Go 1.26.1 compiled this binary, so it lints Go modules up to Go 1.26.1. Run it from the repository root:

golangci-lint run ./...
main.go:10:9: S1003: should use strings.Contains(s, "h") instead (staticcheck)
	return strings.Index(s, "h") != -1
	       ^
1 issues:
* staticcheck: 1

The run reports only the root module. services/api has its own go.mod, so the root run does not lint it. Run golangci-lint in that directory too:

cd services/api && golangci-lint run ./...
api.go:11:9: Error return value of `f.Close` is not checked (errcheck)
	f.Close()
	       ^
1 issues:
* errcheck: 1

Many teams also lint before each commit. The golangci-lint repository defines hooks for the pre-commit framework. Its golangci-lint hook lints only the changes since HEAD, and golangci-lint-full lints all files.

Run golangci-lint in a CI job

In GitHub Actions, the golangci-lint docs recommend golangci-lint-action. Its version input selects the release, and it saves and restores ~/.cache/golangci-lint between runs (golangci-lint-action). For a repository with several Go modules, its working-directory input is “useful for monorepos”. The README also shows a job matrix with one job for each module of a Go workspace. GitLab CI and Buildkite have a component and a plugin, which the CI installation page lists. If the code uses cgo, the job installs the C libraries before the lint step.

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 install command on each laptop, the action’s version input, and the pre-commit rev must change together.
  • Each Go module needs its own run. In this repository, the root run did not report the problem in services/api. A new module needs a new directory in the matrix, or a loop in a script.
  • The linter and the Go version must match. When a go.mod moves to a newer Go release, each laptop and each CI job needs a newer golangci-lint binary.
  • Each machine installs the C libraries. Each laptop and each CI job installs the cgo dependencies of the project, and their versions can differ.
  • 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 golangci-lint module

The Dagger golangci-lint module meets these requirements with one check. A Dagger module is a package of functions that build, test, or check a project. The Dagger team maintains a catalog of official modules for common tools. The project must be a Git repository with a dagger.toml file from the Quickstart. Install the golangci-lint module from the project root:

dagger module install dagger.io/go/golangci-lint

The command adds the module to dagger.toml:

[modules.golangci-lint]
source = "dagger.io/go/golangci-lint"

If you do not know which modules the project needs, run dagger module recommend. It suggests this module when it finds a golangci-lint configuration file:

✔ go: found go.mod
┃ 
┃   dagger module install dagger.io/go
┃ 
┃ golangci-lint: found .golangci.yml
┃ 
┃   dagger module install dagger.io/go/golangci-lint
┃ 
┃ No modules were installed. Run the commands for the modules you select.

A check is a function that passes or fails, such as a test or a linter, and dagger check runs it. The module gives each lint project one check, lint. A lint project is a directory that holds a golangci-lint configuration file. List the check:

dagger check -l
MODULE         GOLANGCI-LINT-PROJECT  CHECK  DESCRIPTION
golangci-lint  *                      lint   Run golangci-lint in this project.
# Use --all to expand collections and list each item.

The sections below meet each requirement with this check.

Pin the golangci-lint version with Dagger

The module pins golangci-lint 2.11.4 by digest by default (golangci-lint module reference). The version setting selects the release for every project. To record your choice in dagger.toml, set the release without the v prefix:

dagger module settings golangci-lint version 2.11.4

List the settings to confirm the value:

dagger module settings golangci-lint
MODULE         KEY                VALUE   DESCRIPTION
golangci-lint  version            2.11.4  golangci-lint release, without the v…
golangci-lint  goVersion                  Go version for every module, overrid…
golangci-lint  base                       Base image for Go containers. Must i…
golangci-lint  includeExtraFiles
golangci-lint  lint                       Project roots to lint, e.g. ["**", "…

Dagger stores settings in dagger.toml, so everyone who runs the check uses the same release, on a laptop and in Cloud Checks:

[modules.golangci-lint.settings]
version = "2.11.4"

The version lives in one file. dagger.lock also pins the Git ref of the module and the images that the check uses.

Commit a v2 configuration file with Dagger

The module lints only the directories that hold a golangci-lint configuration file, and it uses the file that the repository has. If a repository has no configuration file, the module has nothing to lint. The smallest valid .golangci.yml has one line:

version: "2"

The module uses golangci-lint v2. If a v1 file has no version key, golangci-lint stops and the check fails with this error:

Error: can't load config: unsupported version of the configuration: "" See https://golangci-lint.run/docs/product/migration-guide for migration instructions

The golangci-lint migrate command converts a v1 file to v2, as the golangci-lint migration guide describes. Until you migrate a project, the lint setting can skip it. The setting selects lint projects by their path from the repository root. Its default, ["**"], selects all of them. A ! pattern excludes a project and all projects below it, and exclusions always win. Set it in dagger.toml:

[modules.golangci-lint.settings]
lint = ["**", "!legacy"]

An excluded project does not show in dagger check -l --all, and golangci-lint does not run for it. In this guide’s run, the legacy project had a v1 configuration file that made the check fail. After the exclusion and fixes for the other problems, the check passed:

== CHECKS ==  ✔ 1 passed
✔ dag://?check=golangci-lint/projects/lint 1.2s OK

Lint every Go module with Dagger

The module runs golangci-lint run one time for each Go module in a lint project. A project covers the Go modules below it, except where a nested configuration file starts a different project. In this repository, the root project also covers tools/migrate. You write no loop and no matrix. Add --all to list one row for each project:

dagger check golangci-lint -l --all
MODULE         GOLANGCI-LINT-PROJECT  CHECK  DESCRIPTION
golangci-lint  .                      lint   Run golangci-lint in this project.
golangci-lint  services/api           lint   Run golangci-lint in this project.

Lint every project, or lint one project:

dagger check --golangci-lint
dagger check --golangci-lint-project=services/api

Diagnostics show the path from the repository root and the linter name. The second command reports the errcheck problem in services/api that the root run by hand did not report:

== CHECKS ==  ✘ 1 failed
✘ dag://?check=lint&golangci-lint-project=services/api 0.6s ERROR
  ✘ GomodGoMod.withPackages(
    ┆ ctr: withWorkdir services/api
    ┆ command: ["golangci-lint", "run", "--path-prefix", "services/api/", "--output.tab.path=stderr", "--output.tab.print-linter-name=true", "--output.tab.colors=false", "--show-stats=false", "--max-issues-per-linter=0", "--max-same-issues=0"]
    ┆ under: "services/api"
    ┆ skip: []
    ): Container! 0.1s ERROR
  ┃ services/api/api.go:11:9  errcheck  Error return value of `f.Close` is not checked
  ! exit code: 1

== RUN LOCALLY ==
dagger check "dag://?check=lint&golangci-lint-project=services/api"

The command under RUN LOCALLY runs the same check again for the same project. The linters, rules, and exclusions stay in .golangci.yml. The module uses them as golangci-lint finds them.

Give the linter code that compiles with Dagger

The module lints each Go module with the Go toolchain that its go.mod asks for. It adds Go and C/C++ sources, embedded assets, and local replace dependencies inside the repository to the lint container. Most projects need nothing more. The settings below cover the other cases.

One Go version for every module. To use one Go version for all Go modules, set goVersion:

dagger module settings golangci-lint goVersion 1.26

This setting is separate from the version setting of the dagger.io/go module, which applies to tests and generators.

Files the linter reads. If a lint run fails because a file is not there, add a path pattern from the repository root to includeExtraFiles:

[modules.golangci-lint.settings]
includeExtraFiles = ["shared/config/**"]

C dependencies for cgo. golangci-lint must load each package before its linters run. A cgo package needs its C headers and tools in the lint container for that step. In this run, a cgo file needs pkg-config and zlib, and the default container does not have them:

  ┃ main.go:1  typecheck  : exec: "pkg-config": executable file not found in $PATH

The base setting takes a container that has a Go toolchain and the C dependencies. The module installs golangci-lint into that container. The container supplies its own Go toolchain, so the module ignores goVersion when base is set.

Build a lint container for cgo in Dang

Build the base container in a small module of your own. Write it in Dang, Dagger’s native language for modules. Install the Dang SDK, then create the project’s dev module:

dagger module install dagger.io/sdk/dang
dagger module init dang -y

dagger module init creates the module at .dagger/modules/<project>-dev and records it in dagger.toml. Replace the starter code in .dagger/modules/<project>-dev/main.dang with the function below. ProjectDev stands in for the type name that dagger module init generated. Keep the generated name:

type ProjectDev {
  """
  The container golangci-lint runs in: Go, a C compiler, and zlib for cgo.
  """
  lintRuntime: Container! {
    container
      .from("golang:1.26-bookworm")
      .withExec(["apt-get", "update"])
      .withExec(["apt-get", "install", "-y", "--no-install-recommends", "pkg-config", "zlib1g-dev"])
  }
}

Connect the function to the base setting. This is module wiring: a setting refers to a function on a different module, and Dagger gives the function’s result to the setting. dagger module init made the dev module the workspace main module, so the function name is sufficient:

dagger module settings golangci-lint base lint-runtime

Dagger stores the full address in dagger.toml:

[modules.golangci-lint.settings]
base = "dag://<project>-dev/lint-runtime"

Run dagger check again. The Dang SDK adds its own stale check for the dev module:

== CHECKS ==  ✔ 2 passed
✔ dag://?check=dang-sdk/generate/stale 1.1s OK
✔ dag://?check=golangci-lint/projects/lint 2.0s OK

The C libraries now come from one function, on each laptop and in Cloud Checks.

Keep the cache between runs with Dagger

The module keeps the Go module, build, and golangci-lint caches between runs (golangci-lint 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 cache lives in the local engine. In Cloud Checks, it lives 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 the lint check locally and with Cloud Checks

dagger check runs the same lint check on any machine that has the Dagger CLI. Commit the configuration files and the lock file:

git add dagger.toml dagger.lock .golangci.yml
git commit -m "Lint with golangci-lint through Dagger"

If you created a dev module, add .dagger to the commit too. Dagger writes dagger.lock when it runs. The file pins the Git ref of each module and the images that the workspace uses, so every run uses the same versions.

To run the check in CI, use Cloud Checks. Cloud Checks run your checks on Dagger’s Cloud Engines for every push and pull request to a GitHub repository. You do not need a CI workflow file. The Git origin remote of the project must point to the GitHub repository. Enable Cloud Checks from the project root:

dagger cloud checks on

In an interactive terminal, the command offers to complete the steps that are not done, such as a Dagger Cloud sign-in and the GitHub connection. dagger workspace activity shows the recent runs. If the lint check fails in Cloud Checks, run dagger check locally to get the same diagnostics.

Bottom line: To run golangci-lint the same way locally and in CI, pin its version and commit a v2 configuration file. Lint every Go module with code that compiles, and keep the cache between runs. The dagger.io/go/golangci-lint module meets these requirements in one check, and dagger check runs it on a laptop and in Cloud Checks.