This Go module requires Dagger v1.0.0-beta.22 or later for selected collection arguments on checks. Each test receives the filtered package collection and passes it directly to the shared module runner. No self API call or Dang import aliases are needed.
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 or by their workspace path.
| Function | Description |
|---|---|
modules |
Filtered Go module collection; use list to read its items. |
module |
The module containing a workspace path. |
On a package: tests, binary, platforms and generate.
On a module: packages, 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 or workspace path. |
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.
A workspace made from a directory or git ref with asWorkspace has no name, so
it is mounted at /src.
modules is a collection keyed by module root (go-module). Each module's
packages collection 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. Use --go-module=sdk/go to
select a module explicitly. modules retains the include/exclude and skip
options. Read the selected modules as an array with modules(...).list.
Pass findUp: false to module when the path is already a module root.
An explicit module's packages lookup is independent of the workspace cwd.
Packages are accessed through their module, for example:
go.modules(ws).get(key: "sdk/go").packages(ws).
There is no root go.packages API. To operate across modules, select each
module's packages explicitly.
Each selected test has its own check. All checks in a Go module call the same
helper, which runs one otelgotest -json command and returns a shared report.
The engine deduplicates identical container operations. Separate modules use
separate commands and their own toolchains.
The shared run streams its JSON report through an indexer once. Each check reads
one bounded result file, so total report size is independent of File.contents
limits. Failure messages retain the last 32 KiB of each diagnostic stream and
mark truncated output; the full report remains in the shared execution container.
The helper reads the filtered package and test collections. A partial selection
uses explicit package arguments and an anchored -run pattern. Packages without
a selected test are omitted. With no keys removed, the command runs ./...
without -run. A test name in two packages selects both; --go-package narrows
that selection.
A failed test fails its own check. A package that fails to build fails its selected
tests. Failed TestMain setup or teardown and failures from undiscovered tests
also fail that package's selected checks. Failures in packages without selected
test checks fail all checks sharing that module's command. Test failure messages
include subtest output. Otherwise, passing tests in other packages still pass.
Logs and test spans belong to the check that starts the shared command; other
checks share its result.
GoTests.test remains a manual package helper, without a check marker. It does
not replace individual checks. There is no .batch namespace.
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 tests: only the shared source
scan and, with a custom base, one go env in it. A file counts when Go's
build rules accept it for the engine's platform with the build tags of
goflags (and -race's), the Go version and no cgo, the settings the default
containers run tests with. With a custom base, they are the toolchain, cgo
and GOFLAGS its own go env reports, read once; a base that lets Go switch
to a newer toolchain for a module can still run tests constrained to that newer
release unlisted. An example counts only with an output comment, as go test
runs no other.
TestMain, testdata, vendored packages and nested modules are left out. A
selected test that go test does not run after all fails, saying so, rather
than passing. 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.binary helper compiles the selected
main packages in that module, skips the rest, and returns one directory of
binaries. Combine module outputs explicitly when building across modules.
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 binary helper 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 with the Scoping rules.
A pattern matches a package's directory within its module, in every module, or
its workspace path: ["cmd/**", "!cmd/internal"] keeps cmd and its
subdirectories except cmd/internal in each module, and ["!vendor"] drops
every main package in the modules under vendor.
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.binary helper 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 helper 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 generate runs against the committed source. Collection-level
generate is now an ordinary helper; it does not replace item generators.
Calling that helper explicitly runs its selected packages in lexical order,
each seeing the preceding output. Modules run independently. Use an explicit
coordinating call when generators require an 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 manual generate helper 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. Projects the lint
setting skips are left out of projects unless includeSkipLint (for
staticcheck, includeSkipped) is true; project still reaches one, and its
lint does nothing. For both linters, artifact discovery runs each project's
lint check. projects(...).lint(ws) is an ordinary helper for explicitly
linting the selected projects; it does not add another artifact check.
For compatibility with older collection engines, call each selected item with
projects(...).get(key: path).lint(ws) and evaluate the returned check. Older
engines expose the explicit collection helper as .batch.lint(ws) instead of
.lint(ws); that helper now returns Void, so it must not be wrapped as a
Check. Both linter development suites use the per-project API, shared by both engine
versions.
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.15.0 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, or in the module's own when its Go is newer, 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 an older 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. Staticcheck type-checks the standard library of the toolchain it runs under, and cannot read one newer than the Go it was built with, so a module on a Go newer than the pinned one gets Staticcheck built with its own.
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 whose
imports will not parse records the reason against itself and the pass carries
on. A syntax error after a file's imports is the compiler's to report, as with
go build: the scan parses no further than the imports. Anything that needs
that module's sources raises the reason, which names the file; everything else
is unaffected. Test checks report their own failures. The manual generate
helper can ask each selected module whether it holds generators and receive 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 module test helper would find it.
An explicit collection lint call over three failing projects reports one failure,
not three. These helpers 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 reads the shared JSON report separately for each test check, so each failed
test has its own result.
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.
The Go changes can be checked with:
dagger check -m .dagger/modules/go-devThe Go selection checks require the PR 14802 collection API. The linter suites use per-project calls supported by both collection APIs.
The 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.
The selected-* checks in go-dev cover multiple modules and packages, excluded
tests, isolated test failures, teardown failures, undiscovered test failures,
and build failures in packages with and without test checks. They run as part
of the normal Go development suite:
dagger check -m .dagger/modules/go-dev 'selected-*'Run the suite with Dagger v1.0.0-beta.22 or later. Each check creates its fixture with Dagger directories and invokes the engine-provided, version-matched CLI in an isolated session. This exercises the engine's active selection; direct Dang collection calls do not apply that view. No host Python or Bash harness is needed.