The fastest way to sabotage Apple Silicon CI throughput is to name caches after Git branches. Branch keys reuse graphs across commits that quietly change Package.resolved, Podfile.lock, or Gemfile.lock, producing “random” reds and even worse—phantom greens. In 2026, treat dependency caches like content-addressed storage: hash the lockfiles, pin the toolchain build number, and publish the policy so every engineer understands why a cache miss is healthy.
Operational context: disk & artifact retention, worktrees vs clean clones, and runner affinity. When you need more isolated Mac mini M4 hosts in Hong Kong, Japan, Korea, Singapore, or the United States, compare pricing and follow help for SSH/VNC access patterns.
Why lockfile hashes beat branch labels
Branch labels imply “everything on this line of development shares state.” Dependency graphs do not follow lines—they follow exact resolution outcomes. A single merge can bump a transitive version without touching application source, invalidating binaries while leaving branch names unchanged. Hashing lockfiles makes cache hits a proof obligation: if the hash matches, you have evidence the resolver output is identical modulo toolchain.
- Reproducibility: auditors can correlate a green build with a specific lockfile digest.
- Parallel PRs: two pull requests on the same branch name (fork workflows) no longer collide silently.
- Disk economics: LRU over hashes caps growth better than “delete entire branch folder.”
Matrix A: keying recipe by ecosystem
| Ecosystem | Lock inputs | Suggested path suffix |
|---|---|---|
| SwiftPM + Xcode | Package.resolved + Xcode build |
spm/<sha256(resolved)>/xc<build> |
| CocoaPods | Podfile.lock + Ruby version file |
pods/<sha256(lock)>/ruby<ver> |
| Bundler | Gemfile.lock |
bundle/<sha256(lock)> |
| npm / pnpm | package-lock.json or pnpm-lock.yaml |
js/<sha256(lock)>/node<major> |
Matrix B: when shared caches are still unsafe
Even perfect lockfile hashing cannot save you if post-install scripts execute during restore. Treat those jobs as cold path unless you sandbox script execution and verify checksums of restored artifacts.
| Signal | Policy | Mitigation |
|---|---|---|
post_install hooks mutate Pods |
Do not share restore across jobs | Run hooks every build; cache only download blobs |
| Binary pods without checksum verification | Treat as untrusted input | Vendor with content hashes signed in repo |
| Forked PR builds | Separate cache namespace | Never reuse internal monorepo cache paths |
Numeric defaults that survive audits
- Hash algorithm: SHA-256 of normalized lockfile bytes (strip CR/LF differences in CI).
- LRU width: retain last 12 hashes per ecosystem per host.
- Max age: evict untouched hashes after 14 days regardless of LRU.
- Alerting: page when cache volume exceeds 70% of dedicated CI disk.
Security note: caches are part of your supply chain. If a compromised lockfile could poison a shared tarball cache, prefer ephemeral download + verify over blind reuse.
Eight rollout steps
- Inventory which repos lack committed lockfiles—block caching until they exist.
- Emit digest as a CI artifact for every build for later correlation.
- Mount caches on fast APFS volumes, not network shares, for SPM resolution.
- Wire metrics for cache hit ratio, restore seconds, and eviction counts.
- Document cold-cache expectations in CONTRIBUTING.md.
- Test fork workflows explicitly—namespace caches by trust tier.
- Pair with checkout policy from the worktree guide to avoid double-sharing.
- Scale hardware when eviction thrashes—NodeMac adds dedicated M4 nodes per region without procurement delays.
FAQ
Why branch-named caches cause phantom greens?
They reuse binaries across lockfile changes. Hash lockfiles so cache hits imply identical resolver output.
Should SPM caches share keys with DerivedData?
No—separate concerns. SPM keys belong to resolution; DerivedData belongs to compile indices and should include job or toolchain dimensions.
How often to prune?
LRU within a cap plus max-age eviction; always tie to disk alarms.