Root-cause briefing for golang:test-all on dagger/dagger#13608
This briefing answers two related questions:
- Why is the check failing?
- Why did it start failing now?
The short answer is:
The generic Go test tool creates a deliberately incomplete
.git/HEADas a workspace-boundary marker for nested Dagger clients. The new Go client e2e harness later feeds that projected workspace back into Dagger's Go module builder. Go mistakes the marker for a real Git repository, tries to collect VCS metadata, and fails because the repository is only a marker.
This is not fundamentally a notify problem, a test assertion failure, or a
new Go-toolchain regression. It is a composition bug: two individually
reasonable behaviors meet for the first time in this PR and disagree about what
.git means.
Four systems participate:
- The generic Go toolchain module discovers Go modules and creates a small,
filtered
/wstree in which to run each module's tests. - Dagger workspace detection walks upward until it finds
.git; that entry defines the workspace boundary. - The new
sdk/go/e2eharness obtainsCurrentWorkspace()and dynamically servestoolchains/engine-devfrom it. - The Go command automatically stamps VCS metadata into main binaries when
-buildvcs=auto, the default.
The problem lives at the boundary between these systems, not inside any one of them.
gh pr checks identified one failure:
golang:test-all fail
Errored in 58.9s.
Run `dagger trace 81ee336159c92c177f1e0a593958480b`
The trace showed two failed tests:
github.com/dagger/dagger/sdk/go/e2e › TestAgainstEngines › dev
github.com/dagger/dagger/sdk/go/e2e › TestBootstrap
Both failed during the same setup operation:
serve engine-dev module:
failed to load dependencies as modules:
failed to call module "notify" to get functions:
call constructor: exit code: 1
At first glance, this looks like a notify module failure.
That is the first misleading clue.
Both tests call the shared newHarness.
Before either test builds a CLI, starts an engine, or runs an assertion, the
harness does this:
dag.CurrentWorkspace().
ModuleSource("/toolchains/engine-dev").
AsModule().
Serve(ctx)Serving engine-dev loads its dependency graph. That graph includes
notify,
alpine, wolfi, go, codegen, and dagger-cli.
The detailed trace showed both the notify and alpine Go runtime builds
failing. Dependency loading is concurrent; notify happened to be the error
reported at the top. Nothing in its Slack or Discord logic ran.
Zooming into the shared failed span revealed the useful error:
error obtaining VCS status: exit status 128
Use -buildvcs=false to disable VCS stamping.
The failing command was Dagger's standard Go module-runtime build:
go build -ldflags "-s -w" -o /runtime .
That command comes from core/sdk/go_sdk.go.
We have now moved one layer down:
not "notify constructor is broken"
but "Go cannot inspect the repository while building a module runtime"
The trace shows how the generic Go test tool constructs the test container:
-
Select only the files required by the Go module and its
//go:test:includedirectives. -
Mount that projection at
/ws. -
Create
/ws/.git/HEADwith:ref: refs/heads/main -
Change the working directory to
/ws/sdk/go/e2e. -
Run
otelgotest.
The relevant source is in the pinned
github.com/dagger/go toolchain:
# Nested Dagger clients find the workspace boundary by walking up to .git.
.withNewFile(".git/HEAD", "ref: refs/heads/main\n")
The comment is important. The tool is not trying to construct a Git repository. It is constructing a Dagger workspace-boundary marker.
Dagger only needs the marker to exist. Its workspace detector explicitly walks
upward looking for .git; it does not validate the repository contents at that
stage. See core/workspace/detect.go.
So, for Dagger's purpose, this is sufficient:
/ws/.git/HEAD
For Git's purpose, it is not.
A real repository also needs coherent metadata such as configuration, refs,
objects, and an index or valid empty-repository structure. .git/HEAD alone is
like putting a sign saying “library” on an empty room: it is enough to find the
room, but not enough to check out a book.
If the projected workspace were used only to run ordinary tests, the story would stop there. The new harness does something unusual and important:
- The Go test starts inside the projected
/ws. - It connects to Dagger.
- It asks for
CurrentWorkspace(). - It loads
engine-devfrom that workspace. - Dagger's Go SDK builds Go runtime binaries for
engine-devand its dependencies from the same projected tree.
The boundary marker has now crossed an abstraction boundary. It began life as “a hint for Dagger workspace discovery,” but the nested Go build sees it as ordinary source-tree content.
flowchart TD
A["golang:test-all selects sdk/go/e2e"] --> B["Generic Go tool projects selected files into /ws"]
B --> C["Tool adds only /ws/.git/HEAD<br/>as a Dagger workspace marker"]
C --> D["otelgotest runs the new e2e harness"]
D --> E["newHarness asks CurrentWorkspace()<br/>to serve engine-dev"]
E --> F["Dagger Go SDK builds engine-dev dependencies"]
F --> G["go build uses -buildvcs=auto"]
G --> H["Go sees /ws/.git and assumes a Git repository"]
H --> I["Go runs git status --porcelain"]
I --> J["Git exits 128:<br/>the marker is not a repository"]
Go's default -buildvcs=auto behavior stamps repository information into a
main binary when the package, module, and working directory appear to belong to
the same repository.
The implementation:
- detects
.git; - selects the Git VCS driver;
- runs
git status --porcelain; - reads the current revision and commit time; and
- adds fields such as
vcs.revisionandvcs.modifiedto the binary's build information.
See the Go 1.26.3 sources for
setBuildInfo
and
gitStatus.
auto does not mean “ignore every Git error.” Once Go finds what looks like a
repository and the git executable is available, failure to obtain its status
is fatal.
The minimal reproduction is tiny:
example/
├── go.mod
├── main.go
└── .git/
└── HEAD # contains: ref: refs/heads/main
Results:
$ go build .
error obtaining VCS status: exit status 128
Use -buildvcs=false to disable VCS stamping.
$ git status --porcelain
fatal: not a git repository (or any of the parent directories): .git
$ go build -buildvcs=false .
# succeedsThis exactly reproduces the CI error without Dagger, engine-dev, or
notify. That isolates the failing invariant:
A directory containing only
.git/HEADis simultaneously valid as Dagger's current workspace marker and invalid as Go's perceived Git repository.
The marker itself is not new.
The previous github.com/dagger/go pin already contained the same
withNewFile(".git/HEAD", ...).
The new pin still does. Both use Go 1.26 by default. Therefore neither the
toolchain pin update nor a new Go VCS-stamping policy explains the timing.
What changed is the route taken by the tests.
| Before this PR | With this PR |
|---|---|
golang:test-all selected only e2e/**. |
It selects e2e/**,sdk/go/**, so it discovers and runs sdk/go/e2e. |
Go SDK tests used the dedicated go-sdk-dev.test path. |
Go client tests become standard modules under the generic Go tool. |
The outer Dagger graph installed the development client/engine environment before running go test. |
The test itself connects back to Dagger and dynamically serves engine-dev from CurrentWorkspace(). |
The synthetic .git/HEAD marker was not reused as source for another Go module build. |
The projected workspace, marker included, becomes input to nested Go runtime builds. |
The selection change is visible in
dagger.json.
The new dynamic module load is in
sdk/go/e2e/harness_test.go.
So the precise answer to “why now?” is:
This PR is the first time a test running inside the generic Go tool's marker-only workspace turns around and asks Dagger to compile Go modules from that same workspace.
No single component suddenly regressed. A previously latent incompatibility became reachable.
The recent history cleanup is also unrelated: it changed commit structure and DCO trailers, while preserving the source tree byte-for-byte. It merely caused CI to run again.
There is a useful control case. The Helm e2e tests also perform nested Dagger module work under the generic Go tool, but they explicitly include the real repository metadata:
//go:test:include ../../.gitFor that module, the synthetic HEAD is laid over a complete .git directory,
so git status has the data it needs. That explains why the generic mechanism
is not failing everywhere.
It also gives us a likely tactical fix, but not necessarily the best architectural fix.
Add the same //go:test:include ../../../.git pattern used by Helm.
- Benefit: Small PR-local change with an existing repository precedent.
- Cost: Copies repository metadata into the test input, increases coupling to checkout shape, and may behave differently for worktrees or synthetic workspaces.
- Interpretation: Turn the marker into the real thing.
Once CurrentWorkspace() has already been found, the marker has completed its
job. The harness could derive a directory without .git, then call
AsModuleSource with an explicit toolchains/engine-dev source root.
- Benefit: Keeps module-runtime builds hermetic and removes the false VCS signal at the boundary where it becomes harmful.
- Cost: Slightly more explicit module-source plumbing in the test harness.
- Interpretation: Do not let an internal workspace marker escape into a consumer that assigns it different semantics.
Dagger could change the global runtime build in core/sdk/go_sdk.go.
- Benefit: Module runtime builds become independent of checkout metadata.
- Cost: Global behavior change; maintainers should first decide whether VCS build information in module runtime binaries is intentional or useful.
- Interpretation: Make hermeticity a property of the Go SDK runtime builder.
The generic Go tool could eventually pass an explicit Dagger workspace boundary
instead of manufacturing .git/HEAD.
- Benefit: Removes the semantic collision at its origin.
- Cost: Requires a broader workspace-selection mechanism for nested clients, not just a PR-local adjustment.
- Interpretation: A sentinel should not masquerade as a real object owned by another tool.
For this PR, options 1 or 2 are narrow. Option 2 better preserves the conceptual
boundary: .git/HEAD is a Dagger discovery implementation detail, not an input
to module compilation. Options 3 and 4 are broader follow-up design questions.
Immediate failure
go build runs git status --porcelain, which exits 128 because .git
contains only HEAD.
Trigger
The new sdk/go/e2e harness dynamically serves engine-dev from the generic
Go test container's projected CurrentWorkspace.
Underlying design problem
One layer creates a partial representation of a Git repository as a private
sentinel, then exposes it as ordinary filesystem state to another layer that
correctly interprets .git according to Git's public contract.
Filtered workspaces are not passive bags of files. They are executable inputs to downstream tools, and those tools infer behavior from special paths.
Whenever we synthesize a well-known path such as .git, go.mod,
package.json, or .env, we inherit the semantic contract of that path for
every later consumer—not only the consumer for which we created it.
Here, the system worked until the workspace became recursive:
Dagger builds a workspace
→ code inside that workspace asks Dagger to build from the workspace
→ the implementation detail returns as user-visible source
That recursion is why the bug was latent, why the error message named the wrong module, and why it appeared now.