Composite Actions and Reusable Workflows Are Two Different Levels of Reuse

Once ci.yaml and deploy.yaml both need the same build steps, here's the difference between sharing a step sequence and sharing a whole job, and why one company rolled a security scan out to 150 repos in an afternoon.

September 10, 20265 min read3 / 4

Splitting ci.yaml from deploy.yaml created a real problem: both files now need the exact same build steps, checkout, setup Node, install, build, upload. Copy those five steps into both files, and every future change to the build process needs to happen twice, correctly, forever.

GitHub Actions has two different ways to share that logic, at two different levels. Confusing them is easy, and the difference matters.

Step-level reuse: a composite action

A composite action wraps a sequence of steps into one reusable unit, callable from any job.

It lives in its own directory, one level of nesting deep: .github/actions/build-app/action.yaml. That nesting isn't arbitrary, it's how GitHub tells an action apart from a workflow file.

YAML
name: Build App description: Installs, builds, and uploads the dist artifact inputs: node-version: default: '22.22.0' artifact-name: default: 'dist' runs: using: composite steps: - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - run: npm ci shell: bash - run: npm run build shell: bash - uses: actions/upload-artifact@v4 with: name: ${{ inputs.artifact-name }} path: dist/

Notice what's missing compared to a workflow: no on, no jobs, no runs-on. An action never decides when it runs or what machine it runs on, the job calling it already decided both. Instead of jobs, an action has runs, and instead of a job's steps living directly in the file, they live under using: composite.

inputs work like function parameters, with defaults you can override, or required: true if you want to force the caller to supply one. That artifact-name input exists specifically so two different pipelines using this same action can each pick a name that doesn't collide with the other.

Using it from a workflow is one line:

YAML
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/build-app with: artifact-name: dist

One caveat that trips people up: a local composite action lives inside your repo's source code, so checkout has to happen before you can call it. There's a real chicken-and-egg problem there, you can't run code from a repo you haven't checked out yet, which is exactly why checkout still appears in the calling job even though everything else moved into the action.

Job-level reuse: a reusable workflow

A composite action shares steps. A reusable workflow shares an entire job, callable from a completely different repository, not just a different file in the same one.

build.yamlreusable workflow
→
ci.yamlcalls it on PR
build.yamlsame reusable workflow
→
deploy.yamlcalls it on push

A reusable workflow looks almost identical to a normal workflow, on, jobs, the works, just triggered by workflow_call instead of push or pull_request.

YAML
# .github/workflows/build.yaml on: workflow_call: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/build-app

And calling it from ci.yaml collapses that file down to almost nothing:

YAML
# .github/workflows/ci.yaml on: pull_request: branches: [main] jobs: build: uses: ./.github/workflows/build.yaml

One naming gotcha worth knowing before it confuses you: if the reusable workflow file is named build.yaml and its job inside is also named build, the Actions UI shows it nested as "build / build," which reads like two separate jobs ran. Give the reusable workflow's job a distinct name from the file itself, and that confusion disappears.

Why this is worth doing at all

A composite action stays local, in one repo, reused across that repo's own workflows. A reusable workflow can live in one central repo and be called from every other repo an organization owns, the same job-boundary thinking behind keeping build and deploy as separate jobs in the first place: a clean boundary is what makes a piece of logic safe to share.

That's not a small difference in practice. A team running the same pipeline logic across 150 separate repositories doesn't want 150 separate copies of the same YAML, each one silently drifting further from the others every time someone updates one but forgets the rest. Centralize it once, and every repo that calls it gets the update the moment it merges, no repo-by-repo rollout required.

The real payoff shows up the day you need to change something everywhere at once. Adding a new security scan to a reusable workflow adds it to every repo that calls it, in one change. Adding it to 150 separate copy-pasted files means 150 separate pull requests, and the ones nobody gets around to reviewing stay exactly as insecure as they were yesterday.

Picking the right tool

Three mechanisms exist, matched to three different questions:

  1. A composite action, when you have a step sequence inside one job that you want to reuse. Easiest to write, stays in the same repo, good logs.
  2. A reusable workflow, when you have a whole job, or several, that needs to work identically across multiple repos.
  3. A custom JavaScript or Docker action, when the logic doesn't fit cleanly into YAML at all.

Default to the composite action first. It solves the copy-paste problem without asking you to think about cross-repo distribution before you actually need it.

The Essentials

  1. A composite action shares steps within a job. A reusable workflow shares a whole job, across repos. Different problems, different tools.
  2. An action defines runs, never on or runs-on. The calling job already decided both of those.
  3. A local composite action needs checkout to run first, since the action's own code lives in the repo being checked out.
  4. Centralizing a pipeline in one reusable workflow means one change reaches every repo that calls it, instead of a pull request per repo that half of them never get.

Further Reading and Watching