DevOpsIntermediate

GitHub Actions Reusable Workflows: Build CI/CD Templates Once, Use Everywhere

How to stop copy-pasting the same GitHub Actions YAML across repos using reusable workflows and composite actions — with real examples of both.

DevFieldGuideJuly 31, 20269 min read
Share:

The same lint-test-build-deploy YAML, copy-pasted into every repository, is one of the most common sources of CI drift on a team — one repo gets a security fix in its workflow, the other nine don't, and nobody notices until something breaks. GitHub Actions has two real mechanisms for sharing workflow logic instead: reusable workflows and composite actions.

Prerequisites
  • At least two repositories (or two jobs) with meaningfully similar CI/CD steps you want to de-duplicate.
  • Familiarity with basic GitHub Actions syntax — jobs, steps, uses:, with:.

Reusable workflows: sharing entire jobs

A reusable workflow is a normal workflow file with one addition — a workflow_call trigger — that lets other workflows invoke it directly.

yaml
# .github/workflows/reusable-node-ci.yml
name: Reusable Node CI
 
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "22"
      working-directory:
        type: string
        default: "."
    secrets:
      NPM_TOKEN:
        required: false
 
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v6
        with:
          node-version: ${{ inputs.node-version }}
          cache: "npm"
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - run: npm run lint
      - run: npm test
      - run: npm run build

Any other repository (or another workflow in the same repo) calls it like this:

yaml
# .github/workflows/ci.yml
name: CI
 
on: [push, pull_request]
 
jobs:
  ci:
    uses: my-org/shared-workflows/.github/workflows/reusable-node-ci.yml@main
    with:
      node-version: "22"
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

That's the entire pattern: one file defines the logic once, every consuming repo references it by path and ref (@main, or better, a pinned tag/SHA — the same reasoning as pinning any third-party action). Fix a bug or add a step in the shared workflow, and every repo referencing it picks up the change on their next run without touching their own files.

Composite actions: sharing steps within a job

When the shared logic is a handful of steps that need to run inside an existing job (rather than as their own job with a separate runner), a composite action is the right tool instead:

yaml
# .github/actions/setup-and-cache/action.yml
name: "Setup Node with cache"
description: "Checks out code, installs Node, and restores the dependency cache"
inputs:
  node-version:
    default: "22"
runs:
  using: "composite"
  steps:
    - uses: actions/setup-node@v6
      with:
        node-version: ${{ inputs.node-version }}
        cache: "npm"
    - run: npm ci
      shell: bash
yaml
# consuming workflow
steps:
  - uses: actions/checkout@v7
  - uses: my-org/shared-workflows/.github/actions/setup-and-cache@main
    with:
      node-version: "22"
  - run: npm test

Note the shell: bash requirement on run: steps inside a composite action — unlike a normal workflow, composite actions don't inherit a default shell, so every run step must declare one explicitly.

AspectReusable workflowComposite action
Called fromThe `jobs.<id>.uses` key — replaces an entire jobA `steps` entry — runs inside an existing job
Can define multiple jobsYes, each with its own runnerNo — runs as steps in the caller's job/runner
SecretsExplicit `secrets:` block, can require or inheritInherits whatever the calling job already has
Best forA full CI/CD pipeline shape (lint → test → build → deploy)A smaller, reusable chunk (checkout + setup + cache)

Centralizing OIDC-based cloud deploys

A reusable workflow is the natural place to centralize a cloud deployment pattern once — for example, AWS OIDC federation (the same pattern used in this site's own deploy.yml), so no repository ever needs to hold a long-lived AWS access key:

yaml
# .github/workflows/reusable-deploy-s3.yml
on:
  workflow_call:
    inputs:
      aws-region:
        type: string
        required: true
      s3-bucket:
        type: string
        required: true
    secrets:
      AWS_DEPLOY_ROLE_ARN:
        required: true
 
permissions:
  id-token: write
  contents: read
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ inputs.aws-region }}
      - run: aws s3 sync ./out s3://${{ inputs.s3-bucket }} --delete

Notice permissions: id-token: write lives in the reusable workflow itself, not just the caller — a reusable workflow needs to declare the permissions its own steps require, since GitHub Actions doesn't automatically pass elevated permissions down through a workflow_call unless the caller also grants them.

A central "shared-workflows" repository

Once an organization has more than a couple of repos, the practical pattern is a dedicated repository — often literally named shared-workflows or .github — that holds nothing but reusable workflows and composite actions, versioned like any other shared library:

shared-workflows/ .github/ workflows/ reusable-node-ci.yml reusable-deploy-s3.yml actions/ setup-and-cache/ action.yml

Consuming repos pin to a specific release tag (@v1.2.0) rather than @main, so a breaking change to the shared workflow doesn't silently break every consumer's next CI run — exactly the same versioning discipline you'd apply to a shared npm package.

Matrix builds inside a reusable workflow

A reusable workflow can define its own matrix strategy internally, so callers get multi-version/multi-platform testing without having to configure the matrix themselves each time:

yaml
# reusable-node-ci.yml
jobs:
  build-and-test:
    strategy:
      matrix:
        node-version: ["20", "22"]
        os: [ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v6
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci && npm test

Callers still invoke it exactly the same way — the matrix is an implementation detail of the shared workflow, not something every consuming repo needs to redeclare. This is a real advantage over composite actions, which can't define their own matrix at all (a composite action always runs within whatever matrix, if any, the calling job already set up).

Testing a reusable workflow before publishing changes

Because a reusable workflow can be depended on by many repositories, a broken change is higher-blast-radius than a broken change to a single repo's own CI. Two practical safeguards:

  1. A dedicated test workflow inside the shared-workflows repo itself, calling the reusable workflow against a small fixture project committed to the same repo — this catches syntax and logic errors before any consumer ever sees the change.
  2. Branch-based pre-release testing — push the change to a branch (not main), point one low-risk consumer repo at @my-test-branch temporarily, confirm it behaves correctly, then merge and cut a new tagged release for everyone else to adopt deliberately.
yaml
# shared-workflows/.github/workflows/test-reusable-node-ci.yml
on: push
jobs:
  test:
    uses: ./.github/workflows/reusable-node-ci.yml
    with:
      working-directory: "./fixtures/sample-node-app"

Passing outputs back to the caller

Reusable workflows can expose outputs from their jobs, letting a caller branch on something the shared workflow computed — a built image tag, for instance:

yaml
# reusable workflow
jobs:
  build:
    outputs:
      image-tag: ${{ steps.tag.outputs.value }}
    steps:
      - id: tag
        run: echo "value=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
yaml
# caller
jobs:
  build:
    uses: my-org/shared-workflows/.github/workflows/reusable-build.yml@v1
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ needs.build.outputs.image-tag }}"

Environment-specific inputs without duplicating the whole pipeline

A common real requirement is "the same pipeline shape, but different values per environment" (a different S3 bucket for staging vs. production, a different approval gate). Rather than duplicating the reusable workflow per environment, pass environment as an input and let the caller supply the environment-specific values:

yaml
# caller — staging
jobs:
  deploy-staging:
    uses: my-org/shared-workflows/.github/workflows/reusable-deploy-s3.yml@v1
    with:
      aws-region: us-east-1
      s3-bucket: my-app-staging
    secrets:
      AWS_DEPLOY_ROLE_ARN: ${{ secrets.STAGING_DEPLOY_ROLE_ARN }}
 
  deploy-production:
    needs: deploy-staging
    uses: my-org/shared-workflows/.github/workflows/reusable-deploy-s3.yml@v1
    with:
      aws-region: us-east-1
      s3-bucket: my-app-production
    secrets:
      AWS_DEPLOY_ROLE_ARN: ${{ secrets.PROD_DEPLOY_ROLE_ARN }}

Combined with GitHub's native environment: protection rules on the calling job (requiring manual approval before deploy-production runs), this gets a real staged-promotion pipeline out of one shared workflow definition, reused twice with different inputs — no duplicated YAML, and the approval gate lives at the caller level where it belongs, not baked into the shared logic itself.

Discoverability: documenting a shared workflow like a library

Once an organization has more than a few reusable workflows, the same problem any shared library eventually has shows up: nobody remembers what's available or what inputs it expects. Treating the shared-workflows repo's README the way you'd document a published package — every reusable workflow listed with its required/optional inputs, its required secrets, and a minimal usage example — is a small effort that pays for itself the first time someone new to the team needs to add CI to a new repo without re-deriving the pattern from scratch by reading YAML.

Common mistakes

Common mistakes
  • Referencing a shared workflow with @main in production-critical pipelines. A breaking change pushed to main in the shared repo immediately affects every consumer's next run — pin to a tagged release instead, and bump deliberately.
  • Forgetting that composite action run steps need an explicit shell: — a workflow file's default shell isn't inherited, and this fails with a confusing error rather than a clear one.
  • Putting permissions: only on the calling workflow, assuming it flows through to the called reusable workflow automatically. The reusable workflow needs its own permissions: block for whatever its steps actually require.
  • Building one enormous "do everything" reusable workflow with a dozen conditional inputs instead of several smaller, composable ones. A workflow trying to serve every repo's exact pipeline shape through flags becomes as hard to reason about as the duplication it replaced.

When it's worth doing

Best practices
  1. Two or more repos with genuinely identical CI steps is the practical threshold — below that, a shared workflow adds indirection without enough payoff yet.
  2. Centralize anything security-sensitive first (OIDC cloud auth, secret handling) — that's where drift between repos is riskiest, not just annoying.
  3. Version shared workflows with real tags, and treat breaking changes to them like a breaking change to a published package — a major version bump, not a silent edit to main.
  4. Keep each reusable workflow focused on one concern (CI, or deploy, not both) — composing several small ones is easier to reason about than one large parameterized one.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in DevOps

View all