This episode discusses how to share build results between jobs using artifacts and speed up pipelines with dependency caching, from artifact upload and download to cache keys and restore keys.

In episode 6 we learned that jobs run in parallel on separate runners — there's no shared filesystem. In episode 7 we multiplied jobs with a matrix. The next question is sure to come up: if so, how does the deploy job get the build output files from the build job? And why does every push make npm ci download all dependencies from scratch, when it was the same yesterday?
These two problems are solved by two different mechanisms that are often confused: artifacts for moving data between jobs, and caching for speeding up dependency installation across runs. In this episode we'll dissect both, when to use them, and complete workflow examples.
Every job starts with a clean runner — its own checked-out code, no leftovers from other jobs. The needs context from episode 6 can indeed pass data, but only short strings (like job outputs). When what needs to be moved is files: a JavaScript bundle, binaries, installer files, or coverage reports — artifacts are the answer.
The analogy: needs is like writing a note on a scrap of paper for a colleague; artifacts are like shipping a box full of goods via courier. Both are legitimate, but they're different in kind.
To hand over files from a job, use actions/upload-artifact@v4. It packages a directory or file into a single named (name) bundle stored by GitHub:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: bundle
path: dist/
retention-days: 7name is the bundle identity, path determines which file or directory is packaged, and retention-days sets how many days GitHub keeps it before discarding (default 90 days). Artifacts can also be downloaded manually via the "Actions" tab UI — useful for grabbing installer files without waiting for a deploy.
In another job, the bundle is retrieved with actions/download-artifact@v4:
jobs:
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/download-artifact@v4
with:
name: bundle
path: dist/
- run: ./deploy.shThe deploy job waits for build (via needs), then copies the contents of the bundle into the dist/ directory on its own runner. Now files built on one runner can be consumed on another.
Combining both produces an honest build-then-deploy pipeline — deploy always uses exactly the same build output files:
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: bundle
path: dist/
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/download-artifact@v4
with:
name: bundle
path: dist/
- run: ./deploy.shNote
Artifacts cannot be shared across workflow runs — a bundle is created and used within the same run, then stored for a few days for manual debugging purposes. If you need to keep files for a long time (for example release binaries), use GitHub Releases, not artifacts.
The second problem is speed. npm ci downloads and installs the entire node_modules every time the pipeline runs — even though its contents barely change between pushes. Cache stores directories that are expensive to recreate (dependencies, toolchains, build layers) and restores them on the next run.
A cache is locked by a cache key. If the key matches, the cache contents are restored; if not, the job still runs but the new result is stored as a new cache. This distinguishes caches from artifacts: caches are per-repo and persistent across runs; artifacts are per-run.
The easiest way — without writing anything more than a single line — is using the built-in cache feature of setup actions. Actions like setup-node, setup-python, and setup-go have a cache option that automatically stores and restores dependencies according to their ecosystem:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ciWith cache: npm, setup-node itself places the cache at ~/.npm and determines the key based on the lockfile. If your lockfile isn't at the root, point to its location with cache-dependency-path. Python and Go follow the same pattern: cache: pip and cache: go.
For full control, there's actions/cache@v4. The key is a combination of several factors — the runner operating system and a hash of the lockfile contents:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-How to read it? runner.os separates caches per operating system (Linux, Windows, macOS) so they don't overwrite each other. hashFiles produces a hash of the package-lock.json contents — the key only changes when dependencies actually change. Meanwhile restore-keys is the fallback: when an exact key isn't found, GitHub looks for the newest key with that prefix, e.g., Linux-npm- — a partial cache is better than no cache at all.
Tip
By default, caches can only be read from the same branch (and the default branch). restore-keys becomes an important bridge: it lets pull requests from feature branches restore the default branch's cache. Store the ~/.npm directory (not node_modules) because npm ci deletes node_modules when it starts.
| Aspect | Artifact | Cache |
|---|---|---|
| Purpose | Move files between jobs | Reuse expensive build/install results |
| Lifetime | Per run, stored 90 days (default) | Persistent across runs, can expire |
| Contents | Build outputs, binaries, reports | Dependencies, toolchains, build layers |
| Manually downloadable | Yes, via UI | No |
| Cost | Small, per run | Separate storage quota |
| Mistake | Symptom | Solution |
|---|---|---|
Caching node_modules for npm ci | Cache always misses, useless | Cache ~/.npm only |
Cache key without hashFiles | Old cache always used | Include the lockfile hash in the key |
Downloading artifacts without needs | Race condition, files don't exist yet | Add needs to the build job |
retention-days too short | Artifact gone before audit | Adjust to team policy |
| Mistaking cache for artifacts | "Cache" files downloaded manually | Use upload/download artifacts |
Artifacts and caching are two sides of multi-job workflow optimization:
upload-artifact@v4 → download-artifact@v4) with retention-days configuration.cache: npm, cache: pip, cache: go) provide automatic caching; actions/cache@v4 gives full control via key and restore-keys.runner.os + hashFiles keeps caches precise and safe per platform.In the next episode 9, we enter the security phase: Secret Management & Security Best Practices — how to store passwords and API keys safely, prevent script injection, and apply least privilege to GITHUB_TOKEN. Because a fast pipeline is meaningless if it's easy to break into!