Dagger
Search

toolcache

`pacha-site` already hand-roll identically, extracted so the rest of the
organisation's Node/TypeScript pipelines can adopt it instead of running with no
cache at all.

── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────
Dagger's own GHA cache exporter (`_EXPERIMENTAL_DAGGER_CACHE_CONFIG: type=gha`)
never stored anything measurable in any repo that carried it — `GET
/repos///actions/caches` returned 0 entries in every case checked —
and `pacha-site` (commit dbc86e8, 2026-09-05) found it is also a race: with more
than one Dagger session per job, the exporter flushes on EACH session's close,
which can collide with the next session's local filesync parent snapshot.

The alternative `pacha`/`pacha-api`/`pacha-site` already use is userland: a plain
tarball of the npm download cache and `node_modules`, carried across runs by
`actions/cache/restore`+`save` steps the WORKFLOW controls directly, not by a
Dagger-internal exporter. It has no session-end export and nothing for a
mid-session race to corrupt. This module is that pattern, generalised.

── THE SHAPE, UNCHANGED FROM THE THREE HAND-ROLLED COPIES ──────────────────
Two Dagger `cacheVolume`s per caller (`-npm`, `-node-modules`),
shared by name within ONE Dagger engine — which is exactly the lifetime of a
single GitHub Actions job. `npmCache` mounts both onto a container the caller has
already prepared (source mounted, workdir set) and seeds them from a previous
run's `exportNpm` output when the caller has one. `exportNpm` reads the SAME two
volumes back out as a `Directory` the workflow can hand to `actions/cache/save`.
Both calls must happen within the SAME job: a `cacheVolume` is engine-internal
state, and a Dagger engine does not survive past the job that started it — it is
not a cross-job handoff channel (see this repository's README, "A cache volume is
NOT a handoff channel", measured on `elinvo-site`).

`cacheKey` is NOT optional and is not defaulted to anything derived from the
caller: every repository adopting this module must pick its own, matching the
existing convention (`pacha-app-*`, `pacha-api-*`, `pacha-site-*`) — a shared
default would let two callers collide on the same cacheVolume names the moment
either forgot to override it.

`warmTools` IS REQUIRED, not optional, and that is deliberate (v0.1.1). An
OPTIONAL `Directory` parameter on a function in a dependency module crashes the
generated TypeScript client with "Cannot read properties of undefined (reading
'replace')" the moment a caller actually supplies a value — but works with no
error when the caller omits the flag entirely. Measured with a two-line probe
function doing nothing but returning its input: identical signature, `Directory`
required — works; optional — crashes, in BOTH cases with a value actually
passed. Every real caller (a GHA workflow that always runs `mkdir -p` before
`--warm-tools=$DIR`, cache hit or not) always supplies a value, so an optional
parameter here would crash on every single real invocation. Callers pass
`dag.directory()` (empty) when they have nothing to seed from; this function's
own cache-miss handling (the `-s` test below) already treats an empty directory
exactly like a true miss.

Installation

dagger install github.com/wildbitca/daggerverse/toolcache@v0.1.1

Entrypoint

Return Type
Toolcache
Example
dagger -m github.com/wildbitca/daggerverse/toolcache@755f4fba1746e1ee36993252fcac250b26669317 call \
func (m *MyModule) Example() *dagger.Toolcache  {
	return dag.
			Toolcache()
}
@function
def example() -> dagger.Toolcache:
	return (
		dag.toolcache()
	)
@func()
example(): Toolcache {
	return dag
		.toolcache()
}

Types

Toolcache 🔗

npmCache() 🔗

Mounts an npm download cache (/root/.npm) and a node_modules cache onto base, seeded from a previous run’s exportNpm output when warmTools is given. A miss is not an error: base comes back with the two cacheVolumes mounted but empty, and the caller’s own install step runs cold exactly as it would with no cache at all.

base must already have its source directory mounted and its workdir set to where node_modules should live — this only adds the two mounts and, on a warm run, untars into them; it does not run npm ci or any install step.

Return Type
Container !
Arguments
NameTypeDefault ValueDescription
baseContainer !-

container with the repository mounted and workdir set.

cacheKeyString !-

namespaces the two cacheVolumes (<cacheKey>-npm, <cacheKey>-node-modules). Pick one unique to the calling repository (the existing convention is the repo name, e.g. “pacha-site”) and pass the IDENTICAL value to exportNpm. Two callers that share a value share a cache, which is never what a second caller actually wants.

nodeModulesPathString !-

absolute path where node_modules lives under base’s workdir, e.g. “/src/node_modules”.

warmToolsDirectory !-

the Directory a previous run’s exportNpm returned, round-tripped through actions/cache by the caller’s workflow. Pass dag.directory() (empty) on a cache miss — see the module header on why this cannot be optional.

Example
dagger -m github.com/wildbitca/daggerverse/toolcache@755f4fba1746e1ee36993252fcac250b26669317 call \
 npm-cache --base IMAGE:TAG --cache-key string --node-modules-path string --warm-tools DIR_PATH
func (m *MyModule) Example(base *dagger.Container, cacheKey string, nodeModulesPath string, warmTools *dagger.Directory) *dagger.Container  {
	return dag.
			Toolcache().
			Npmcache(base, cacheKey, nodeModulesPath, warmTools)
}
@function
def example(base: dagger.Container, cachekey: str, nodemodulespath: str, warmtools: dagger.Directory) -> dagger.Container:
	return (
		dag.toolcache()
		.npmcache(base, cachekey, nodemodulespath, warmtools)
	)
@func()
example(base: Container, cacheKey: string, nodeModulesPath: string, warmTools: Directory): Container {
	return dag
		.toolcache()
		.npmCache(base, cacheKey, nodeModulesPath, warmTools)
}

exportNpm() 🔗

The npm + node_modules caches npmCache mounted, as a Directory the workflow can hand to actions/cache/save. Call AFTER the build/test call in the SAME job: the two cacheVolumes are shared by name within one Dagger engine, so this picks up whatever that call’s containers wrote into them.

Return Type
Directory !
Arguments
NameTypeDefault ValueDescription
cacheKeyString !-

the IDENTICAL value passed to npmCache for this job.

cacheBustString !-

varies the call so the engine does not serve a stale result from a previous invocation in the same run; the GitHub run id is the usual value. Unused beyond that — present only to defeat caching of this function’s own result.

Example
dagger -m github.com/wildbitca/daggerverse/toolcache@755f4fba1746e1ee36993252fcac250b26669317 call \
 export-npm --cache-key string --cache-bust string
func (m *MyModule) Example(cacheKey string, cacheBust string) *dagger.Directory  {
	return dag.
			Toolcache().
			Exportnpm(cacheKey, cacheBust)
}
@function
def example(cachekey: str, cachebust: str) -> dagger.Directory:
	return (
		dag.toolcache()
		.exportnpm(cachekey, cachebust)
	)
@func()
example(cacheKey: string, cacheBust: string): Directory {
	return dag
		.toolcache()
		.exportNpm(cacheKey, cacheBust)
}