CI caches can serve stale artifacts, leak data between branches, or use a key that is technically valid but does not match the work. preloop treats each cache as a view onto one content-addressed pool and gives each protocol its own key and invalidation behavior.
One pool, eighteen protocols
Objects are stored once under the SHA-256 digest of their contents. An entry maps a namespace, repository, ref, extension, key, and version to an object digest.
Three properties follow:
Deduplication. The same node_modules archive saved under two keys or branches is one object. A file shared by twelve cache entries is one blob, so storage follows distinct content.
Immutable entries. Saving the same identity twice is refused rather than overwritten. To store new content under an old name, a job must change the key or version. Two jobs racing on one key cannot create a torn entry.
Scoped visibility. Objects are shared physically, but an entry is visible only when its namespace uploaded it, a visible entry references it, or the global tier published it. Sharing bytes on disk does not share lookups between tenants.
Keys belong to the toolchain
preloop uses each tool's own content-addressing where it exists:
- actions/cache uses the workflow's key and restore-key semantics.
- Go uses its action ID, a hash of the compiler, flags, and source.
- Turborepo and Nx use their computed task hashes and declared inputs.
- Cargo's target directory uses repository, workflow, job, matrix, OS, architecture, and compiler version. It deliberately excludes Cargo.lock.
Cargo already tracks per-unit fingerprints. A dependency change invalidates only units that depend on it, while other units remain usable. Including Cargo.lock in the cache key would discard the entire target directory after every dependency change and force a cold build. The cache key locates the right target-directory neighborhood; Cargo decides which units are fresh.
preloop owns tenancy, immutability, quotas, and collection. The toolchain owns keys and invalidation. Keeping those responsibilities separate avoids serving content that happens to be fast but is wrong.
Scope comes from job identity
A cache call carries a job token. preloop reads namespace, repository, and ref from the token's claims and uses them as the write scope. The request body cannot choose a namespace, so a compromised step cannot write to another branch's cache by changing a request field.
Fork pull requests can read but cannot write. Reads do not use the trust gate; writes do. A job without write permission receives a read-only scope instead of failing solely because a cache save is disallowed.
Lookups use a fixed order: the job's ref, the pull request's base ref, then the default branch. In each scope, lookup checks the exact key, the newest prefix match, then restore keys in the declared order. Unrelated branches are invisible.
Cache families
The pool serves these groups:
Archives already in workflows. actions/cache save steps are stored verbatim. A per-file variant stores a manifest of file digests and fetches only files that differ. Keys, restore keys, and step syntax stay the same.
Toolchains. Downloaded runtimes and dependency directories used by setup-node, setup-python, setup-java, setup-ruby, and setup-bun are cached. Runtime keys are stable per platform and version.
Build state. Go action entries use GOCACHEPROG. Rust target directories use per-file manifests, exclude the incremental directory that cannot survive a different machine, and keep Cargo dep-info files needed by freshness checks. node_modules and bundler state are also cached.
Committed volumes. A working directory is saved and restored as a volume instead of an archive, for state that changes on most runs and is cheaper to keep live than to pack and unpack every time.
Remote task caches. Turborepo's /v8/artifacts protocol and Nx's /v1/cache use the same pool. The job token supplies authentication, so a remote cache that previously needed a hosted service needs a URL.
Container layers. Layer and config blobs are stored by digest, along with manifests and Buildx cache records. Tags are moving pointers and are revalidated on pull, so a tag cannot return an old image without checking its current digest.
Public artifacts. The pool stores browser builds for Playwright and Puppeteer and apt package archives. A global tier fetches toolchain objects from upstream package registries and verifies them by digest before a job can read them.
Observations. Step reuse records, test dependency maps, job receipts, and per-job stat-to-digest maps are stored in the same pool. These facts make later runs cheaper.
What keeps caches correct
Four controls handle most correctness cases:
Quotas and LRU. Quotas apply per extension and namespace. Eviction is least-recently-used inside a repository-and-extension bucket, so one noisy neighbor cannot evict entries from another bucket.
Collection with a grace period. Background collection removes objects with no entry references after a grace period. The delay prevents collection from deleting a blob while a fetch in flight is about to use it.
Read leases. Deletes check active leases first and return 409 rather than removing a blob from a running job.
Fail-open reads and fail-closed reuse. A cache miss, corrupt object, or unreachable backend is treated as absent and the job runs. Step reuse and test selection take the opposite approach and fail closed because they affect correctness.
What a restore looks like
The visible output is a restore line:
hashed 18,204 files on disk
fetched 3.9 MB of 412 MB (99% already present)
Cache restored from key: node-cache-Linux-x64-npm-8f21…
Restored 1.2sIllustrative output. The restore mechanism hashes files already on disk and fetches only missing or changed digests.
A warm Rust target directory with no source changes requires Cargo's freshness check rather than a full build. The check is cheaper than rebuilding the directory.
What cache metrics do not show
Hit rate depends on how warm the runner already was, so it is not a useful comparison by itself. The meaningful result depends on each cache's key stability, content size, and toolchain behavior. A cache that occasionally misses but never serves wrong content is more useful than one that hits often and sometimes returns stale state.