Caching and Artifacts

Caching: skip redoing work that hasn't changed

steps:
  - uses: actions/checkout@v4
  - uses: actions/cache@v4
    with:
      path: ~/.npm
      key: npm-${{ hashFiles('package-lock.json') }}
  - run: npm ci
graph TD run["Workflow run starts"] --> check{"Cache key matches\na previous run's key?"} check -->|"hit — lockfile unchanged"| restore["Restore cached ~/.npm,\nnpm ci reuses it, much faster"] check -->|"miss — lockfile changed\nor first run"| fresh["npm ci downloads everything fresh,\nthen saves a NEW cache under the new key"]

The key is what determines a hit or miss — keying on hashFiles('package-lock.json') means the cache is reused exactly when the lockfile hasn't changed (dependencies are identical), and a new cache entry is created under a new key the moment the lockfile does change. This is the same underlying principle as Docker's layer caching — cache reuse driven by whether the input to a step actually changed, not by how much time has passed or how many runs have happened since.

Artifacts: passing files between jobs

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - run: ./deploy.sh dist/
graph LR buildjob["Job: build (own VM)"] -->|"upload-artifact"| storage["GitHub-managed<br/>artifact storage"] storage -->|"download-artifact"| deployjob["Job: deploy (different VM)"]

Given that jobs run in genuinely separate machines (per Fundamentals), this upload/download round trip through GitHub's artifact storage is the only way for deploy to see build's output — there's no shared disk to fall back on. Artifacts are also how build outputs survive to be inspected later (downloaded from the workflow run's UI) even after the runner VM that produced them is gone.

Cache vs. artifact — not interchangeable

Caching is for speeding up repeated work across separate workflow runs (dependencies that rarely change, re-fetched otherwise) — it's explicitly best-effort; a cache miss just means falling back to doing the work fresh, never a hard failure. Artifacts are for passing specific, expected files between jobs within one run — losing an artifact (say, download-artifact finds nothing because the name doesn't match) is a real failure, not a fallback-to-slow-path situation, because the deploying job has no other way to get those files at all.

Common pitfall

Using a cache key that's too broad (e.g. a fixed string with no content hash at all) means the cache never invalidates when dependencies actually change — a workflow can end up testing against genuinely stale dependencies while believing it fetched the current lockfile's versions, because the cache hit served old content that was never supposed to still match. Keying on a hash of the actual lockfile (as shown above) ties cache validity directly to the input that should invalidate it, rather than to an unrelated proxy like a date or a manually bumped version number that's easy to forget to update.