Dagger modules for Go tooling, written in the .dang
module language.
Go has one toolchain from one supplier, so go is one module and it lives at
the root of this repository, where it always has. The linters do not come from
that supplier and do not share its release dates, so they are their own modules
— over one shared library, in this repository.
github.com/dagger/go
├── go.dang the main module, at the root: test, build, generate
├── gomod/ the shared library. No checks.
├── golangci-lint/ lint
├── staticcheck/ lint
└── .dagger/ development checks for the root module
go is the root module, so its address is the repository:
dagger install github.com/dagger/go # test, build, generate
dagger install github.com/dagger/go/golangci-lint
dagger install github.com/dagger/go/staticcheckgomod is a library, not a tool. You depend on it when you write a module; you
do not install it to get checks.
go discovers every Go module in the workspace, so a repository with test
fixtures or a vendored copy of some source will have those checked too. The
linters discover every project — every directory holding their configuration
file — which a fixture can have too. Say which roots you mean in dagger.toml:
[modules.golangci-lint.settings]
lint = ["**", "!docs"]A bare pattern selects, a "!"-prefixed pattern excludes, and an exclude wins
whatever the order. docs means docs and every module below it; ** and *
mean all of them; ["!**"] means none. Other glob shapes are not interpreted.
go spells its two as test and generate rather than lint. Its build
setting uses the same rules, but selects main packages by their directory
within each module rather than module roots.
| Function | Description |
|---|---|
packages |
Packages discovered from the workspace, as a collection. |
modules |
Modules discovered from the workspace, as a list. |
module |
The module containing a workspace path. |
On a package: tests, binary, platforms and generate.
On a module: test, generate, skip-test, skip-generate,
has-generate-directives, generate-directories, base, include,
include-base, include-discovered, source, test-data.
| Setting | Meaning |
|---|---|
version |
Go version for every module, instead of each one's go.mod. |
base |
Base image for Go containers. |
includeExtraFiles |
Extra workspace files to mount, as include patterns. |
test |
Module roots to test (see Scoping). |
generate |
Directories to run go generate in (see below). |
build |
Main packages to build, by module-relative directory. |
buildFlags |
Extra flags for go build only. |
goflags |
GOFLAGS in every Go container. |
mountPath |
Where the workspace is mounted in Go containers. |
base must carry a Go toolchain and any C/C++ dependencies the modules need.
otelgotest is installed into it unless it already has one. It supplies its own
toolchain, so version is ignored alongside it and recorded in warnings.
goflags is passed as-is, e.g. -tags=extended,withdeploy. Flags that take a
value must use the -flag=value form, as Go requires inside GOFLAGS.
mountPath defaults to /src/<workspace name>, where the name is the last
segment of the workspace address — usually the repository name — so tests that
expect it in their working directory keep passing. It must be absolute.
packages is the collection to select on. It is keyed by package directory
relative to the workspace root, e.g. sdk/go/cmd/dagger, which adds a
go-package dimension. A directory is a package key when it has something to
do: tests to run, a main package to build, or go:generate commands to run.
Each package's tests are a collection too, keyed by function name, which adds
go-test.
$ dagger list go-packages -a
$ dagger check --go --test --go-package=sdk/go
$ dagger check --go --test --go-test=TestConnect
$ dagger check --go --test --go-package=sdk/go --go-test=TestConnect
$ dagger generate --go --go-package=sdk/go/internal/gen
$ dagger check -l --all --go -f=cli # one line per test, as flags to reuse--test alone also selects every other module's check named test; --go
narrows it to this one. The linters key their collections by project, as
--golangci-lint-project and --staticcheck-project. dagger check --help
lists the flags in effect.
Packages are found in the Go modules at or below the workspace cwd, plus the
enclosing module when the cwd is inside one, so dagger -W ./sdk/go check
scopes every tool to that module without a flag. Modules are not a dimension:
they decide how a package is mounted and which Go toolchain it gets, and
modules and module remain for inspecting that. include/exclude filter
the module roots modules finds; pass findUp: false to module when the
path is already a module root.
Tests run once per selected package, through the GoTests batch test. With
every test in the package selected it runs go test ./pkg; with some filtered
out it runs go test -run over the selected names. A test name in two packages
is two keys, so --go-test alone selects both and --go-package picks one.
Collections never batch across parent items, so packages run as separate
execs, not one go test ./... per module.
Test names come from searching the package's _test.go files for
func TestXxx(t *testing.T), func FuzzXxx(f *testing.F) and
func ExampleXxx(), so listing them runs no container. TestMain, testdata
and nested modules are left out. Build constraints are not evaluated: a test
excluded by one is still listed, and selecting it runs nothing. A module
outside the test selection offers no tests.
test on a module is a plain function rather than a check, so that dagger check does not run the same tests twice.
binary on a package returns its binary for the engine's platform. Only a
main package selected by the build setting has one; any other package
fails, naming itself. The GoPackages batch binary compiles the selected
main packages with one go build per module, skips the rest, and returns one
directory of binaries.
A binary is named as go build names it: after the last element of the
package's import path, or the one before when that is a major version such as
v2. A module's root package takes its name from the module path in go.mod.
Names can collide, as cmd/foo and tools/foo both build foo: the batch
binary refuses such a pair and names both packages, while a single package's
binary does not mind.
A directory is a main package when one of its own non-test .go files says
package main, so listing them runs no container. Files with an ignore build
tag, usually go generate scripts, are left out, as are testdata, nested
modules and names starting with . or _. Other build constraints are not
evaluated: a package whose files are all for another platform is listed, and
building it fails.
The build setting selects main packages by module-relative directory with
the Scoping rules, and applies in every module: ["cmd/**", "!cmd/internal"] keeps cmd and its subdirectories except cmd/internal.
buildFlags are passed to go build as separate arguments, after goflags
has applied through GOFLAGS, so a value may contain spaces:
["-trimpath", "-ldflags=-s -w -X main.version=dev"]. Tests and go generate
never see them. Every package in every module gets the same flags.
A package's platforms are keyed by GOOS/GOARCH, and every pair the pinned
Go toolchain supports is a key (gomod's go-platforms). Each platform's
binary is the package's binary cross-compiled for it, in the native
container with GOOS and GOARCH set; nothing is emulated. Windows binaries
end in .exe.
The GoPlatforms batch binary returns one subdirectory per platform, e.g.
linux-amd64/, windows-amd64/:
- With no platform selected, it builds for the engine's platform only, e.g.
linux/arm64. - With platforms selected, it builds for exactly those.
The batch tells the two apart by its delta: an unnarrowed collection means no
platform was asked for. So selecting every platform explicitly looks the same
as selecting none, and builds only the engine's. A single platform's binary
has no such default; it builds for its own key.
binary returns artifacts and is not a check. A main package that stops compiling is
caught by golangci-lint and staticcheck, which type-check every package.
Tests and go generate always run natively. cgo is off when cross-compiling
unless base brings a C cross-toolchain; some ports, such as ios/*, need
one. A module whose toolchain predates a port fails with Go's own error.
generate patterns are relative to the workspace root, whatever the cwd, and
use Dagger glob syntax: *, ? and character classes match within a path
segment, and ** matches zero or more segments.
- A literal path selects only that directory, even if it is a module root.
.selects the workspace root only. internal/**selectsinternaland everything below it.- A
!prefix excludes, and exclusions always win. - An empty list, or one with only exclusions, starts from every directory.
- The default
["**"]reaches directories in nested modules.
For example: ["sdk/go/engineconn", "internal/**", "!internal/fixtures/**"].
go generate . runs in each selected directory that has a generator command;
go:generate:include alone is not one. Each such directory is a package with a
generate generator and its stale check.
A package's own generate runs alone, against the committed source. The
GoPackages batch generate replaces it and runs the selected packages
together, per module, in lexical path order, each seeing the output of the ones
before it: with every package selected, that is go generate ./... for each
module. So a generator that reads another package's generated output sees it
when the two are generated together, and sees the committed version when its
package is generated alone. Modules run independently and cross-module
dependencies are not inferred; use an explicit coordinating command when
generators need a different order.
A directory can pick the container its generators run in with
//go:generate:container, resolved with the caller's Workspace.resolve — a
workspace container by name, such as generate-env, or an image reference such
as docker.io/library/golang:1.26.1-alpine. Conflicting values in one
directory are errors. Consecutive directories naming the same one share a
container; when it changes, workspace files carry over so later commands still
see earlier output. Without the directive, the directory uses the module's own
base container.
A package's own generate fails when the scan could not read its module,
naming the file, while the batch generate skips that module (see below).
Tests run with a nested Dagger engine, so a suite that drives Dagger works.
| Function | Description |
|---|---|
projects |
Projects discovered from the workspace, as a collection. |
project |
The project containing a workspace path. |
module |
The Go module containing a workspace path. |
version |
The bundled golangci-lint version. |
A project is a directory holding .golangci.yml, .yaml, .toml or .json,
and it lints everything below that no nested configuration claims: each Go
module below it, and the part below it of a module that encloses it. A
directory with no configuration above it is not linted, and a configuration
with no Go code around it is not a project.
On a project: modules and lint (a @check). A project runs golangci-lint
once per Go module it covers, with that module's toolchain, and golangci-lint
finds the project's configuration by its own lookup. The collection's batch
lint runs once over the selected projects in place of each project's own, as
for go. Both linters name their check lint, so dagger check --lint runs
them together and --golangci-lint --lint runs this one; select projects with
--golangci-lint-project=PATH.
version is the linter release, pinned to 2.11.4 by digest, and goVersion is
the Go toolchain — chosen independently, because the binary is copied out of
its image onto that toolchain. A C/C++ toolchain is present, since cgo
dependencies need one during typecheck.
Both linters take base, goVersion, includeExtraFiles and lint settings
that work as go's do, except that lint selects project roots. A base must
carry a Go toolchain and any C/C++ dependencies; the linter is installed into
it unless it already has one, and goVersion is ignored alongside it and
recorded in warnings. Each linter's
configuration files are mounted with their directory structure, so its normal
config lookup applies. Use includeExtraFiles for files the linter reads that
the Go patterns miss, such as generated inputs or non-Go embedded assets.
| Function | Description |
|---|---|
projects |
Projects discovered from the workspace, as a collection. |
project |
The project containing a workspace path. |
module |
The Go module containing a workspace path. |
Projects work as golangci-lint's do, rooted at each staticcheck.conf.
Staticcheck merges a package's staticcheck.conf files itself, so a nested
project still inherits from its parent's. Select it with
--staticcheck --lint, and projects with --staticcheck-project=PATH.
version is the Staticcheck release, built once with go install in a pinned
Go container, and goVersion is the Go toolchain. Test files are analyzed;
tests are not executed.
The shared library. It has no checks of its own and never will — two modules with a check for the same tool would run that tool twice.
| Function | Description |
|---|---|
modules |
Modules discovered from the workspace's go.mod files. |
module |
The module containing a workspace path. |
at |
A module root taken as given, with no workspace consulted. |
setting-patterns |
Selection patterns, repaired for older beta engines. |
go-container |
A Go toolchain container at a version, with the shared caches. |
tool-builder |
The pinned container tool binaries are built in. |
go-platforms |
Every GOOS/GOARCH the pinned toolchain can target. |
host-platform |
The engine's platform as GOOS/GOARCH. |
with-tool |
Install a tool binary, unless the container has one. |
with-warnings |
Announce ignored settings as container steps. |
default-go-version |
The version used for a module that declares no go directive. |
scanned-module-roots |
The directories the scan accepts as Go modules. |
scan |
The raw workspace scan, as a directory. |
with-go-caches |
A container with the shared Go download and build caches. |
On a module:
| Function | Description |
|---|---|
selected |
Whether selection patterns choose this module root. |
include-discovered |
Patterns found by scanning directives and local replaces. |
include / source |
What this module mounts, as patterns and as a directory. |
test-directories |
Directories holding Go test files. |
generate-directories |
Directories holding a go:generate command. |
scan-error |
Why this module could not be scanned, or null. |
include-base |
The built-in Go source patterns, module-scoped. |
go-version |
The Go version this module's go directive asks for. |
subpath / test-data |
Path and testdata mechanics. |
The tool types stay duplicated; the mechanics do not. Each module returns
its own GoModule, because that type is what its callers see and each tool has
its own fields and verbs to hang on it. Dang also cannot yet name a
dependency's non-root type. So each GoModule holds a path and forwards
discovery, selection and source policy to gomod.
One source scan for the whole workspace. gomod builds the
helpers/go-includes scanner in a pinned Go image that depends on nothing
about the caller or the module root, so three tools with three different base
images still produce one identical container. The engine builds it once, runs
it once, and each module reads its own slice of the output. Before, each of the
three modules carried its own copy of that program and ran its own scan.
The Go toolchain comes from the module, not from the tool. With nothing
set, each module is checked with the toolchain its own go.mod asks for. Each
module runs in its own container anyway, so there is no need to find one
version that suits the whole workspace.
go 1.26.1 in a module |
golang:1.26-alpine — the minor series, always its newest patch |
goVersion: "1.26.1" |
golang:1.26.1-alpine — set explicitly, used as written |
no go directive |
gomod's default-go-version |
The series rather than the exact patch, because a go directive is a minimum
and a go.mod bumped to a patch with no image yet would otherwise stop the
module being checked at all. The root reports null for version/goVersion,
since the answer varies per module; ask a module what it resolved to.
A tool is never built in the module's container. golangci-lint's binary is copied out of its pinned image; Staticcheck and otelgotest are built once in a pinned Go container. A module on an older Go would otherwise fail to build the tool, and the error would be about the tool rather than the module.
A tool is installed only when the container does not already have it, so a
base carrying its own build — an instrumented one, say — keeps it. version
therefore says what to install if needed and composes with base.
base brings its own toolchain, so version/goVersion is ignored alongside
it rather than refused — no setting needs editing to say what the base already
says. Each ignored setting is listed in warnings and announced as a step in
the container, since Dang has no diagnostics channel of its own.
A directory with a go.mod is not automatically a module. Discovery
reports a module root only when its go.mod carries a module line,
and when the module holds Go files that Go itself would look at — not only ones
under testdata/ or a ./_ directory. Everything else is left out, because
handing it to a tool yields a complaint about the tool rather than about the
directory: golangci-lint errors on a module with no packages, and no Go command
accepts a go.mod with no module line. module and at still reach one, so a
caller that spells out a path gets that path, and an unparseable go.mod still
says what is wrong with it. gomod's scanned-module-roots is the scanner's
view of the same list, and its suite asserts the two agree.
Listing never runs the scanner. Which roots are modules, and which tests a
module has, are answered from the workspace directly: findRoots, a read of
each go.mod, globs and Workspace.search. The scanner builds and runs only
when something needs a module's sources, so dagger check -l and
dagger list pay for neither.
The line is "no Go files", not "no packages after build constraints". A module whose files are all constrained out is still a module, and the toolchain's message about it names the module already.
A module the scan cannot read fails alone. One scan serves every tool in
the workspace, so a module with an unreadable go.mod or a Go file that will
not parse records the reason against itself and the pass carries on. Anything
that needs that module's sources raises the reason, which names the file;
everything else is unaffected. The batch test reports it beside the modules
that passed, and the batch generate — which has to ask every selected module
whether it holds generators — gets an answer rather than an error. Failing the
whole scan instead would let one bad file stop every check in the repository,
including for modules you had excluded.
Discovery errors name the file. go: error reading go.mod: missing module declaration, from whichever container happened to run first, is not something
you can act on. The scan catches the same fault and says
vendored/go.mod: missing module declaration, and an unresolvable local
replace points at the line that declares it.
Scan flags mean what they say. A bare scan follows go:embed and local
replace directives. --test also follows go:test:include, and --generate
also follows go:generate:include and the modules a go:generate go -C
reaches. A lint run asks for neither, because golangci-lint and Staticcheck
type check the code rather than run it.
Selection is one syntax, asserted in one place. gomod's suite owns the
selection matrix; each tool's suite asserts only that its own settings reach
it.
Fixtures are shared where they are the same. testdata/ and fixtures/ at
the root serve every module, with a .golangci.yml in the ones golangci-lint
lints. A tool keeps its own only when the fixture is specific to it —
golangci-lint/testdata/projects fails under any configuration but the one
each part is meant to get, and golangci-lint/testdata/go-module-lint-fail
does not compile, so neither can live where go's batch test would find it.
A batch of three failing projects reports one failure, not three. The lint
modules aggregate results into a directory and sync it, and the first error
stops every later report; you repair one project, run again, and find the next.
go's batch test does not have this shape — it collects each module's failure
and names them all.
The obvious repair for the lint modules — collecting exit codes rather than merging outputs — does report all three, but it loses the parallel run. Dang has no structured concurrency primitive of its own, so this should be repaired once, in Dang, rather than twice here.
One command runs all four suites:
dagger checkThe root dagger.toml installs go's suite as the entrypoint and each other
suite under a <tool>-checks key, which prefixes its checks. Every module needs
v1.0.0-beta.15 or later, the first release with collections and the
Workspace.resolve that //go:generate:container uses.
One suite, or one check, at a time:
dagger check -m .dagger/modules/go-dev # the root go module
dagger check -m gomod/.dagger/modules/e2e # the shared library
dagger check -m golangci-lint/.dagger/modules/e2e
dagger check -m staticcheck/.dagger/modules/e2e
dagger check -m gomod/.dagger/modules/e2e scan-check # or one check by nametestdata/ and fixtures/ hold the modules the suites run against. Several of
them fail on purpose — a failing test, a lint diagnostic, a module with no
packages — and the suites assert those failures, so repairing a fixture would
be a hole in the coverage rather than a repair.
github.com/dagger/golangci-lint and github.com/dagger/staticcheck came
first and still work. The modules here are the same tools rebuilt on the shared
library, so they gain what it gives every module: one notion of a module root,
one selection syntax, one source scan shared across every tool in the
workspace.
Two differences are worth knowing before you switch:
- They lint projects, rooted at each configuration file, rather than Go modules. A repository with no configuration file is not linted at all.
skipLintno longer takes a workspace. It never read one; selection is a question about a path.- A bare scan no longer implies test inputs. The standalone modules were embed-only in practice, so this changes nothing about what they mounted, but the flag now says so.