Learn GitHub Actions - Artifacts & Caching Management
Episode 8 of 21

Learn GitHub Actions - Artifacts & Caching Management

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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.

Main Discussion

The Problem: Jobs Don't Share a Filesystem

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.

Uploading Artifacts with upload-artifact

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:

Upload build artifact
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: 7

name 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.

Downloading Artifacts in a Later Job

In another job, the bundle is retrieved with actions/download-artifact@v4:

Download artifact in another job
jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: bundle
          path: dist/
      - run: ./deploy.sh

The 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.

Complete Build and Deploy Workflow

Combining both produces an honest build-then-deploy pipeline — deploy always uses exactly the same build output files:

Build upload, deploy download
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.sh

Note

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.

Dependency Caching

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.

Automatic Caching in Setup Actions

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:

Automatic npm cache in setup-node
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
- run: npm ci

With 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.

Cache Key and Restore Key

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:

Manual npm dependency cache
- 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.

Artifact vs Cache Differences

AspectArtifactCache
PurposeMove files between jobsReuse expensive build/install results
LifetimePer run, stored 90 days (default)Persistent across runs, can expire
ContentsBuild outputs, binaries, reportsDependencies, toolchains, build layers
Manually downloadableYes, via UINo
CostSmall, per runSeparate storage quota

Common Mistakes

MistakeSymptomSolution
Caching node_modules for npm ciCache always misses, uselessCache ~/.npm only
Cache key without hashFilesOld cache always usedInclude the lockfile hash in the key
Downloading artifacts without needsRace condition, files don't exist yetAdd needs to the build job
retention-days too shortArtifact gone before auditAdjust to team policy
Mistaking cache for artifacts"Cache" files downloaded manuallyUse upload/download artifacts

Conclusion

Artifacts and caching are two sides of multi-job workflow optimization:

  • Artifacts move files between jobs (upload-artifact@v4download-artifact@v4) with retention-days configuration.
  • Caching speeds up subsequent runs by storing expensive-to-recreate dependencies.
  • Setup actions (cache: npm, cache: pip, cache: go) provide automatic caching; actions/cache@v4 gives full control via key and restore-keys.
  • The key pattern 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!