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.
- 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.
# .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 buildAny other repository (or another workflow in the same repo) calls it like this:
# .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:
# .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# consuming workflow
steps:
- uses: actions/checkout@v7
- uses: my-org/shared-workflows/.github/actions/setup-and-cache@main
with:
node-version: "22"
- run: npm testNote 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.
| Aspect | Reusable workflow | Composite action |
|---|---|---|
| Called from | The `jobs.<id>.uses` key — replaces an entire job | A `steps` entry — runs inside an existing job |
| Can define multiple jobs | Yes, each with its own runner | No — runs as steps in the caller's job/runner |
| Secrets | Explicit `secrets:` block, can require or inherit | Inherits whatever the calling job already has |
| Best for | A 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:
# .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 }} --deleteNotice 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:
# 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 testCallers 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:
- 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.
- Branch-based pre-release testing — push the change to a branch (not
main), point one low-risk consumer repo at@my-test-branchtemporarily, confirm it behaves correctly, then merge and cut a new tagged release for everyone else to adopt deliberately.
# 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:
# reusable workflow
jobs:
build:
outputs:
image-tag: ${{ steps.tag.outputs.value }}
steps:
- id: tag
run: echo "value=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"# 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:
# 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
- Referencing a shared workflow with
@mainin production-critical pipelines. A breaking change pushed tomainin the shared repo immediately affects every consumer's next run — pin to a tagged release instead, and bump deliberately. - Forgetting that composite action
runsteps need an explicitshell:— 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 ownpermissions: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
- Two or more repos with genuinely identical CI steps is the practical threshold — below that, a shared workflow adds indirection without enough payoff yet.
- Centralize anything security-sensitive first (OIDC cloud auth, secret handling) — that's where drift between repos is riskiest, not just annoying.
- 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. - 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.
Related reading
- CI/CD Pipelines Explained: From Commit to Production — shares tags: devops, git (same category).
- What Is GitOps? Principles, Workflow, and Why Teams Adopt It — shares tags: devops, git.
- Infrastructure as Code: Why Terraform Won — shares tags: devops, cloud.
- Core AWS Services Every Developer Should Know — shares tags: cloud, devops.
- Git Hooks Explained: Automate Your Workflow — shares tags: devops, git.