Share setup steps across GitHub Actions jobs

The same checkout-and-toolchain preamble copied into every job, a command run twice inside one job, and a setup step that runs even when the step it feeds is skipped all pay for work a workflow never needed to redo. A composite action or reusable workflow gives every job one place to define that preamble instead of N copies to keep in sync.

ci.hygiene.duplicated-setupstatic · 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

Each of these wastes time in a different place. Identical checkout, setup-node, and install steps pasted into every job in a workflow mean the runner pays that setup cost once per job instead of once per workflow, and a change to the toolchain version has to be made in every copy or the jobs quietly drift apart. A command repeated twice inside a single job, often a rebuild step added as a defensive workaround for a stale-artifact bug, pays its cost twice on every run and usually hides a cache or incremental-build problem that never got fixed. And a setup step with no if: feeding a consumer step that does have one is the sharpest version: the job pays for the expensive part (a browser install, a large download, a toolchain fetch) on every run, including the runs where the condition means the consumer never executes and that setup was wasted outright.

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
      - run: pnpm lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
      - run: pnpm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
      - run: pnpm 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"
          cache: "pnpm"
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "pnpm"
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      # rebuild in case the previous install left stale artifacts
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "pnpm"
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      - run: pnpm build

How to detect it

  1. For duplicated setup across jobs: read every job in a workflow file and compare their leading steps. grep -B2 -A2 'uses: actions/checkout' .github/workflows/*.yml and look for the same sequence of checkout, language setup, and install steps repeated verbatim across two or more jobs.

  2. For within-job duplicates: for each job, extract its run: command lines and check for repeats. yq '.jobs[].steps[].run' .github/workflows/ci.yml | sort | uniq -d surfaces any command that appears more than once in the same job, which is the signature of a defensive rebuild step.

  3. For unconditional setup feeding a conditional consumer: walk each job's steps in order. For every step carrying an if: (especially one referencing secrets.*, env.*, a branch name, or a label), look at the setup step immediately above it in the same job. If that setup step has no if: and its only purpose is to prepare for the conditional step below, it runs unconditionally while its consumer does not.

  4. Confirm a candidate composite action or reusable workflow does not already exist under .github/actions/ or .github/workflows/ before proposing a new one; extending an existing shared step beats adding a second one.

Tradeoffs and safety

  • A composite action lives in the same repository and runs on the same runner as the caller, so it cannot cross a needs: boundary or share state between jobs; that is what artifact upload/download or a reusable workflow's outputs are for, not this fix.

  • A reusable workflow (workflow_call) is the right tool instead of a composite action when the duplication is whole JOBS across workflows, not just leading steps within one workflow; it adds its own indirection, so reach for it only once a composite action stops being enough.

  • Consolidating three near-identical setup blocks into one composite action is only safe if they are actually identical in intent. If one job's install pins different flags or a different Node version on purpose, extracting a shared action either needs an input for that difference or should stay unmerged.

  • When pushing an if: up from a consumer step onto its setup step, copy the condition exactly rather than a simplified version. A setup step gated on the wrong subset of the consumer's condition either still runs when it shouldn't, or skips when the consumer needed it.

  • A within-job duplicate rebuild step is sometimes there because the real bug (a stale cache key, a build tool that does not support incremental builds) has not been fixed. Deleting the duplicate without fixing the underlying cause can reintroduce the flake it was worked around for.

Extract shared setup into a composite action

A composite action packages a sequence of steps behind one uses: line, defined once in the repository and referenced by every job that needs it. Unlike a bash function or a YAML anchor, it is a first-class action: it can declare inputs for the parts that legitimately vary (a Node version, an install flag) and it runs in the calling job, so it has no problem sharing that job's environment. Checkout stays in the calling job, above the uses: line: a local action is a file in the repository, so the runner can only load ./.github/actions/setup-project once the checkout that fetches it has run. Put the action at .github/actions/setup-project/action.yml:

.github/actions/setup-project/action.yml
name: Setup project
description: Node, pnpm, and install - shared by lint, test, and build
inputs:
  node-version:
    description: Node.js version to install
    required: false
    default: "20"
runs:
  using: composite
  steps:
    - uses: pnpm/action-setup@v4
      with:
        version: 9
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: pnpm
    - run: pnpm install --frozen-lockfile
      shell: bash

Push the if: up onto the setup step, not just the step that consumes it

The sharpest version of this pattern is not duplication, it is a setup step with no condition at all feeding a step that has one. A browser install, an apt package, or a toolchain fetch runs on every job execution, but the step that actually uses it only runs when a secret is present, a label is set, or the branch matches. The fix is not to touch the consumer, it already has the right condition. It is to copy that same condition onto the setup step above it, so the expensive part is skipped on exactly the runs where it would otherwise go unused:

.github/workflows/ci.yml
# Before: setup runs on every PR, including forks with no Clerk secrets
- name: Install Playwright Chromium
  run: bunx playwright install --with-deps chromium

- name: Web smoke e2e
  if: env.CLERK_SECRET_KEY != '' && env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY != ''
  run: bunx playwright test smoke

# After: the same condition gates the setup step too
- name: Install Playwright Chromium
  if: env.CLERK_SECRET_KEY != '' && env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY != ''
  run: bunx playwright install --with-deps chromium

- name: Web smoke e2e
  if: env.CLERK_SECRET_KEY != '' && env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY != ''
  run: bunx playwright test smoke

Remove the duplicate command inside a single job

This one does not need extraction or a condition, it needs deletion, plus a look at why it was added. The usual shape is a build or install step, then later in the same job a second invocation of the identical command right before the step that needed fresh output, commented as a rebuild for stale artifacts. Removing the second invocation is safe once the actual staleness cause is understood: a cache key that does not include everything the build reads, or a build tool invoked in a way that does not support incremental output. Fix that cause first, then delete the duplicate; deleting it without understanding why it was added risks bringing back whatever flake it was covering for.

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 duplicated setup and fix what you find.

1. For every workflow file, compare the leading steps of each job. If two or more jobs share
   an identical sequence of checkout, toolchain setup, and install steps, note the job names
   and the exact steps that repeat.
2. For each job individually, extract its run: commands and check for exact repeats within
   that same job. Flag any duplicate, especially one that looks like a defensive rebuild
   (a comment mentioning "stale", "just in case", or "rebuild").
3. Walk each job's steps in order. For every step with an if: condition, check whether the
   step immediately above it (with no if:) exists only to prepare for that conditional step.
   If so, note it as a setup step that runs unconditionally for a consumer that does not.
4. Read the composite action and reusable workflow docs linked on this page before choosing
   which fix applies: a composite action for shared steps within one workflow's jobs, a
   reusable workflow for shared whole jobs across workflows, and a copied if: condition for
   the unconditional-setup case.
5. For the composite action fix, create .github/actions/<name>/action.yml with the shared
   steps, replace each job's duplicated steps with a single "uses: ./.github/actions/<name>"
   step, and verify no job's install flags or tool versions were silently merged away.
6. Show the full diff and open a pull request; do not apply changes blindly. In the PR body,
   list each finding with file, job, and line, and state how to verify: re-run the workflow
   and confirm the same steps execute (or correctly skip) as before.

Confirm the change landed

  1. After extracting a composite action, diff the resolved steps: run act -n or re-run the workflow and confirm each job's log shows the same setup and install steps it had before, now nested under the composite action's name, with checkout still running as the job's own first step.

  2. Confirm the composite action is versioned with the rest of the repository (referenced as ./.github/actions/<name>, not a separate repo needing its own release) so a change to setup takes effect on the next run without a version bump.

  3. For the within-job fix, confirm the command that used to run twice now runs once, and that the job still passes on a rerun; if removing the duplicate was masking a real staleness bug, this is where it resurfaces.

  4. For the unconditional-setup fix, force the consumer's condition to false (a workflow_dispatch input, or a branch without the required secret) and confirm the setup step now shows as skipped in the run log rather than executing.

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 / OPT16 / OPT31 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: Creating a composite action (opens in new tab)

2GitHub Actions: Reuse workflows (opens in new tab)

3GitHub Actions: Metadata syntax reference (opens in new tab)

4GitHub Actions: Evaluate expressions in workflows and actions (opens in new tab)

Last updated 2026-08-21