z5labs
and release pipelines for Go projects. Start at Z5labs.Go for the sourcetree and the standardized checks over it; the GoApp factory still carries
the multi-arch build and the signed publish, until the second half of the
chainable API replaces it. Either way, the terminal Ci runs the pipeline.
The lint stage runs golangci-lint v2. A repository adopting this pipeline
keeps its `.golangci.yml` in the v2 dialect — the file opens with
`version: "2"` — because a v2 binary refuses a v1 file outright rather
than ignoring it. Supplying no file at all takes the bundled policy in
configs/golangci.yml, which is the v2 file to copy from. The release is
pinned once, in the `go` module; lintVersion moves it, either major.
Installation
dagger install github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4afTypes
Z5Labs 🔗
Z5labs is the root module type. Construct the Go language chain via Go, and the release pipeline via GoApp.
go() 🔗
Go returns the Go language chain bound to source: the standardized
checks — gofmt, go vet, golangci-lint and go test -race — over a Go
source tree, configured by the chain’s With* methods and run by its
terminal Ci.
This is what a Go library needs and all it needs, which is why the library archetype it replaces is gone: a library is a source tree you never build an application from. An application starts here too; the build and the publish sit downstream of this chain rather than beside it.
The returned object is GoChain rather than Go because the go module
this one depends on already owns that name — see GoChain’s doc comment.
Return Type
GoChain !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory ! | - | No description provided |
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go --source DIR_PATHfunc (m *MyModule) Example(source *dagger.Directory) *dagger.Z5LabsGoChain {
return dag.
Z5labs().
Go(source)
}@function
def example(source: dagger.Directory) -> dagger.Z5LabsGoChain:
return (
dag.z5labs()
.go(source)
)@func()
example(source: Directory): Z5LabsGoChain {
return dag
.z5labs()
.go(source)
}goApp() 🔗
GoApp wires up an opinionated CI/release pipeline for a package main
Go application. Call Ci to run checks + multi-arch buildpublish, or Builder to produce the same image single-arch locally.
Every binary GoApp builds is stamped at link time with the version and the commit it was built from, so an application can answer “which build am I running” without a second build definition beside this one. Declare these two package-level vars in your main package and they are filled in:
var (
version = "dev"
commit = "none"
)
The names are fixed by the module — main.version and main.commit —
and the values are taken from HEAD, never from a parameter. A tag
pointing at HEAD gives version the stripped tag name; anything else
gives “-”, the same rule the published image
tag follows, so the two agree by construction. commit is the short HEAD
SHA. Because both are functions of the commit alone, two builds of one
commit are byte-identical; there is no caller-supplied value that could
break that. Source without git metadata at HEAD is an error.
publishOn is a regex evaluated against source repo’s HEAD refs (after
normalizing refs/remotes/origin/X → refs/heads/X); matches trigger
publish. When registry is set, auth is required.
platforms defaults to [“linux/amd64”,“linux/arm64”].
registryService, when non-nil, is a Dagger-hosted registry reached over the session network instead of over the public network — used by tests against a local registry service and by callers whose private registry is itself a Dagger service. Its endpoint is assigned by the engine, so it replaces registry as the address published to; registry is still what decides that a publish happens at all.
insecure means plain HTTP and no TLS verification, and it is off unless the caller asks for it. It is deliberately not inferred from registryService being set: that inference made a caller who supplied a service for their own reasons silently publish over an unverified connection. It is spelled insecure rather than tlsVerify because a bool defaulting to true cannot be turned off from the CLI.
Return Type
GoApp !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory ! | - | No description provided |
| pkg | String | "." | No description provided |
| binaryName | String | - | No description provided |
| publishOn | String | "^refs/heads/main$" | No description provided |
| registry | String | - | No description provided |
| authUsername | String | "ci" | No description provided |
| auth | Secret | - | No description provided |
| lintConfig | File | - | A The lint stage runs golangci-lint v2, so this file must be written
in the v2 dialect — it has to open with |
| lintVersion | String | - | The golangci-lint release the lint stage installs, e.g. “v2.12.2”.
Empty takes the version pinned by the |
| platforms | [String ! ] | - | No description provided |
| registryService | Service | - | No description provided |
| insecure | Boolean | - | No description provided |
| idTokenRequestUrl | String | - | The CI provider’s OIDC token request endpoint —
|
| idTokenRequestToken | Secret | - | The bearer token for that endpoint —
|
| idTokenService | Service | - | A Dagger-hosted OIDC token endpoint, reached over the session network instead of the public one. When set, its engine-assigned endpoint replaces the host in idTokenRequestUrl; the path and query stay the caller’s, because those are part of the provider’s protocol. This exists for the same reason registryService does: a service’s address is not known until the engine assigns one, so it cannot be written into a URL ahead of time. It is used by the test suite, which runs a real token endpoint, and by anyone whose issuer is itself a Dagger service. |
| signingKey | Secret | - | A PEM-encoded EC private key to sign the provenance with, instead of an ephemeral key certified by the public sigstore CA. This selects the signing mode and nothing else: the workload identity token is still exchanged, and the predicate still says only what that token’s claims say. Use it for a build that cannot reach a public CA. Leaving it unset is keyless signing and is what a normal CI publish should do. |
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go-app --source DIR_PATHfunc (m *MyModule) Example(source *dagger.Directory) *dagger.Z5LabsGoApp {
return dag.
Z5labs().
Goapp(source)
}@function
def example(source: dagger.Directory) -> dagger.Z5LabsGoApp:
return (
dag.z5labs()
.goapp(source)
)@func()
example(source: Directory): Z5LabsGoApp {
return dag
.z5labs()
.goApp(source)
}GoChain 🔗
GoChain is the Go language chain: a source tree, and the standardized checks over it. Construct via Z5labs.Go and call the terminal Ci.
The type is not named Go, which is what the design called for, because
this module depends on the go module and that module’s root object is
literally Go. A dependency’s objects occupy their bare names in the
dependent module’s type space, so a local object of the same name is
resolved as the dependency’s and the module fails to load:
failed to add object to module "z5labs": failed to validate type def:
object "Z5labs" function "Go" cannot return external type from
dependency module "go"
The method is unaffected — Z5labs.Go is the constructor and the CLI
path is still dagger call go ... — so the collision costs the type’s
spelling and nothing else. See daggerverse/CLAUDE.md.
The stages are not opt-in. Ci always runs fmt, vet, lint and test, which
is what makes “the z5labs pipeline ran” mean the same thing in every
repository that adopts it — the same guarantee the archetype factories
gave before this chain replaced them. The With* methods configure those
stages; none of them switches one on or off. A caller who wants a subset
is describing a different pipeline and should reach for the go module’s
own Ci builder, which is exactly that: stages enabled one by one.
A library is a Go you never build an application from, so there is no separate library archetype.
ci() 🔗
Ci runs the standardized check stages against the source: gofmt, go vet,
golangci-lint against the bundled policy unless WithLint supplied one,
and go test ./... with the race detector unless WithTest turned it off.
The stages run in parallel and their errors are aggregated.
Return Type
Void ! Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go --source DIR_PATH \
cifunc (m *MyModule) Example(ctx context.Context, source *dagger.Directory) {
return dag.
Z5labs().
Go(source).
Ci(ctx)
}@function
async def example(source: dagger.Directory) -> None:
return await (
dag.z5labs()
.go(source)
.ci()
)@func()
async example(source: Directory): Promise<void> {
return dag
.z5labs()
.go(source)
.ci()
}withBuild() 🔗
WithBuild records build tags for the App terminal. Ci does not build, so tags have no effect on it today.
tags are passed to the Go toolchain as -tags a,b,c, selecting which
//go:build-constrained files compile. The build belongs to App — the
terminal that produces container images — which lands with the second
half of this API, and these are the tags it will build with. The method
is here now because the shape of the chain is what is being fixed; until
App exists a caller who sets tags gets no error and no effect, which is
why that is the first thing this comment says.
Return Type
GoChain !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| tags | [String ! ] ! | - | No description provided |
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go --source DIR_PATH \
with-build --tags string1 --tags string2func (m *MyModule) Example(source *dagger.Directory, tags []string) *dagger.Z5LabsGoChain {
return dag.
Z5labs().
Go(source).
Withbuild(tags)
}@function
def example(source: dagger.Directory, tags: List[str]) -> dagger.Z5LabsGoChain:
return (
dag.z5labs()
.go(source)
.withbuild(tags)
)@func()
example(source: Directory, tags: string[]): Z5LabsGoChain {
return dag
.z5labs()
.go(source)
.withBuild(tags)
}withLint() 🔗
WithLint configures the lint stage.
version is the golangci-lint release the stage installs, e.g. “v2.12.2”.
Empty takes the version pinned by the go module, which is a v2 release;
pinning a “v1.x” release rolls the whole stage — config dialect included
— back to v1.
config is a .golangci.yml replacing the bundled policy in
configs/golangci.yml. It has to be written in the dialect the pinned
major speaks: a v2 binary refuses a v1 file outright, before any linter
runs, and v1 refuses a v2 file the same way.
Both arguments are optional, because pinning a version and replacing the
policy are independent decisions and requiring one to state the other
would make every version pin also a policy fork. An argument left out
leaves that setting alone rather than clearing it, so the independence
holds across calls as well as within one: with-lint --config=x
with-lint --version=y keeps both, where an unconditional assignment
would have dropped the config and run the bundled policy the caller
thought they had replaced. Nothing can un-set either one, which is not a
use case — a caller who wants the defaults does not call this.
Return Type
GoChain !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| version | String | - | No description provided |
| config | File | - | No description provided |
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go --source DIR_PATH \
with-lintfunc (m *MyModule) Example(source *dagger.Directory) *dagger.Z5LabsGoChain {
return dag.
Z5labs().
Go(source).
Withlint()
}@function
def example(source: dagger.Directory) -> dagger.Z5LabsGoChain:
return (
dag.z5labs()
.go(source)
.withlint()
)@func()
example(source: Directory): Z5LabsGoChain {
return dag
.z5labs()
.go(source)
.withLint()
}withTest() 🔗
WithTest configures the test stage. race turns the data-race detector on or off; it is on unless this method says otherwise.
race is a required argument rather than an optional one, because an
optional bool takes its zero value when the flag is absent, so a bare
with-test would silently drop the race detector the pipeline otherwise
guarantees. Turning it off is a real weakening of the check, so the
caller states it.
Return Type
GoChain !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| race | Boolean ! | - | No description provided |
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go --source DIR_PATH \
with-test --race booleanfunc (m *MyModule) Example(source *dagger.Directory, race bool) *dagger.Z5LabsGoChain {
return dag.
Z5labs().
Go(source).
Withtest(race)
}@function
def example(source: dagger.Directory, race: bool) -> dagger.Z5LabsGoChain:
return (
dag.z5labs()
.go(source)
.withtest(race)
)@func()
example(source: Directory, race: boolean): Z5LabsGoChain {
return dag
.z5labs()
.go(source)
.withTest(race)
}GoApp 🔗
GoApp is the application archetype. Construct via Z5labs.GoApp.
builder() 🔗
Builder returns the local-dev sibling that produces the same image CI would publish, single-arch (host platform).
Return Type
Builder ! Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go-app --source DIR_PATH \
builderfunc (m *MyModule) Example(source *dagger.Directory) *dagger.Z5LabsBuilder {
return dag.
Z5labs().
Goapp(source).
Builder()
}@function
def example(source: dagger.Directory) -> dagger.Z5LabsBuilder:
return (
dag.z5labs()
.goapp(source)
.builder()
)@func()
example(source: Directory): Z5LabsBuilder {
return dag
.z5labs()
.goApp(source)
.builder()
}ci() 🔗
Ci runs the standardized GoApp pipeline: verify .git exists, run the shared check stages (fmt+vet+lint+test -race) once, build a scratch image per platform, then conditionally publish per the publishOn filter.
It returns the digest of what was published — the manifest list naming every platform variant, or the single image manifest when only one platform was built. Every matching ref publishes the same bytes under its own tag, so one digest describes them all. A run that publishes nothing — no ref matched, or no registry was configured — returns the empty string rather than an error.
Returning the digest rather than only an error is what lets a caller reference what was published: an attestation, a deployment manifest or a release note has to name an immutable artifact, and a tag is not one.
Every published image carries the standard OCI source annotations —
revision, source, created, and version on a tag build — on each
platform variant, and every published digest carries three
attestations: an SPDX and a CycloneDX SBOM per platform, produced by
the go module from the binaries this pipeline compiled, and a signed
SLSA provenance statement whose build identity comes from an exchanged
workload identity token. A publish that cannot produce provenance
fails rather than publishing without it.
Publish is a side-effecting operation against an external registry, so the whole pipeline is uncached — re-runs (e.g. after a retry, or after a new ref appears within the same engine session) must actually push.
Return Type
String ! Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go-app --source DIR_PATH \
cifunc (m *MyModule) Example(ctx context.Context, source *dagger.Directory) string {
return dag.
Z5labs().
Goapp(source).
Ci(ctx)
}@function
async def example(source: dagger.Directory) -> str:
return await (
dag.z5labs()
.goapp(source)
.ci()
)@func()
async example(source: Directory): Promise<string> {
return dag
.z5labs()
.goApp(source)
.ci()
}Builder 🔗
Builder produces the same image GoApp.Ci would publish, single-arch (host platform). Used for local development to verify the artifact before pushing. Both of its functions route through the same per-platform build Ci uses, so the binary carries the same version and commit stamp and a local build is the same artifact.
binary() 🔗
Binary returns the host-platform compiled binary as a *dagger.File.
Return Type
File ! Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go-app --source DIR_PATH \
builder \
binaryfunc (m *MyModule) Example(source *dagger.Directory) *dagger.File {
return dag.
Z5labs().
Goapp(source).
Builder().
Binary()
}@function
def example(source: dagger.Directory) -> dagger.File:
return (
dag.z5labs()
.goapp(source)
.builder()
.binary()
)@func()
example(source: Directory): File {
return dag
.z5labs()
.goApp(source)
.builder()
.binary()
}container() 🔗
Container returns the host-platform scratch image containing the compiled binary at /app/ with that path as entrypoint.
Return Type
Container ! Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@1605a0beb62ff451135cd7c694d889ecc414c4af call \
go-app --source DIR_PATH \
builder \
containerfunc (m *MyModule) Example(source *dagger.Directory) *dagger.Container {
return dag.
Z5labs().
Goapp(source).
Builder().
Container()
}@function
def example(source: dagger.Directory) -> dagger.Container:
return (
dag.z5labs()
.goapp(source)
.builder()
.container()
)@func()
example(source: Directory): Container {
return dag
.z5labs()
.goApp(source)
.builder()
.container()
}