The dependency cache belongs before the credential boundary
The recorded install took 25 seconds cold and 1.1 seconds warm. The harder question was what the install process was allowed to see—and what one build could change for the next.
I started with a simple inefficiency: every fresh runner script cloned a repository and installed dependencies again. Repeating package installation made setup slower and added more package-manager work to each attempt. A shared cache looked like the obvious fix. It also changed the trust model. Once several jobs consume one directory, cache identity, publication, credentials, concurrent writers and later mutation all become part of the design.
The first useful rule was that a cache hit must mean “these exact inputs produced this complete dependency tree,” not “a folder with this name exists.” The key therefore includes the repository, its resolved commit, the install command, and the bytes—or explicit absence—of supported lockfiles. A moving branch name is not enough: two attempts against different commits must not silently share a result. Nor should a changed install command or lockfile reuse the previous tree.
On a miss, preparation installs into a private location. Only after the install has produced the expected modules does the runner publish the completed entry by rename. That makes publication a boundary: consumers should see either no entry or a complete one, never a half-installed tree. When two cold jobs race, one can win publication; the other should discard its candidate and consume the already-published complete entry. The losing job must not overwrite or merge into the winner’s tree.
The older implementation’s retained review recorded a real lockfile install at 25 seconds cold and 1.1 seconds warm, with no package-manager call on the warm path. It recorded an approximately 1.2 GB cache entry and exercises with six parallel and twelve staggered cold installations. The same review reports that its independent set detected 30 non-equivalent faults and retained one equivalent naming control. Those are reviewer-reported local results for that earlier shared-cache implementation. They are not a universal benchmark, and they do not describe the later copy-per-step design.
A cache hit is shared state
The first cache made a symlink from a step’s node_modules to the cached modules. That avoided reinstalling, but it also meant a step could write through the link and change the shared tree. One poisoned cache entry could then affect a later step whose source and lockfile were unchanged. The caller did not need package-manager privileges to create this class of contamination; ordinary writes by the build process were enough.
The later implementation changed the handoff:
// Each step owns its copy: writes through wrappers cannot poison later cache hits.
await cp(cachedModules, modules, {
recursive: true,
verbatimSymlinks: true,
});
const used = new Date();
await utimes(entry, used, used);
await pruneDependencyCache(root, entry);
This prevents ordinary writes through that step’s node_modules from mutating the published cache tree. It buys isolation between a consumer and the retained entry, but it is a copy, not a process sandbox. The step runs as the same user and can still write directly to the shared cache path if it knows where that path is. A boundary that depends on the process never discovering a filesystem path is not a security boundary against same-user code.
Copying also moves cost instead of erasing it. The cached modules tree is approximately 1.2 GB, so the per-step copy adds roughly that order of data written for every consumer. The source notes that the copy is not cancellable. The earlier 1.1-second warm measurement cannot answer whether a warm copy is faster overall, how much I/O it creates, or what happens when cancellation arrives halfway through. I would measure package-manager time, copy time, bytes written, peak I/O, cancellation latency and end-to-end accepted-step time on the same filesystem before making a speed claim.
Build credentials into the boundary
The install process also needs an explicit environment contract. Passing through the runner’s whole parent environment because “the secret redactor probably catches everything” gives package lifecycle scripts a much broader credential surface than they need. The newer code builds the environment from allowed variable names and filters values resolved as secrets. This limits what the installer receives by construction.
That is a useful reduction, but it does not make dependency installation harmless. A package install can execute lifecycle scripts. Credentials intentionally exposed to installation remain exposed to that code. Filtering should be tested against the resolved values and the actual environment passed to the child process; it should not depend on a list of familiar secret-looking variable names. The policy is about which values cross the boundary, not how reassuring their variable names look.
The cache key and install environment answer different questions. The key says which inputs produced this tree. The environment says what authority the build had while producing it. Reproducible identity does not imply a safe environment, and a restricted environment does not stop a stale or incomplete cache entry from being reused. Both controls need independent checks.
The trade is explicit now
I would describe the sequence as three designs with different costs. Installing from scratch repeated work. A shared symlink cache reduced that repeated package-manager work but allowed ordinary consumer writes to flow into shared state. Copying each cached tree to a step’s own node_modules blocks that write-through path while increasing filesystem traffic and leaving same-user direct cache access possible.
That framing matters operationally. If storage pressure is the binding limit, a large copy on every step may be worse than a slower install. If package scripts are untrusted relative to later jobs, a symlink is too permissive. If jobs share one operating-system identity, a same-user path remains within reach unless the broader runner isolation changes. There is no single “cache is secure” switch that resolves those different threats.
Before adopting the pattern, I would ask four questions. Can the cache identity be derived from immutable inputs? Can a consumer observe a partially published entry? Can one consumer change what a later consumer receives? What credentials and filesystem authority does the install process actually have? Then I would measure the copy path separately from the install path and set a retention policy that accounts for disk pressure.
The test I use is simple: mutate a consumer’s installed dependency and verify the next consumer still reads the published cache; race cold publishers and verify each reader gets a complete tree; alter one key input and prove it misses; inspect the child environment for a resolved secret; then cancel during preparation and record what work continues. Those tests turn “we have a cache” into a concrete statement about identity, publication and authority.
The 25-second and 1.1-second figures were useful for the earlier design. They do not settle the economics of the final one. The improvement here is a clearer boundary and an honest list of what still needs a benchmark—not a speed number borrowed from an architecture that changed.
How this was verified
I checked the earlier implementation and its retained review at p51-platform f21539dd088f, and the per-step copy implementation and tests at e05778edfa52fd0bd80c4127c37ee23e576b808c. The 25-second cold, 1.1-second warm, approximately 1.2 GB and concurrency figures are reported by the independent review of the earlier cache; I did not rerun that benchmark. The final copy design’s performance was not measured in this writing pass. The design remains same-user writable through direct cache-path access; this is not a process sandbox or a tamper-proof cache.
