---
title: "Stop Duplicate Dependency Installs in CI | StarSling"
description: "Every GitHub Actions job gets a fresh runner. Install dependencies once and hand them to downstream jobs with upload-artifact and download-artifact."
url: https://starsling.dev/github-actions/optimizations/avoid-duplicate-dependency-installs
canonicalUrl: https://starsling.dev/github-actions/optimizations/avoid-duplicate-dependency-installs
---

# Stop reinstalling the same dependencies in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Stop reinstalling the same dependencies in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.cache.duplicate-installs. Detection mode: static. Last updated: 2026-08-21

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.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Archive dependencies into one file before uploading](#archive-before-upload)
- [Artifacts move data within a run; cache moves it across runs](#artifacts-are-not-cache)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## 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_

```yaml
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
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/ci.yml_

```yaml
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
```

<a id="how-to-detect-it"></a>

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

<a id="tradeoffs"></a>

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

<a id="archive-before-upload"></a>

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

<a id="artifacts-are-not-cache"></a>

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

<a id="verify"></a>

## 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:

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

<a id="related-pages"></a>

## Related pages

- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Share setup steps across GitHub Actions jobs](https://starsling.dev/github-actions/optimizations/avoid-duplicated-setup)
- [Optimize npm installs in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-npm-install)

<a id="sources"></a>

## Sources

- [GitHub Actions: storing workflow data as artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts)
- [actions/upload-artifact](https://github.com/actions/upload-artifact)
- [actions/download-artifact](https://github.com/actions/download-artifact)
- [GitHub Actions: using jobs in a workflow](https://docs.github.com/en/actions/using-jobs/using-jobs-in-a-workflow)
