Dagger
Search

z5labs

and release pipelines for Go projects.

There are two entry points. Z5labs.Go is the Go language chain: it carries
the standardized checks over a source tree and its terminal Ci runs them,
while its other terminal, App, cross-compiles the application, packages one
image per platform and hands back an App whose Publish pushes them. A
library is a source tree you never call App on, which is why there is no
library archetype.

Z5labs.App is the other, and it is the general one: an App assembled from
executables this module did not build. See "Prebuilt executables, and
languages with no chain" below. Z5labs.Go is built on it rather than beside
it, so there is one image build, one variant-set validation and one publish.

# The image contract

Every image this module builds carries the same environment, and it is
exactly three variables:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/home/nonroot
TMPDIR=/tmp

with /usr/local/bin as the directory an extension's executables land in.
Every value is fixed by the module and no caller-facing method can move any
of them, because a published image is something other people write `FROM`
and `COPY` lines against: a PATH that varied per app would make "put your
plugin on the PATH" a per-image question, and moving the directory later
would break every line already written. The PATH value is the conventional
default a container runtime injects when an image sets none, so an image
that later gains a real base layer behaves the way its base expects.

Exactly one seam puts an executable in that directory, and it is App.WithApp
— see "Composing one application into another" below. Contributing at, under
or over it is refused, and so is contributing onto any of the other five
directories that PATH names: a contributed file or tree states no
architecture and lands in every variant, so content something finds on the
PATH by name is a way to leave an arm64 image running an amd64 executable.
"An extension's executables land here" therefore means composition, and
nothing else (devex#427).

The application's own entrypoint does not rely on any of that. It is an
absolute path — /app/ — so the app runs whatever the PATH says.
PATH exists for what an extension adds, not for finding the app itself.
/app itself is root-owned and 0755: the directory the entrypoint sits in is
what decides whether the binary can be unlinked and replaced, and one the
application could write is one whose published digest stops describing what
is running.

The rest of the OCI configuration is part of the contract too. One field
carries a value and the rest are empty:

User 65532:65532 — see "# The image runs as 65532:65532" below
WorkingDir nothing — see "# No working directory" below
Cmd no default arguments
ExposedPorts none
Labels none — the per-platform OCI *annotations* are a separate field

Empty is a promise rather than an omission, and so is the one value: it is
all asserted the same way, by a publish that reads back the whole
configuration of every variant and refuses one that does not match, in both
directions. Each of the empty fields would otherwise be inherited, silently,
from the first base layer this module builds on, and the User is the field
where "not set" is not neutral (devex#426, devex#399).

# The image runs as 65532:65532

Every image this module publishes sets its User to 65532:65532 — a uid and a
gid, written as numbers. It is the same identity every byte in the image is
owned by, which is one decision rather than two: a uid that owns nothing it
runs, or files owned by a uid nothing runs as, is a pair somebody has to keep
in agreement by hand.

Numbers rather than a name, because a scratch image has no /etc/passwd for a
name to resolve against — a User of "nonroot" is a string nothing in the
image can turn into a uid — and because the two places the number is read
again are a long way from here: a `COPY --chown=65532:65532` in a derived
Dockerfile, and runAsUser in a Kubernetes securityContext. Neither of those
can ask the image what its user is; both are written against a number
somebody wrote down. 65532 specifically because that is distroless's
`nonroot`, and because z5labs/avroc and Zaba505/cpybkc already pin it in
their hand-rolled image builds — moving onto this archetype should not also
be a change to a contract they have published.

It is not overridable at build time, and no application gets root by asking.
There is no WithUser on App and there will not be one, for the reason there
is no way to turn provenance off: a hardening default that can be switched
off is hardening nobody downstream can rely on, and "runs as non-root" is
precisely the kind of claim an admission policy is written against. An
application that genuinely needs uid 0 is out of scope for this archetype
rather than a case it configures for.

A *deployment* may still pick a different uid, and that is an ordinary
configuration rather than a workaround. Every file this module writes is
world-readable, every directory world-traversable, and the entrypoint is
0555, so `docker run --user $(id -u):$(id -g)`, or a securityContext naming a
uid a cluster allocated, runs the same image the same way.

What an override to any other *non-root* uid does not buy is a writable
image: /app is root-owned 0755, HOME is root-owned 0555, and contributed
content is read-only. An override back to uid 0 does buy one, because root
bypasses the permission check — that is the same exception HOME's paragraph
below names, and it is now something a deployment has to ask for rather than
something it gets by default.

# Running as 65532 is behaviour-affecting

This changed images that already existed, and it changed them in the
direction that fails rather than the direction that warns. Before it, an
application ran as uid 0, which bypasses the permission check on every mode
in the image — so it could write anywhere at all, the read-

Installation

dagger install github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb

Types

Z5Labs 🔗

Z5labs is the root module type. Construct the Go language chain via Go; everything this module does is reached from there.

app() 🔗

App begins an application assembled from executables this module did not build: a prebuilt binary, a vendor’s CLI, or the output of a language whose chain nobody has written yet.

What comes back is a builder. Add one variant per platform with WithVariant and finish with Build, which is what refuses an empty set. The App that comes out is the same App a language chain produces — the same publish, the same hardening, the same annotations, the same SBOMs and the same signed provenance — because GoChain.App is built on this constructor rather than beside it.

version is the version every image is published under, and the same rules apply as to GoChain.App’s: it has to be usable as an OCI tag, and SemVer build metadata is refused rather than mangled. It is validated by Build.

What this seam is for, and what it is not

It is for packaging bytes somebody else produced with the hardening, the multi-platform publish, the annotations and the attestations this pipeline gives everything else. It is not a way to hand this module an image: the module still builds the image around the executable, applies the modes and the ownership and pins the layout, so what a caller supplies is bounded and every part of it is reachable by an exec of the entry. A caller-supplied container is a different and unbounded thing, and is refused — see contribute.go, which turned down the same offer for the same reason.

Its documents are asserted rather than derived

Say this out loud rather than leaving it to be found. A Go binary’s SBOM is derived from the compiled artifact, so it cannot disagree with what it describes. A document handed to WithVariant is a claim its supplier made. What keeps the claim honest is that it has to name the SHA-256 of the executable it accompanies and a publish checks it, so a document about other bytes fails the publish instead of shipping. What it cannot check is whether the components listed inside that document are the ones really linked into the executable; that is the supplier’s assertion, in a signed artifact, checkable by anyone who pulls the image.

Return Type
AppBuilder !
Arguments
NameTypeDefault ValueDescription
versionString !-

The version every image is published under. Any OCI-tag-safe string; SemVer build metadata is refused.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 app --version string
func (m *MyModule) Example(version string) *dagger.Z5LabsAppBuilder  {
	return dag.
			Z5labs().
			App(version)
}
@function
def example(version: str) -> dagger.Z5LabsAppBuilder:
	return (
		dag.z5labs()
		.app(version)
	)
@func()
example(version: string): Z5LabsAppBuilder {
	return dag
		.z5labs()
		.app(version)
}

composeSelfTest() 🔗

ComposeSelfTest checks the rules that decide whether one application’s payload may be composed into another’s image.

It sits on the module rather than in tests/ for the reason ImageConfigSelfTest and ContributionPathSelfTest record, and here the reason is stronger than economy. Two of these rules cannot be driven through the public API at all: no constructor in this module can declare a payload of more than one file, and no caller-facing seam can put an environment variable on an image — so a multi-file payload and a conflicting variable are branches that would be unexecutable end to end, and therefore deletable tomorrow with every test still green. Split out as free functions over declarations and maps, every branch runs in process.

The end-to-end half — that the refusals really are wired into WithApp, that a composed payload lands where the plugin directory promises, and that the derived image is published, annotated and attested like any other — is in tests/, where it costs a pair of applications instead of a table.

It runs in process and needs no container, so it is cheap enough to be a check of its own.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 compose-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Composeselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.composeselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.composeSelfTest()
}

contributedTreeSelfTest() 🔗

ContributedTreeSelfTest checks what a contributed tree may hold: directories and regular files, and nothing else.

It sits on the module for the reason ContributionPathSelfTest does — walkTree is an unexported function over a real filesystem, and every row below would otherwise cost a Dagger call to build a tree that differs from its neighbour by one entry. What cannot be checked in process is that the rule is wired into both readers of a contributed tree, that Dagger’s own export and copy preserve a link at all, and that a publish carrying one is refused before anything is pushed; those are in tests/.

The rule is a refusal rather than a skip because a skipped link is in no document and no digest while still being in the image — see the “A symbolic link is not content” section in contribute.go, which carries the decision and the measurements behind it.

It runs in process and needs no container, so it is cheap enough to be a check of its own.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 contributed-tree-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Contributedtreeselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.contributedtreeselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.contributedTreeSelfTest()
}

contributionPathSelfTest() 🔗

ContributionPathSelfTest checks the rules that decide where a caller may contribute content, and where they may not.

It sits on the module rather than in tests/ for the reason ImageConfigSelfTest records: the rules are unexported pure functions, and driving every branch of them through the public API would mean building a real multi-platform app per row of the table below. The end-to-end half — that the refusal really is wired into WithFile and WithDirectory, and that the entrypoint is one of the paths it protects — is in tests/, where it costs one app instead of twenty.

The rules exist because of one failure wearing several shapes: content that lands on top of other content leaves the image holding one thing while its documents describe two. That is the same undetectable incompleteness the contribution mechanism exists to prevent, arriving through the mechanism itself.

The PATH’s rows are the exception, and they answer a different question: what may reach a directory the image searches by name. Nothing contributed may, because only composition names the architecture of what it brings — see contribute.go. They are table rows rather than a mode bit because a rule enforced by a mode was bypassed by wrapping a binary in a directory, which is what devex#427 closed, and they cover every directory appPath names rather than the plugin directory alone: discovery by bare name is what the rule is about, and /usr/local/bin is one of six directories it can happen in.

It runs in process and needs no container, so it is cheap enough to be a check of its own.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 contribution-path-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Contributionpathselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.contributionpathselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.contributionPathSelfTest()
}

directoryDocument() 🔗

DirectoryDocument produces the SPDX document required to contribute dir to an image, for content whose ecosystem has no module able to produce one.

Unlike FileDocument this one enumerates: the package it describes carries one SPDX file element per file in the tree, each with its SHA-256 and the SHA-1 the format’s verification code is computed from. A directory summed as a single opaque blob would satisfy “the contribution is described” and leave “every file in the image is accounted for” false one level down, which is the same failure this whole mechanism exists to close.

The package’s own checksum is a digest over the sorted list of the tree’s paths and their digests, so two directories with the same name and version and different contents are different packages rather than one.

A tree carrying a symbolic link is refused, and so is one carrying a device, a pipe or a socket. A link is not bytes and cannot be enumerated, digested or given the mode the module sets, so a document that skipped it would describe a tree the image does not have — see the “A symbolic link is not content” section in contribute.go. The refusal names the link and what it points at, so it can be found and replaced with the thing itself.

name is required, because a directory has no name of its own to fall back to. See FileDocument for license and version.

Return Type
File !
Arguments
NameTypeDefault ValueDescription
dirDirectory !-

The directory the document describes.

nameString !-

How the contribution is named in the image’s document.

licenseString -

An SPDX licence expression for the content, e.g. MIT or Apache-2.0.

versionString -

The version of the contributed content, if it has one.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 directory-document --dir DIR_PATH --name string
func (m *MyModule) Example(dir *dagger.Directory, name string) *dagger.File  {
	return dag.
			Z5labs().
			Directorydocument(dir, name)
}
@function
def example(dir: dagger.Directory, name: str) -> dagger.File:
	return (
		dag.z5labs()
		.directorydocument(dir, name)
	)
@func()
example(dir: Directory, name: string): File {
	return dag
		.z5labs()
		.directoryDocument(dir, name)
}

fileDocument() 🔗

FileDocument produces the SPDX document required to contribute file to an image, for content whose ecosystem has no module able to produce one.

The document describes exactly one package — the file — with the SHA-256 of its bytes. Files are not enumerated inside it, because the package is one file and a file list would restate the checksum that is already there; what the image-level document then contains is the file, named and hashed.

license is an SPDX licence expression such as MIT, Apache-2.0 or “Apache-2.0 WITH LLVM-exception”. Leaving it out publishes NOASSERTION, which is the honest answer and is not the same answer as a licence that was looked for and not found.

name overrides how the contribution is identified in the image’s document. It defaults to the file’s own name, which is usually right; supply one when the file’s name is a build artifact rather than a description of it.

version is what the contributed content is a version of. There is no default: content with no ecosystem usually has no version, and inventing one would put a value in a field consumers compare.

Return Type
File !
Arguments
NameTypeDefault ValueDescription
fileFile !-

The file the document describes.

licenseString -

An SPDX licence expression for the content, e.g. MIT or Apache-2.0.

nameString -

How the contribution is named in the image’s document. Defaults to the file’s own name.

versionString -

The version of the contributed content, if it has one.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 file-document --file file:path
func (m *MyModule) Example(file *dagger.File) *dagger.File  {
	return dag.
			Z5labs().
			Filedocument(file)
}
@function
def example(file: dagger.File) -> dagger.File:
	return (
		dag.z5labs()
		.filedocument(file)
	)
@func()
example(file: File): File {
	return dag
		.z5labs()
		.fileDocument(file)
}

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, plus the build terminal App.

This is what a Go library needs and all it needs, which is why there is no library archetype: a library is a source tree you never call App on. 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
NameTypeDefault ValueDescription
sourceDirectory !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH
func (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)
}

imageConfigSelfTest() 🔗

ImageConfigSelfTest checks the rule every published image is held to: its OCI configuration is exactly what expectedImageConfig describes — the standardized environment, the declared entrypoint, and nothing else — and any difference from it is refused.

It sits on the module rather than in tests/ because the rule it checks is unexported and, more to the point, because its most important cases cannot be reached from tests/ at all. Nothing caller-facing can put a variable, a label, a port, a working directory or a default argument on an image, which is by design; the consequence is that every branch refusing one is unexecutable through the public API, so a suite built out of real images can only ever exercise the passing case. Splitting the comparison out and driving it here is what makes those refusals guarantees rather than comments — delete one and this check goes red.

The empty expectations are the half worth insisting on. An image that promises no working directory, no default arguments, no exposed ports and no labels promises those things exactly as much as it promises its PATH, and every one of them would be inherited from a base layer the moment this module builds on one (devex#426).

It also pins the shape of the standardized set: that it is PATH, HOME and TMPDIR and nothing else, that each is the constant that names it, and that the plugin directory is on the PATH.

It does not pin the values, and the distinction is worth stating because the check below reads like it does. The comparison is against the same constants expectedImageEnv returns, so it catches a fourth variable, a missing one, or a literal written into expectedImageEnv beside the constant — drift between the map and the constants — and not a constant that moved. Moving one is what breaks published Dockerfiles and deployed manifests, and the pin that catches it is the deliberately literal one in tests/, beside wantImagePath. Two literal copies here would be a third place to update and a second place to get it wrong.

It runs in process and needs no container, so it is cheap enough to be a check of its own.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 image-config-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Imageconfigselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.imageconfigselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.imageConfigSelfTest()
}

imageSbomSelfTest() 🔗

ImageSbomSelfTest checks the rule the image-level documents exist to keep: everything that entered the image is in them, once, and the two formats say the same thing about it.

It sits on the module rather than in tests/ for the reason ImageConfigSelfTest records, and for one more that is specific to this rule. The interesting case is an image assembled from several sources. App.WithFile and App.WithDirectory do now put a second contribution into an image — AppCustomizedImageStaysAttested publishes one and reads the documents back — but a real publish per case is not a way to drive a de-duplication table: the case that matters here is two contributions sharing a component, which needs two ecosystem documents and a synthesized overlap rather than a certificate bundle. Driving the assembly directly is what makes the de-duplication and the cross-format agreement guarantees rather than comments.

It runs in process over synthesized documents and needs no container, so it is cheap enough to be a check of its own.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 image-sbom-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Imagesbomselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.imagesbomselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.imageSbomSelfTest()
}

sourceRedactionSelfTest() 🔗

SourceRedactionSelfTest checks redactURLCredentials against the rule rather than against a publish: no remote reaches org.opencontainers.image.source carrying userinfo, and an SSH remote — whose username is part of the address — survives untouched.

It is a check of its own because the failure it guards cannot be seen from a publish. A remote whose credential leaks produces an image that builds, pushes and runs exactly like one whose credential was stripped; the difference is a field on a manifest that anyone who can pull the image can read, discovered by whoever reads it rather than by this pipeline. And the inputs that leak are the ones a working tree cannot easily be made to have: driving them through GoChain.gitFacts would mean a container, a repository and a remote URL that git itself would have to accept, per row.

It sits on the module rather than in tests/ for the same reason VersionTagsSelfTest does — the function is unexported — and because a table of a dozen remotes costs one in-process call here.

The rows are the shapes that distinguish the rule, including the accepting ones. A redactor that returned the empty string for everything would pass a table of leaking inputs alone while deleting the annotation from every image this module publishes.

Nothing here quotes an input. url.Parse’s own error is the reason the habit is worth keeping: it renders as parse "<the whole URL>": invalid URL escape "%zz", credential included, so a redactor that reported why it could not parse would leak by exactly the path this check closes. The assertions below print a row’s name and its expectation, and print what came back only after establishing that it does not carry the credential.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 source-redaction-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Sourceredactionselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.sourceredactionselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.sourceRedactionSelfTest()
}

variantSetSelfTest() 🔗

VariantSetSelfTest checks the rules that decide which sets of prebuilt executables can become an application, and which cannot.

It sits on the module for the reason ContributionPathSelfTest and ImageConfigSelfTest do: the rules are unexported pure functions, and driving every branch of them through the public API would mean compiling a real executable per row of the tables below. The end-to-end half — that the refusals really are wired into WithVariant and Build, and that an accepted set produces an image that runs — is in tests/, where it costs one application instead of a dozen.

It runs in process and needs no container, so it is cheap enough to be a check of its own.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 variant-set-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Variantsetselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.variantsetselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.variantSetSelfTest()
}

versionTagsSelfTest() 🔗

VersionTagsSelfTest checks the tag family a release is published under, case by case, against the rule rather than against a release.

It is a check of its own because the failure it guards is not one a publish can show you. A derivation that moves a tag it should not have — a prerelease that walks v1, a date-shaped version that invents a 2026.08 — succeeds at the registry and is discovered by a consumer whose FROM line resolved to something they never asked for, by which time it is published and someone has pulled it. So every shape that distinguishes the rule is stated here, including the accepting ones: a derivation that published one tag for everything would pass a table of prereleases alone.

It sits on the module rather than in tests/ for the same reason ImageEnvironmentSelfTest does — the function is unexported — and because a table of thirty versions costs one in-process call here and thirty publishes there.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 version-tags-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Z5labs().
			Versiontagsselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.z5labs()
		.versiontagsselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.z5labs()
		.versionTagsSelfTest()
}

AppBuilder 🔗

AppBuilder assembles an App from executables somebody else built: one entry per platform, each with the document describing it, terminated by Build.

Construct it with Z5labs.App. The file comment above records why the intermediate type exists at all and why it is this shape.

build() 🔗

Build packages every contributed executable as an image and returns the application.

This is where an empty variant set is refused. An App with no variants is publishable-looking and publishes nothing, and — because WithFile and WithDirectory apply content to every variant there is — it would swallow every contribution made to it without a word. Build existing is what keeps that state out of an App entirely.

The version is validated here rather than by the constructor, which has no way to report an error. Publish validates it a third time, which is what keeps the refusal of SemVer build metadata a property of publishing rather than of one constructor.

Return Type
App !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 app --version string \
 build
func (m *MyModule) Example(version string) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			App(version).
			Build()
}
@function
def example(version: str) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.app(version)
		.build()
	)
@func()
example(version: string): Z5LabsApp {
	return dag
		.z5labs()
		.app(version)
		.build()
}

withVariant() 🔗

WithVariant contributes one platform’s executable, and the document describing it.

platform is stated rather than inferred, and that is the rule the whole design turns on. A *dagger.File carries no architecture, so a helper that took an executable and worked out where it belonged would silently admit the failure devex#397 exists to refuse: an index whose arm64 manifest holds an amd64 binary, which fails at exec time with the kernel’s message and for nobody here. Nothing in this module infers a platform from a file.

entry becomes the image’s entrypoint. It lands in the standardized executable directory, mode 0555, owned by the image’s non-root user — the same treatment a compiled binary gets, because it goes through the same code.

name is what it lands as, and it defaults to the file’s own name. Supply one when the artifact’s file name is not what the application should be called, which is the common case for prebuilt binaries: a release pipeline names its cross-compiled artifacts app-amd64 and app-arm64, and the entry has to be one path in every variant or the entrypoint differs per architecture. Given no name and per-platform file names, this refuses the set rather than picking one — so --name is how a normal dist/ directory is contributed, and the default is for the case where the file is already called the right thing.

document is an SPDX 2.3 JSON document describing the executable, and it is required for the reason every contribution’s is: the SBOM a publish attaches accounts for the whole image, and a helper admitting undescribed content would make that contract true by the letter and false in substance. Z5labs.FileDocument produces one for an executable whose ecosystem has no module able to; a Go binary should carry dag.Go().Spdx instead.

What is refused, and why each one is a real failure

A platform contributed twice, because the second would silently replace the first and the App would ship fewer architectures than it was asked for. A platform that is not GOOS/GOARCH, because it cannot name a manifest. And an entry whose file name differs from the entries already contributed, because the entrypoint would then be a different path per architecture — a consumer who overrides the entrypoint, or writes a COPY –from= line against the image, would be right on one platform and wrong on another with nothing in the manifest list to say so.

A single-platform App is expressible and is not a degenerate case: one WithVariant and a Build is a complete application, published as one variant rather than as a multi-platform index pretending to be one.

Return Type
AppBuilder !
Arguments
NameTypeDefault ValueDescription
platformScalar !-

The platform this executable was built for, e.g. linux/arm64.

entryFile !-

The executable, which becomes the image’s entrypoint.

documentFile !-

An SPDX 2.3 JSON document describing the executable.

nameString -

What the executable is called in the image. Defaults to the file’s own name.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 app --version string \
 with-variant --entry file:path --document file:path
func (m *MyModule) Example(version string, platform , entry *dagger.File, document *dagger.File) *dagger.Z5LabsAppBuilder  {
	return dag.
			Z5labs().
			App(version).
			Withvariant(platform, entry, document)
}
@function
def example(version: str, platform: , entry: dagger.File, document: dagger.File) -> dagger.Z5LabsAppBuilder:
	return (
		dag.z5labs()
		.app(version)
		.withvariant(platform, entry, document)
	)
@func()
example(version: string, platform: , entry: File, document: File): Z5LabsAppBuilder {
	return dag
		.z5labs()
		.app(version)
		.withVariant(platform, entry, document)
}

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.

app() 🔗

App builds the application at pkg for every platform and returns it.

One binary is cross-compiled per platform, stamped at link time with version and with the commit; each is packaged as an image carrying the module’s standardized environment, the absolute entrypoint and the OCI source annotations; and an SPDX document describing each binary is generated for it. What comes back holds the images and those documents and knows nothing about the chain that produced them — see App.

The document per binary is a contribution document rather than the image’s. Publish assembles every contribution to an image into the SPDX and CycloneDX pair it attaches, so what a consumer reads describes the whole image; see sbom.go. For an app built only from a language chain the image is the binary, so the two are the same set of components — which is what makes this a change of subject rather than of content.

version is the caller’s, and is validated here

The version was a pure function of HEAD before this chain existed, which suits a high-frequency install with no semantic versioning and does not suit a project releasing on semver. It is now stated by whoever is releasing, and the only thing this module has an opinion about is that it can be an image tag: an OCI tag is [A-Za-z0-9_][A-Za-z0-9._-]{0,127}, and a version outside that charset is refused rather than rewritten.

SemVer build metadata — the + and everything after it — is called out separately when it is refused, because it is the case where rewriting would silently do damage: + is not in the tag charset, so dropping it would publish 1.0.0+build.1 and 1.0.0+build.2 under one tag, and the second would quietly replace the first.

commit still comes from HEAD and from nothing else

Every binary is stamped with main.version and main.commit. Declare the 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. commit is the short HEAD SHA and is never a parameter — a build identity a caller could have supplied identifies nothing — so two builds of one (commit, version) pair are byte-identical. Source without git metadata at HEAD is an error.

pkg is the package to build, in go build package syntax, relative to the source root. platforms defaults to linux/amd64 and linux/arm64.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
versionString !-

The version every binary is stamped with and every image is published under. Any OCI-tag-safe string; SemVer build metadata is refused.

pkgString "."

The package to build, in go build package syntax, relative to the source root.

platforms[Scalar ! ] -

The platforms to build for, e.g. linux/amd64. Empty takes the pipeline’s pair, linux/amd64 and linux/arm64.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string
func (m *MyModule) Example(source *dagger.Directory, version string) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version)
}
@function
def example(source: dagger.Directory, version: str) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
	)
@func()
example(source: Directory, version: string): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
}

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@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 ci
func (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.

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 — and these are the tags it builds every platform’s binary with.

Return Type
GoChain !
Arguments
NameTypeDefault ValueDescription
tags[String ! ] !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 with-build --tags string1 --tags string2
func (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
NameTypeDefault ValueDescription
versionString -No description provided
configFile -No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 with-lint
func (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
NameTypeDefault ValueDescription
raceBoolean !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 with-test --race boolean
func (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)
}

App 🔗

App is a built application: one container image per platform, the documents describing each of them, and the version they all carry. Construct it with GoChain.App and publish it with Publish.

It does not know what built it

App holds images, documents and build facts — never a binary, never a source tree, never a name. That is why there is no binary-name input and no Binary accessor: an application’s binary name is a detail of the language chain that compiled it, and an App that carried one would make every future chain — a Zig chain, a Java chain, an App assembled from nothing at all — have to invent one.

The consequence worth stating is that App cannot describe its own contents. dag.Go().Spdx(binary, source) needs both of the things App refuses to hold, so a document is produced by whoever contributed each thing in the image and carried here as one file per contribution. Publish assembles them into the SPDX and CycloneDX pair it attaches, and the oci module that does the attaching never learns that any of it is an SBOM — the separation daggerverse/CLAUDE.md asks for. It costs nothing, because dag.Go().Spdx returns a lazy *dagger.File that no unpublished app ever evaluates. sbom.go carries the whole arrangement and why the documents describe the image rather than the binary.

Configuration is chained, not constructed

Where the images are published, whose identity vouches for them and whether the connection is verified are all With* methods rather than constructor arguments, because none of them is a property of the artifact. The same App can be published to a mirror, to an internal registry, or nowhere at all.

container() 🔗

Container returns the image built for platform.

This is the same container Publish pushes, not a second build that merely agrees with it: GoChain.App and this method are both session-cached, so within one chained call the app is built once and everything downstream — a check on the image, the publish, an export — sees those exact bytes. That is what makes a check seam meaningful, and it is why the caching is part of the API rather than an optimization.

The guarantee is bounded by the session. Two separate dagger call invocations are two sessions and two builds, and while a build of one (commit, version) pair is byte-identical to another by construction, nothing here promises that the second invocation reuses the first’s containers. A caller that needs one build inspected and then published chains both onto one call.

What this container has not been through is the image-configuration check. That is a publish-time gate — App.Publish holds every variant to expectedImageConfig before the first byte moves — and it deliberately does not run here. An inspection seam exists for the image that is wrong as much as for the one that is right, and a Container that refused to hand back a container whose configuration failed the gate would withhold the bytes at exactly the moment somebody is trying to find out what is wrong with them; docker load and a look at the config is how that is done.

That is a statement about this module’s publish path and not a guarantee about the bytes. What comes back is an ordinary core Container, and a caller who wants to can push it themselves — container --platform=linux/amd64 publish --address=… — which goes round this gate exactly as it goes round the annotations, the assembled SBOMs, the provenance and the signature that make a z5labs release what it is. An image that leaves through Publish has been checked; an image somebody exported from here and pushed by hand is theirs.

Return Type
Container !
Arguments
NameTypeDefault ValueDescription
platformScalar !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 container
func (m *MyModule) Example(source *dagger.Directory, version string, platform ) *dagger.Container  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Container(platform)
}
@function
def example(source: dagger.Directory, version: str, platform: ) -> dagger.Container:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.container(platform)
	)
@func()
example(source: Directory, version: string, platform: ): Container {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.container(platform)
}

containers() 🔗

Containers returns every platform’s image, in the order the platforms were given to App. Same guarantee, and the same session bound, as Container.

Return Type
[Container ! ] !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 containers
func (m *MyModule) Example(source *dagger.Directory, version string) []*dagger.Container  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Containers()
}
@function
def example(source: dagger.Directory, version: str) -> List[dagger.Container]:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.containers()
	)
@func()
example(source: Directory, version: string): Container[] {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.containers()
}

publish() 🔗

Publish pushes this app to every repository named and returns one digest-pinned reference per published tag.

Each repository is a path appended to the address given to WithRegistry: “z5labs/hello” against “ghcr.io” publishes ghcr.io/z5labs/hello. The registry stays a separate input because a mirror or an internal registry serves the same release, and the repository is stated here rather than derived from the binary because the two are not the same thing — a binary called hello is routinely published as hello-service.

One manifest list is pushed per repository, naming every platform variant, so a consumer pulls a repository and gets their architecture. The returned references are <address>/<repository>:<tag>@<digest>: pinned, because a tag is a mutable name and a caller anchoring a deployment or a release note to what shipped has to be able to name immutable bytes.

One release, a family of tags

A release is published under every tag its version implies, not under the version alone: v1.2.3 also comes to name v1.2, v1 and latest, so a consumer can pin at the level of risk they want. Every tag of one release names one digest — the same manifest list, pushed once — and one reference comes back per tag, in the order the tags were written.

A SemVer prerelease publishes its own full version tag and moves none of the moving ones, and a version that is not SemVer publishes as a single tag. versionTags derives the family and is where those rules are stated; it is a pure function of the version, so what it cannot see — a release published out of order walking v1 backwards — is recorded there too.

How to read what comes back

The references are grouped repository-major: every tag of the first repository, in family order, then every tag of the second. The first reference of each group is the immutable one — the full version — so the reference a caller pins a deployment or a release note to is refs[i*len(family)], and a caller who does not want to know the family’s size can take the references whose tag is the version they passed.

This was one reference per repository before the family existed, so a caller indexing refs[i] per repository reads a tag of the first repository now rather than the reference for repositories[i]. The grouping is stated here because there is nowhere better: a structured return — a repository, a digest and its tags — is the shape this wants, and it is a schema object that every consumer of this module would have to take a binding for, so it is worth doing deliberately rather than as part of this change.

Every published digest carries an SPDX and a CycloneDX document per platform 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 — see newSigner — and so does one that cannot produce a complete document for every platform, for the same reason and by the same rule.

Those two documents describe the image: every byte in it, not only the executable a language chain built. They are assembled here, at publish time, out of one document per thing that entered the image, and they replace those rather than sitting beside them — a consumer fetches two documents per platform and nothing else. sbom.go records why the subject is the image, why assembly is not a scan, and what a contribution has to supply.

Those documents are attached as OCI referrers of the digest and are discoverable that way alone, so cosign verify-attestation finds nothing even though they are there. The package doc says why and gives the oras discover commands that do find them; a consumer pointed at this method needs that section too.

The image is signed too, and not merely the provenance statement about it: the manifest list and every per-platform manifest beneath it each carry a cosign signature, so a consumer runs

cosign verify <address>/<repository>:<version> \
  --certificate-identity-regexp '^https://github.com/<owner>/<repo>/\.github/workflows/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

against the tag, and the same command against any per-platform digest their runtime resolved. Both pass, which is the point of signing every manifest rather than only the one the tag names — see signImage. A publish that cannot sign fails in the same way and for the same reason as one that cannot attest. WithSigningKey changes the verifying command; its doc comment says how.

Within one repository the tag is the last thing written: the manifest list goes up under its own digest, the attestations are attached to that digest, and only then does the tag come to name it. So a publish that fails leaves no tag pointing at an unattested image — it leaves an unreferenced manifest, or, when the version was already published, the previous release still in place. attachAttestations records why that ordering was chosen and what the alternatives cost.

Repositories are published in the order given, and the operation is not atomic: a failure part way through leaves the earlier repositories published, and says which ones in its error. A registry has no transaction spanning repositories, so the alternative to saying so is not atomicity — it is a caller who cannot tell what shipped.

Publishing is a side effect against an external registry, so it is uncached: a re-run must actually push. The build above it is session cached, so the bytes pushed are the bytes Container returned.

Return Type
[String ! ] !
Arguments
NameTypeDefault ValueDescription
repositories[String ! ] !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 publish --repositories string1 --repositories string2
func (m *MyModule) Example(ctx context.Context, source *dagger.Directory, version string, repositories []string) []string  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Publish(ctx, repositories)
}
@function
async def example(source: dagger.Directory, version: str, repositories: List[str]) -> List[str]:
	return await (
		dag.z5labs()
		.go(source)
		.app(version)
		.publish(repositories)
	)
@func()
async example(source: Directory, version: string, repositories: string[]): Promise<string[]> {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.publish(repositories)
}

withApp() 🔗

WithApp composes another application’s payload into every platform of this one’s image.

The result is an ordinary App: published, annotated, signed and attested exactly like any other, to a repository of its own that has nothing to do with any binary’s name. What comes out is the base program wearing one more executable — it keeps the base’s entrypoint, user, environment and executable directory, and it is published under the base’s version, because the release it belongs to is the base’s release.

from’s entry lands in the standardized plugin directory under its own file name, so a base image whose CLI discovers plugins on the PATH finds it without either side agreeing on anything but that directory. Everything else from carries — its contributed files and directories — lands at its own path. The file comment above records why the entry is read from the declaration rather than from the entrypoint, and it is the single most important thing about this method.

There is no path argument and nothing is inferred. A composed application is composed whole or refused: see the file comment for the four refusals and why each is a failure that would otherwise publish cleanly and die on first exec.

Composition is not restricted to applications this module compiled, and a derived image may be composed into in turn — the payload of the result is the union of both sides’, the collision surface grows and the semantics do not. What gets nothing from this seam is an out-of-pipeline image built FROM a published base: a stranger’s Dockerfile adds bytes with no document and has no mechanism to produce one, so that image’s attestation describes the base and nothing else.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
fromApp !-

The application whose payload is composed into this one’s image.

Example
echo 'Custom types are not supported in shell examples'
func (m *MyModule) Example(source *dagger.Directory, version string, from *dagger.Z5LabsApp) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withapp(from)
}
@function
def example(source: dagger.Directory, version: str, from_: dagger.Z5LabsApp) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withapp(from_)
	)
@func()
example(source: Directory, version: string, from: Z5LabsApp): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withApp(from)
}

withDirectory() 🔗

WithDirectory contributes a directory tree to every platform’s image at path.

The tree lands at path in each variant, mode 0555 throughout, owned by the image’s non-root user. The mode is uniform — every file inside it is 0555 too, rather than 0444 under a traversable parent — and the comment on contributedDirectoryMode above records why that is accepted rather than worked around.

An executable inside such a tree is therefore executable, and that is not a way to extend the image: like WithFile, this refuses a path at, under or over any directory the image’s PATH resolves against, so nothing a caller contributes is ever discovered on the PATH by name. Wrapping a binary in a directory used to be exactly that bypass — see the file comment above and App.WithApp, which is the seam that names the platform of every byte it brings.

The tree may hold directories and regular files and nothing else. A symbolic link in it is refused — when the document is produced, and again at publish time for a caller who brought one of their own — because a link is the one kind of content none of the rules here can see: it is not given the mode, the path it names is not compared against anything, and it is in no document and no digest. The file comment above carries the decision and what was measured to reach it; contribute what the link pointed at instead.

document is an SPDX 2.3 JSON document describing the tree, and it is required for the reason WithFile’s is. Z5labs.DirectoryDocument produces one for content with no ecosystem, and it enumerates: the document it writes carries one file element per file in the tree, because “the contribution is described” and “every file in the image is accounted for” are different promises and only the second one is the point.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
pathString !-

The absolute path in the image to contribute the directory at.

dirDirectory !-

The directory to contribute.

documentFile !-

An SPDX 2.3 JSON document describing the directory’s contents.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-directory --path string --dir DIR_PATH --document file:path
func (m *MyModule) Example(source *dagger.Directory, version string, path string, dir *dagger.Directory, document *dagger.File) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withdirectory(path, dir, document)
}
@function
def example(source: dagger.Directory, version: str, path: str, dir: dagger.Directory, document: dagger.File) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withdirectory(path, dir, document)
	)
@func()
example(source: Directory, version: string, path: string, dir: Directory, document: File): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withDirectory(path, dir, document)
}

withFile() 🔗

WithFile contributes a file to every platform’s image at path.

The file lands at path in each variant, mode 0444, owned by the image’s non-root user. Neither the mode nor the owner is a caller’s to state: see the file comment above.

document is an SPDX 2.3 JSON document describing what is in the file, and it is required — content arrives described or it does not arrive, because the documents a publish attaches describe the whole image rather than the binary a chain built. Z5labs.FileDocument produces one for content with no ecosystem, computing the digests itself; content that has an ecosystem should carry that ecosystem’s document, the way a Go binary carries dag.Go().Spdx.

The bytes are platform-neutral by construction: one *dagger.File is contributed to every variant. That is why there is no WithExecutable beside this — a raw file carries no platform, so a helper landing one in the executable directory would silently admit a binary built for the wrong architecture — and it is why a path at, under or over any directory the image’s PATH resolves against is refused here rather than merely being useless. Platform-specific executables arrive as an App instead: App.WithApp composes one, matched platform by platform, and lands its entry in the plugin directory.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
pathString !-

The absolute path in the image to contribute the file at.

fileFile !-

The file to contribute.

documentFile !-

An SPDX 2.3 JSON document describing the file’s contents.

Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-file --path string --file file:path --document file:path
func (m *MyModule) Example(source *dagger.Directory, version string, path string, file *dagger.File, document *dagger.File) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withfile(path, file, document)
}
@function
def example(source: dagger.Directory, version: str, path: str, file: dagger.File, document: dagger.File) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withfile(path, file, document)
	)
@func()
example(source: Directory, version: string, path: string, file: File, document: File): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withFile(path, file, document)
}

withInsecure() 🔗

WithInsecure publishes over plain HTTP with no TLS verification.

It is off unless a caller asks for it, and it is deliberately not inferred from WithRegistryService 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 — which is also why this method takes no argument at all.

Return Type
App !
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-insecure
func (m *MyModule) Example(source *dagger.Directory, version string) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withinsecure()
}
@function
def example(source: dagger.Directory, version: str) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withinsecure()
	)
@func()
example(source: Directory, version: string): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withInsecure()
}

withOidc() 🔗

WithOidc supplies the CI provider’s OIDC token request machinery, which is what a publish signs its provenance with.

requestUrl is the token request endpoint — ACTIONS_ID_TOKEN_REQUEST_URL on GitHub Actions, and whatever the equivalent is elsewhere. requestToken is the bearer for it, a secret because it is a credential for minting identity tokens.

There is deliberately no repository, ref or commit parameter beside them. Every identifying field in the provenance comes out of the exchanged token’s claims, because anything a caller could have supplied attests to nothing.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
requestUrlString !-No description provided
requestTokenSecret !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-oidc --request-url string --request-token env:MYSECRET
func (m *MyModule) Example(source *dagger.Directory, version string, requestUrl string, requestToken *dagger.Secret) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withoidc(requestUrl, requestToken)
}
@function
def example(source: dagger.Directory, version: str, requesturl: str, requesttoken: dagger.Secret) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withoidc(requesturl, requesttoken)
	)
@func()
example(source: Directory, version: string, requestUrl: string, requestToken: Secret): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withOidc(requestUrl, requestToken)
}

withOidcService() 🔗

WithOidcService points the token exchange at a Dagger-hosted OIDC endpoint, reached over the session network instead of the public one. Its engine-assigned endpoint replaces the host in WithOidc’s requestUrl; the path and query stay the caller’s, because those are part of the provider’s protocol.

This exists for the same reason WithRegistryService does, and is used by the test suite, which runs a real token endpoint rather than relaxing the provenance requirement into the shape of the tests.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
svcService !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-oidc-service --svc PROTOCOL://HOST:PORT
func (m *MyModule) Example(source *dagger.Directory, version string, svc *dagger.Service) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withoidcservice(svc)
}
@function
def example(source: dagger.Directory, version: str, svc: dagger.Service) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withoidcservice(svc)
	)
@func()
example(source: Directory, version: string, svc: Service): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withOidcService(svc)
}

withRegistry() 🔗

WithRegistry sets the registry to publish to.

address is the registry alone — “ghcr.io”, “registry.example.internal:5000” — and never a repository path. The repository is stated to Publish, so that the same app can go to a mirror or to an internal registry by changing this and nothing else.

username and auth are the credential. There is no unauthenticated publish: a registry that accepts anonymous writes is one this pipeline has no way to tell from a misconfigured one.

The +cache directive is repeated here rather than stated once for the chain: caching is per function in Dagger, so a chained method left undirected takes the default seven-day TTL and can hand a later session a stale object built from arguments it only appears to share — a registry service, for one, whose engine-assigned address is long gone.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
addressString !-No description provided
usernameString !-No description provided
authSecret !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-registry --address string --username string --auth env:MYSECRET
func (m *MyModule) Example(source *dagger.Directory, version string, address string, username string, auth *dagger.Secret) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withregistry(address, username, auth)
}
@function
def example(source: dagger.Directory, version: str, address: str, username: str, auth: dagger.Secret) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withregistry(address, username, auth)
	)
@func()
example(source: Directory, version: string, address: string, username: string, auth: Secret): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withRegistry(address, username, auth)
}

withRegistryService() 🔗

WithRegistryService points the publish at a Dagger-hosted registry reached over the session network instead of over the public network.

A service’s endpoint is assigned by the engine, so it cannot be written into an address ahead of time; this is how the publish learns it. Used by the test suite against a local registry, and by anyone whose private registry is itself a Dagger service.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
svcService !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-registry-service --svc PROTOCOL://HOST:PORT
func (m *MyModule) Example(source *dagger.Directory, version string, svc *dagger.Service) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withregistryservice(svc)
}
@function
def example(source: dagger.Directory, version: str, svc: dagger.Service) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withregistryservice(svc)
	)
@func()
example(source: Directory, version: string, svc: Service): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withRegistryService(svc)
}

withSessionSigstore() 🔗

WithSessionSigstore points keyless signing at a certificate authority and a transparency log running inside this Dagger session, instead of at the public sigstore.

It exists so the keyless path can be executed rather than only described: without it, the certificate request, the chain split, the log uploads — the image signatures’ and the provenance envelope’s — and the three annotations that carry them are reachable only by publishing a real release. The suite stands up a CA of its own and verifies the result with stock cosign verify --certificate-identity, which is the command Publish’s doc comment tells consumers to run.

What it takes to redirect a real publish with this

Deliberate work, which is the property being bought — not impossibility, which would be a stronger claim than the type supports. A *dagger.Service is a container the caller controls, and a container can proxy: a service running socat forwards a certificate request, workload identity token and all, straight out of the session. So this is not a boundary; it is a seam that cannot be crossed by accident. Three decisions make that so:

  • It takes services, never URLs. There is no string argument here for a typo, an inherited environment variable or a templated value to land in, which is the whole class of accident a --fulcio-url flag would have opened: one wrong character and a release is certified by somebody else’s CA. Redirecting this one takes a caller writing a service and passing it, which is not a thing that happens to a release pipeline unattended.
  • Both are required, in one call. A publish is either wholly against a session-hosted sigstore or wholly against the public one; there is no state in which the certificate comes from one place and the log entry from another, which is incoherent rather than merely unusual. A pair that arrives half set is refused rather than quietly completed from the public sigstore — see sigstoreEndpoints.
  • It is never inferred. Not from WithRegistryService, not from WithOidcService, not from WithInsecure — inferring it from the shape of a test session is the relaxation daggerverse/CLAUDE.md names, and it would leave the production path as the only unexercised one, which is the situation this method exists to end.

It also conflicts with WithSigningKey rather than being ignored beside it. A supplied key is never certified by anything, so a call setting both has asked for two different modes; Publish refuses instead of picking one silently.

The name says “session” for the same reason WithInsecure says “insecure”: this method’s job is to be conspicuous in a file where it does not belong. A release pipeline signing against a sigstore that exists only for the length of one build is a thing a reader should stop at.

What a session-hosted sigstore cannot establish is stated where it is asserted: a local log’s countersignature is trusted by nobody, so a verifier still has to be told to ignore the log, and nothing here says anything about the public services’ availability.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
fulcioService !-No description provided
rekorService !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-session-sigstore --fulcio PROTOCOL://HOST:PORT --rekor PROTOCOL://HOST:PORT
func (m *MyModule) Example(source *dagger.Directory, version string, fulcio *dagger.Service, rekor *dagger.Service) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withsessionsigstore(fulcio, rekor)
}
@function
def example(source: dagger.Directory, version: str, fulcio: dagger.Service, rekor: dagger.Service) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withsessionsigstore(fulcio, rekor)
	)
@func()
example(source: Directory, version: string, fulcio: Service, rekor: Service): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withSessionSigstore(fulcio, rekor)
}

withSigningKey() 🔗

WithSigningKey signs the provenance with a caller-supplied PEM-encoded EC private key 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.

It changes what a consumer has to run, and the change is a downgrade worth stating. Nothing certifies a supplied key, so there is no identity to verify against and nothing to record in the public transparency log. That covers both signed things: the image signature carries the signature alone, and the provenance envelope carries a bare public key with no log entry, where a keyless publish gives each of them a certificate and a countersigned log entry. Verifying the image then means

cosign verify <ref> --key cosign.pub --insecure-ignore-tlog=true

where the keyless mode gets an identity and an issuer and no such flag. A caller who does not want to hand their consumers that flag should not be supplying a key.

Return Type
App !
Arguments
NameTypeDefault ValueDescription
keySecret !-No description provided
Example
dagger -m github.com/z5labs/devex/daggerverse/z5labs@c10a12007999eb807f68cd9af9000fbaeab159cb call \
 go --source DIR_PATH \
 app --version string \
 with-signing-key --key env:MYSECRET
func (m *MyModule) Example(source *dagger.Directory, version string, key *dagger.Secret) *dagger.Z5LabsApp  {
	return dag.
			Z5labs().
			Go(source).
			App(version).
			Withsigningkey(key)
}
@function
def example(source: dagger.Directory, version: str, key: dagger.Secret) -> dagger.Z5LabsApp:
	return (
		dag.z5labs()
		.go(source)
		.app(version)
		.withsigningkey(key)
	)
@func()
example(source: Directory, version: string, key: Secret): Z5LabsApp {
	return dag
		.z5labs()
		.go(source)
		.app(version)
		.withSigningKey(key)
}