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, 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`).
── CACHEVOLUME NAMESPACING: WHY THE VOLUMES ARE PASSED IN, NOT KEYED BY STRING ──
Dagger namespaces a `dag.cacheVolume(key)` call by the MODULE that makes the
call, not just by the key string (see `docs/cache-investigation-2026-10-19.md`
in `org-gitops` for the full trace evidence). A v0.1.x caller that mounted
`dag.cacheVolume('-npm')` in its own `base()` and then called this
module's `npmCache(cacheKey: '-npm', ...)` was never touching the same
volume: its own call landed in `mod(-ci.)`, while this module's internal
`dag.cacheVolume()` call for the identical string landed in
`mod(toolcache...)` — two disjoint volumes that only ever shared a label, so
the `actions/cache` round-trip seeded and exported a volume `npm ci` never read
from (confirmed: every restore showed ~336 bytes, i.e. two empty tars).
The fix is for the CALLER to create the `CacheVolume`s (so they are namespaced
to the caller's own module, matching whatever `base()` already mounts) and hand
the resulting objects to `npmCache`/`exportNpm`, which now only mount whatever
`CacheVolume` they are given — they never call `dag.cacheVolume()` themselves.
Passing an already-constructed `CacheVolume` across the module boundary carries
its resolved identity with it, so there is no second, toolcache-internal
namespace left to diverge from.
`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.2.0Entrypoint
Return Type
Toolcache Example
dagger -m github.com/wildbitca/daggerverse/toolcache@fc748617f43d10e504b4768cb9a45f0991d08b63 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. |
| npmCacheVolume | CacheVolume ! | - | the |
| nodeModulesCacheVolume | CacheVolume ! | - | same as |
| nodeModulesPath | String ! | - | absolute path where |
| warmTools | Directory ! | - | the |
Example
dagger -m github.com/wildbitca/daggerverse/toolcache@fc748617f43d10e504b4768cb9a45f0991d08b63 call \
npm-cache --base IMAGE:TAG --npm-cache-volume VOLUME_NAME --node-modules-cache-volume VOLUME_NAME --node-modules-path string --warm-tools DIR_PATHfunc (m *MyModule) Example(base *dagger.Container, npmCacheVolume *dagger.CacheVolume, nodeModulesCacheVolume *dagger.CacheVolume, nodeModulesPath string, warmTools *dagger.Directory) *dagger.Container {
return dag.
Toolcache().
Npmcache(base, npmCacheVolume, nodeModulesCacheVolume, nodeModulesPath, warmTools)
}@function
def example(base: dagger.Container, npmcachevolume: dagger.CacheVolume, nodemodulescachevolume: dagger.CacheVolume, nodemodulespath: str, warmtools: dagger.Directory) -> dagger.Container:
return (
dag.toolcache()
.npmcache(base, npmcachevolume, nodemodulescachevolume, nodemodulespath, warmtools)
)@func()
example(base: Container, npmCacheVolume: CacheVolume, nodeModulesCacheVolume: CacheVolume, nodeModulesPath: string, warmTools: Directory): Container {
return dag
.toolcache()
.npmCache(base, npmCacheVolume, nodeModulesCacheVolume, 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, passing the IDENTICAL CacheVolume objects given to npmCache
— this picks up whatever that call’s containers wrote into them.
Return Type
Directory !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| npmCacheVolume | CacheVolume ! | - | the identical |
| nodeModulesCacheVolume | CacheVolume ! | - | the identical |
| 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@fc748617f43d10e504b4768cb9a45f0991d08b63 call \
export-npm --npm-cache-volume VOLUME_NAME --node-modules-cache-volume VOLUME_NAME --cache-bust stringfunc (m *MyModule) Example(npmCacheVolume *dagger.CacheVolume, nodeModulesCacheVolume *dagger.CacheVolume, cacheBust string) *dagger.Directory {
return dag.
Toolcache().
Exportnpm(npmCacheVolume, nodeModulesCacheVolume, cacheBust)
}@function
def example(npmcachevolume: dagger.CacheVolume, nodemodulescachevolume: dagger.CacheVolume, cachebust: str) -> dagger.Directory:
return (
dag.toolcache()
.exportnpm(npmcachevolume, nodemodulescachevolume, cachebust)
)@func()
example(npmCacheVolume: CacheVolume, nodeModulesCacheVolume: CacheVolume, cacheBust: string): Directory {
return dag
.toolcache()
.exportNpm(npmCacheVolume, nodeModulesCacheVolume, cacheBust)
}