Artifacts and caching: passing files between jobs, and the cache that never hits
Artifacts move files between jobs and out of a run (90-day default retention); cache speeds repeat runs via a restore key. The classic failure: a cache key that omits the lockfile hash so it never invalidates, or never hits across the 10 GB / 7-day eviction window.
“The cache is broken — it never hits.” Every CI run still took eight minutes reinstalling dependencies, even though the team had added actions/cache weeks ago. The logs said Cache not found for input keys on almost every run. The key was the problem: someone had written key: node-modules — a constant string. A cache entry is written under its key only if no entry with that exact key already exists; once node-modules was saved on the very first run, it became immutable, so every later run found it… but the team had also been wondering why dependency upgrades never took effect in CI. Both symptoms were the same bug seen from two sides: a key with no content hash never invalidates (so a stale cache serves forever) and, because of an unrelated typo in the path, the saved entry was empty so restores were no-ops. The fix was the canonical pattern — key: node-${{ hashFiles('**/package-lock.json') }} plus a restore-keys: prefix — which ties the cache identity to the lockfile so it invalidates exactly when dependencies change and reuses a near-match otherwise.
Artifacts vs cache: two different jobs
They look similar — both store files — but they solve opposite problems:
- Artifacts carry files out of a workflow: build outputs, test reports, coverage, logs — things a human or a later job downloads. Upload with
actions/upload-artifact, fetch withactions/download-artifact. They are the only way to pass files between jobs, since each job runs on a fresh runner. Default retention is 90 days (configurable per-upload down to 1 day or set repo-wide), after which they are deleted. - Cache speeds up future runs of the same work: dependency directories (
~/.npm,node_modules,~/.cargo), build intermediates. Useactions/cache. A cache entry is keyed and immutable once written — and it is best-effort, not guaranteed: under the eviction policy GitHub removes caches not accessed in 7 days, and once a repo’s caches exceed 10 GB total it evicts the least-recently-used entries to stay under the limit.
The mental rule: artifact = “I need this file later or for a human,” cache = “recomputing this is slow and the inputs rarely change.” Never use cache for build outputs you must not lose (it can be evicted at any time); never use artifacts as a speed cache (no key-based restore, and 90-day retention costs storage).
- uses: actions/upload-artifact@v4
with:
name: dist
path: build/
retention-days: 7 # shrink from the 90-day default for ephemeral builds
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-The cache key is the whole design
A cache key has to do two contradictory things: invalidate when inputs change and hit when they don’t. The canonical pattern resolves it with two fields:
keymust include a content hash of the inputs —hashFiles('**/package-lock.json'). When the lockfile changes, the hash changes, the key changes, and the old cache is correctly not used. A key without a hash (key: npmfrom the Hook) never changes, so it either serves a stale cache forever or — because writes are immutable — locks in whatever was saved first.restore-keysis an ordered list of prefixes tried when the exactkeymisses. On a lockfile change the exact key won’t exist yet, butrestore-keys: npm-${{ runner.os }}-matches the previous cache as a partial hit — so you start from last build’snode_modulesand only install the delta, then save under the new exact key. This is what turns a cold cache into a warm one across dependency bumps.
The save semantics catch people: actions/cache saves automatically at job end only if the exact key did not already exist (a cache hit on the exact key means no save). Immutability means you cannot overwrite an entry — to “update” a cache you must change the key. And because of the 7-day-idle and 10 GB-LRU eviction, a rarely-run workflow’s cache may simply be gone, which is correct behavior, not a bug — the cache is an optimization that must tolerate misses.
A workflow uses key: deps-${{ hashFiles('package-lock.json') }} with restore-keys: deps-. A PR bumps one dependency, changing the lockfile. What happens on that run, and what gets saved?
▸Why this works
Why are cache entries immutable instead of overwrite-on-save? Because concurrent jobs would otherwise race to write the same key and a reader could get a half-written cache, or two branches could clobber each other’s dependency state under a shared key. Immutability makes a key a stable content identity: the same key always means the same bytes, so a hit is safe to trust without revalidation. The cost is that “updating” a cache is impossible — you make a new key (which the content hash does for you automatically when inputs change) and let LRU eviction retire the old one. This is why a content hash in the key is not optional polish; it is what makes the immutable model correct instead of permanently stale.
Sizing and hygiene
Caches and artifacts both cost storage and both have limits that bite at scale. A single cache entry should be the dependency directory, not the whole workspace — caching node_modules plus ~/.npm plus the build output triples the size and pushes you toward the 10 GB LRU cliff faster, evicting other useful caches. Artifacts default to 90-day retention, which for per-PR build outputs is wasteful; drop retention-days to a week or less for ephemeral data, and reserve long retention for releases and audit logs. Compress before uploading large artifacts, and don’t upload node_modules as an artifact at all — that is what cache is for. The discipline is matching each store to its job: durable hand-off → artifact with deliberate retention; speed optimization with tolerated misses → cache with a content-hashed key.
A team caches build output (the final compiled binary) instead of uploading it as an artifact, to 'save time downloading.' Why is this the wrong store, and what breaks?
- 01Why must a cache key include a content hash, and what does restore-keys add on top of it?
- 02Contrast artifacts and cache by durability and use, and name the eviction and retention numbers.
Artifacts and cache both persist files but answer opposite needs. Artifacts are the durable channel: they carry build outputs, reports, coverage, and logs out of a workflow for a human or a downstream job, and because each job runs on a fresh runner they are the only way to move files between jobs — retained 90 days by default, which you should shrink with retention-days for ephemeral per-PR data and reserve long for releases and audit trails. Cache is a best-effort speedup for repeat work like dependency directories and build intermediates: entries are keyed and immutable once written, and they are explicitly evictable — GitHub drops caches untouched for 7 days and, past 10 GB of total repo cache, evicts least-recently-used entries, so a miss is correct behavior the design must tolerate. The cache key carries the entire correctness burden: include a content hash such as hashFiles('**/package-lock.json') so the key changes exactly when inputs change — a hashless constant key either serves a stale cache forever or, given immutability, freezes whatever was saved first — and add restore-keys prefixes so a fresh exact-key miss still recovers the previous cache as a warm partial hit, installing only the delta before saving under the new key. Match each store to its job: durable hand-off you must not lose goes in an artifact with deliberate retention; slow-to-recompute, rarely-changing inputs go in a content-hashed cache that treats misses as free. Cache the dependency directory, not the whole workspace, to stay clear of the 10 GB LRU cliff that would evict your other useful caches. Now when you see a CI run that reinstalls everything every time, check the key first — a constant string or a hash of the wrong file is almost always the cause, and the fix is one hashFiles() call away.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.