toolcache
`pacha-site` already hand-roll identically, extracted so the rest of theorganisation'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.2Entrypoint
Return Type
Toolcache Example
dagger -m github.com/wildbitca/daggerverse/toolcache@fdb51be8c65ccec56d941f4fe71e5e6ad5ab0a3e 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
| Name | Type | Default Value | Description |
|---|---|---|---|
| base | Container ! | - | container with the repository mounted and workdir set. |
| cacheKey | String ! | - | namespaces the two cacheVolumes ( |
| nodeModulesPath | String ! | - | absolute path where |
| warmTools | Directory ! | - | the |
Example
dagger -m github.com/wildbitca/daggerverse/toolcache@fdb51be8c65ccec56d941f4fe71e5e6ad5ab0a3e call \
npm-cache --base IMAGE:TAG --cache-key string --node-modules-path string --warm-tools DIR_PATHfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| cacheKey | String ! | - | the IDENTICAL value passed to |
| cacheBust | String ! | - | 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@fdb51be8c65ccec56d941f4fe71e5e6ad5ab0a3e call \
export-npm --cache-key string --cache-bust stringfunc (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)
}