Stop reinstalling the same dependencies in GitHub Actions

Every job in a GitHub Actions workflow runs on its own fresh runner, so a lint job, a test job, and a build job that each check out and install the same dependency tree run that install three separate times. Installing once in an upstream job and handing the result to the others with actions/upload-artifact and actions/download-artifact turns three installs into one.

ci.cache.duplicate-installsstatic · checkable from the repo
How StarSling works

AI agents open the PR

StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.

Do this

GitHub Actions gives every job in a workflow its own runner, and nothing on that runner survives past the job: no shared filesystem, no in-memory state, no leftover node_modules from a sibling job. Splitting a pipeline into lint, test, and build jobs is a reasonable way to get them running in parallel, but if each one independently checks out the repo, sets up the toolchain, and runs the same install command, the dependency tree gets resolved and written to disk as many times as there are jobs. That is pure duplicate work: the second and third installs cannot find anything the first one did not already produce. Passing the installed dependencies forward as a workflow artifact, or consolidating jobs that were split without a reason for the split, removes the repeats without removing the jobs.

.github/workflows/ci.yml
name: CI
on:
  pull_request:

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - name: Archive installed dependencies
        run: tar -cf node_modules.tar node_modules
      - uses: actions/upload-artifact@v4
        with:
          name: node-modules
          path: node_modules.tar
          retention-days: 1

  lint:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: node-modules
      - run: tar -xf node_modules.tar
      - run: npm run lint

  test:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: node-modules
      - run: tar -xf node_modules.tar
      - run: npm test

  build:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: node-modules
      - run: tar -xf node_modules.tar
      - run: npm run build

Avoid this

.github/workflows/ci.yml
name: CI
on:
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run build

How to detect it

  1. List every job in a workflow and its first several steps: yq '.jobs | to_entries[] | {name: .key, steps: .value.steps[0:4]}' .github/workflows/ci.yml - jobs whose opening sequence is checkout, then a setup action, then an install command, are candidates.

  2. Compare those opening sequences across jobs in the same workflow file. Identical or near-identical sequences (same setup action, same install command, same lockfile) with no needs: artifact handoff between them are the finding.

  3. Search for the handoff actions: grep -rn 'actions/upload-artifact\|actions/download-artifact' .github/workflows/ - a workflow with repeated installs and zero matches here has no mechanism for one job to pass its install to another.

  4. Count setup-action calls per job AND per action, across every workflow file: yq '.jobs | to_entries[] | {"file": filename, "job": .key, "dupes": [.value.steps[] | .uses // "" | select(test("actions/setup-")) | sub("@.*"; "")] | group_by(.) | map(select(length > 1)) | map({"action": .[0], "count": length})} | select(.dupes | length > 0)' .github/workflows/*.yml - it names the file, the job, and the action for every genuine repeat. Grouping by job keeps the normal case of several jobs each calling setup once from reading as a duplicate; grouping by action keeps a polyglot job that legitimately calls setup-node and setup-python from reading as one; and the glob covers workflows named anything. A second actions/setup-node inside one job simply overwrites the first, which is why the repeat is the signal.

Tradeoffs and safety

  • Artifact upload and download themselves take time and count against the same per-repository storage GitHub charges for. On a small dependency tree the transfer can cost more than the install it replaces; measure both before converting a fast job.

  • node_modules built on one runner OS or architecture is not portable to another. Only hand dependencies forward between jobs that share the same runs-on target; a workflow that installs on ubuntu-latest and needs the tree on windows-latest or macos-latest needs a separate install per platform, not a shared artifact.

  • needs: install makes lint, test, and build wait on the install job instead of starting immediately, which can lengthen the critical path even though it removes duplicate work. If the jobs were fast and running fully in parallel before, compare total wall-clock time, not just work done.

  • If the jobs were only ever split so that a failure in one would not block the others, and they always run on the same runner target anyway, consolidating them into a single job that installs once and runs lint, test, and build as sequential steps removes the duplication with no artifact handoff at all.

  • Workflow artifacts are scoped to a single workflow run and are the wrong tool for reuse ACROSS runs; that is what the dependency cache (actions/cache, or a setup action's built-in cache: input) is for. Use artifacts only to move data between jobs within one run.

Archive dependencies into one file before uploading

actions/upload-artifact works on any file or directory, but a dependency tree is thousands of small files, and uploading them individually is slower than uploading one archive. Tar the directory into a single file first, upload that one file, then extract it in each downstream job. This is the same shape as passing a build output forward, just applied to the installed dependency tree instead of a compiled artifact.

Artifacts move data within a run; cache moves it across runs

It is easy to reach for actions/upload-artifact and actions/download-artifact as a general caching mechanism, but they solve a different problem than actions/cache. An artifact is scoped to the workflow run that created it and exists to hand data from one job to another job in that same run. actions/cache is scoped to the repository and exists to reuse data across separate runs (today's run restoring what yesterday's run stored). Use artifacts for the cross-job handoff this page describes, and keep the lockfile-keyed dependency cache from GitHub Actions cache: dependencies, keys, and cache hits for reuse between runs; they are complementary, not interchangeable.

Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR.

Prompt for your coding agent
Audit this repository's GitHub Actions workflows for dependency installs that are repeated across jobs, and remove the duplication.

1. For every workflow file, list each job's opening steps. Flag any workflow where two or
   more jobs independently run checkout, a setup action (setup-node, setup-python, setup-go,
   etc.), and an install command against the same lockfile, with no needs: relationship or
   artifact handoff between them.
2. Within each job, also check for a setup action called more than once (for example two actions/setup-node steps); remove the redundant call rather than restructuring the job.
3. For flagged workflows, propose ONE of two fixes, whichever fits: (a) add an upstream
   job that checks out, installs, archives the dependency directory, and uploads it with
   actions/upload-artifact, then change the downstream jobs to needs: <upstream job> and
   actions/download-artifact plus an extraction step instead of their own install; or (b) if
   the jobs were split for no reason that still holds (they always run on the same runs-on
   target and do not need independent pass/fail signals), consolidate them into one job that
   installs once and runs the remaining steps in sequence.
4. Before proposing (a), confirm every job in the handoff uses the same runs-on target; dependencies installed on one OS or architecture are not portable to another.
5. Read the GitHub Actions artifacts documentation and the actions/upload-artifact and actions/download-artifact READMEs linked on this page before writing the workflow changes.
6. Show the full diff and open a pull request rather than applying changes directly. In
   the PR body, name each workflow and job you changed, and state how to verify: re-run the
   workflow and confirm the install command runs once instead of once per job.

Confirm the change landed

  1. Re-run the workflow and check the log of each downstream job: the install command should no longer appear, replaced by an actions/download-artifact step and a fast tar -xf.

  2. Confirm the upstream install job's artifact upload succeeds and downstream jobs' download steps report a cache hit for that artifact name in the same run.

  3. Compare the sum of time spent on install-equivalent steps across all jobs before and after: it should drop from one install per job to one install total, plus the (smaller) upload and download times.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT12 / OPT14 / OPT27 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (these 3 plus 70 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1GitHub Actions: storing workflow data as artifacts (opens in new tab)

2actions/upload-artifact (opens in new tab)

3actions/download-artifact (opens in new tab)

4GitHub Actions: using jobs in a workflow (opens in new tab)

Last updated 2026-08-21