Dagger
Search

workspace-ci

Anyone maintaining a repository of modules ends up writing the same CI by
hand: enumerate the checks, work out which ones a change could affect, route
each to the module that owns it, and avoid re-running what a previous run
already proved good. This module is that engine. It reads the workspace it is
invoked from, diffs a commit range, and returns the checks to run — already
routed, with timeouts and memoization hashes applied — so a CI system needs one
call and at most a format shim.

Nothing here loads a module the plan does not need. Checks are enumerated per
module (Module.checks), never through a root aggregator that installs every
suite as a toolchain, and the run-everything path emits one leg per module so
it loads none at all. See README.md for what counts as a change, what is never
memoized, and how base-image drift is bounded.

Installation

dagger install github.com/z5labs/devex/daggerverse/workspace-ci@443ec787c61bd02545c8a6bf5b08ad9f2dae746d

Entrypoint

Return Type
WorkspaceCi !
Arguments
NameTypeDefault ValueDescription
globalPaths[String ! ] -Repo-relative path prefixes that govern how CI runs rather than what any check computes; a change to one runs everything. They belong to no module's source context, so nothing else would attribute them.
timeoutsString "{}"Per-leg check-step budgets in minutes, as a JSON object keyed by a leg's display name or by a module directory (which covers every leg of that module). It is JSON because Dagger function parameters cannot be Go maps.
defaultTimeoutInteger 6The check-step budget in minutes for a leg with no override.
memoTokenSecret -A credential for reading the memoization store: a GitHub token with actions:read on memoRepo. Nothing is ever written from here — see README.md.
memoRepoString -The owner/name whose Actions cache holds the memoization store.
memoRefs[String ! ] -The git refs whose cache scopes may be trusted to hold recorded passes. Defaults to none, which reads nothing: a scope a run can write is a scope that must be chosen deliberately.
memoTtlInteger 86400How long, in seconds, a recorded pass may be honoured. This is the answer to base-image drift, which a source-derived hash cannot see.
Example
dagger -m github.com/z5labs/devex/daggerverse/workspace-ci@443ec787c61bd02545c8a6bf5b08ad9f2dae746d call \
func (m *MyModule) Example() *dagger.WorkspaceCi  {
	return dag.
			Workspaceci()
}
@function
def example() -> dagger.WorkspaceCi:
	return (
		dag.workspace_ci()
	)
@func()
example(): WorkspaceCi {
	return dag
		.workspaceCi()
}

Types

WorkspaceCi 🔗

WorkspaceCi plans CI for the workspace it is invoked from.

affectedModules() 🔗

AffectedModules returns, as a JSON array of repo-relative directories, the modules whose checks a change could affect. It is the same attribution Plan applies, stopping before any module is loaded, and answers “what did this change reach” without paying for check enumeration.

The arguments mean what they mean on Plan.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
baseString !-

The commit the change is measured from.

headString !-

The commit the change is measured to.

repoDirectory -

The repository to plan for. Defaults to the calling workspace.

workspaceWorkspace -

The workspace to read repo from when repo is omitted. Defaults to the caller’s.

Example
echo 'Custom types are not supported in shell examples'
func (m *MyModule) Example(ctx context.Context, base string, head string) string  {
	return dag.
			Workspaceci().
			Affectedmodules(ctx, base, head)
}
@function
async def example(base: str, head: str) -> str:
	return await (
		dag.workspace_ci()
		.affectedmodules(base, head)
	)
@func()
async example(base: string, head: string): Promise<string> {
	return dag
		.workspaceCi()
		.affectedModules(base, head)
}

generated() 🔗

Generated verifies that every committed dagger.gen.go and internal/dagger/*.gen.go in the calling workspace matches what dagger develop would produce at each module’s pinned engineVersion.

Every module in the workspace is checked, including the root one and every tests or examples module.

This check is why generated files need not be global inputs to the memoization hash: it proves they are derived from inputs that are, it belongs to the root module so a plan always runs it, and it is never memoized. The result is deliberately never cached either — the workspace is read at call time rather than passed as an argument, so a cached pass would be a pass for a tree the check never looked at.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/workspace-ci@443ec787c61bd02545c8a6bf5b08ad9f2dae746d call \
 generated
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Workspaceci().
			Generated(ctx)
}
@function
async def example() -> None:
	return await (
		dag.workspace_ci()
		.generated()
	)
@func()
async example(): Promise<void> {
	return dag
		.workspaceCi()
		.generated()
}

generatedSelfTest() 🔗

GeneratedSelfTest pins that Generated can actually fail.

The check this repo extracted it from silently verified nothing for months (it routed through Workspace.Generators, which is empty unless a module declares a +generator function), so a green Generated is only worth as much as the proof that a stale module turns it red (#184).

It runs the same codegen comparison against a single module, first pristine (expecting no drift) and then with that module’s committed bindings deliberately made stale (expecting drift naming the file).

Return Type
Void !
Arguments
NameTypeDefault ValueDescription
probeModuleString -

The module to make stale, repo-relative. Defaults to the first dependency-free module in the workspace, which is the cheapest one to regenerate.

Example
dagger -m github.com/z5labs/devex/daggerverse/workspace-ci@443ec787c61bd02545c8a6bf5b08ad9f2dae746d call \
 generated-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Workspaceci().
			Generatedselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.workspace_ci()
		.generatedselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.workspaceCi()
		.generatedSelfTest()
}

plan() 🔗

Plan returns the legs of CI to run for a change, each already routed to the module that owns it and bounded by a timeout.

Each leg is a {name, module, filter, hash, timeout, jobTimeout} object: the display name, the repo-relative module to invoke with -m, the check pattern to pass to dagger check (empty to run every check the module has), the input hash a pass may be recorded under (empty means never memoize), and the step and job budgets in minutes.

base and head are the commit SHAs to diff, three-dot (merge-base) like a PR’s change set. An empty or all-zeros base — a new branch, a missing base — means “run everything”.

A plan that cannot read the workspace is an error, never an empty plan: an empty matrix skips the run job and passes the gate having run nothing. Everything else fails safe towards running too much — an unusable diff range, an unreadable source context, a module whose checks cannot be enumerated.

repo defaults to the calling workspace and is where everything is read from: module discovery is a dagger.json walk, source contexts and check enumeration work off the exported tree, and the change set comes from its .git. Passing it explicitly is also the escape hatch for a caller whose .git is a file rather than a directory (a git worktree), which would otherwise degrade to running everything.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
baseString !-

The commit the change is measured from.

headString !-

The commit the change is measured to.

formatEnum "JSON"No description provided
repoDirectory -

The repository to plan for. Defaults to the calling workspace.

workspaceWorkspace -

The workspace to read repo from when repo is omitted. Defaults to the caller’s.

knownGoodString "[]"

Input hashes a previous run already proved good, as a JSON array. They are honoured on the same terms as the ones read from the memoization store, and are how a CI system that reads its own store — or a test — supplies them without one. Anything unparseable is treated as empty: a store that cannot be read must cost speed, never correctness.

diagnosticsBoolean -

Emit a diagnostics object — the plan plus which modules had to be loaded to produce it, whether everything was selected, which legs a recorded pass retired, and whether recorded passes were honoured at all — instead of the bare plan. Intended for tests and for explaining a plan, not for CI.

Example
echo 'Custom types are not supported in shell examples'
func (m *MyModule) Example(ctx context.Context, base string, head string) string  {
	return dag.
			Workspaceci().
			Plan(ctx, base, head)
}
@function
async def example(base: str, head: str) -> str:
	return await (
		dag.workspace_ci()
		.plan(base, head)
	)
@func()
async example(base: string, head: string): Promise<string> {
	return dag
		.workspaceCi()
		.plan(base, head)
}

selectionSelfTest() 🔗

SelectionSelfTest verifies the change -> modules -> legs mapping, and the properties a recorded pass depends on, against fixed fixtures — so a regression in either fails CI rather than silently under-running a consumer’s checks. It runs in-process and needs no services, so it is cheap enough to run on every leg set.

Return Type
Void !
Example
dagger -m github.com/z5labs/devex/daggerverse/workspace-ci@443ec787c61bd02545c8a6bf5b08ad9f2dae746d call \
 selection-self-test
func (m *MyModule) Example(ctx context.Context)   {
	return dag.
			Workspaceci().
			Selectionselftest(ctx)
}
@function
async def example() -> None:
	return await (
		dag.workspace_ci()
		.selectionselftest()
	)
@func()
async example(): Promise<void> {
	return dag
		.workspaceCi()
		.selectionSelfTest()
}