GitHub Actions Reusable Workflows & Composite Actions (2026): Stop Copy-Pasting YAML Across Repos
> ## TL;DR > > - Copy-pasted workflow YAML is a maintenance debt with a blast radius: fix a bug in one repo's CI and the other nine quietly keep the bug. > - Reusable workflows (on: workflow_call) share whole jobs β build, test, deploy pipelines β across repos, with typed inputs, explicit secrets and their own outputs. Callers use them with a single uses: line at job level. > - Composite actions bundle *steps* into one action (action.yml with runs.using: composite). Use them for repeated step sequences inside jobs, not for whole pipelines. > - Rule of thumb: if it needs its own job, runner, matrix or secrets contract β reusable workflow. If it's five steps you keep re-typing β composite action. > - Pin what you consume: reference shared workflows/actions by tag or full SHA, and let Dependabot propose the bumps, so "shared" never means "surprise".
My own projects are small: code on GitHub, deployed by Vercel on push to main, one workflow file doing its one job. Reusable workflows are not for that project. They're for the moment every engineer eventually reaches β the third, fifth, tenth repository β where the same CI YAML has been copied so many times that nobody remembers which copy is current, and a fix applied in one place silently doesn't exist in the others.
I learned the general shape of this problem from Jenkins years earlier: every capability arrived as a plugin, and every plugin became something I owned β versions to track, conflicts to resolve, maintenance that compounded. Copy-pasted YAML is the same debt in a different costume. Each copy feels free; the fleet of copies is what you pay for. GitHub Actions gives you two proper abstraction tools β reusable workflows and composite actions β and most of the confusion in this space is just using one where the other belongs.
The problem: drift you can't see
A typical path: repo one gets a good CI workflow. Repo two copies it, tweaks the Node version. Repo three copies repo two's version, adds a deploy step. Six months later a security fix lands β say, an action pin bump or a token-permission tightening β and someone diligently applies it to the repos they remember. The fleet now runs four variants of "the same" pipeline, differing in ways nobody chose.
The fix isn't discipline ("remember to update all copies") β discipline doesn't scale across repositories any better than it did across Jenkins plugins. The fix is removing the copies: define the pipeline once, call it from everywhere.
Reusable workflows: share whole jobs
A reusable workflow is a normal workflow file with one difference: its trigger is workflow_call instead of (or in addition to) push/pull_request. That trigger declares a contract β typed inputs, secrets it expects, outputs it returns:
# .github/workflows/ci-node.yml (the reusable workflow)
name: Node CI (reusable)
on:
workflow_call:
inputs:
node-version:
type: string
required: false
default: "20"
run-deploy:
type: boolean
required: false
default: false
secrets:
NPM_TOKEN:
required: false
outputs:
artifact-name:
description: "Name of the built artifact"
value: ${{ jobs.build.outputs.artifact_name }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact_name: ${{ steps.pack.outputs.name }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test
- id: pack
run: echo "name=app-${{ github.sha }}" >> "$GITHUB_OUTPUT"Any repository (with access) calls it at job level with uses:, passing inputs and secrets explicitly:
# .github/workflows/ci.yml (in each application repo)
name: CI
on: [push, pull_request]
jobs:
ci:
uses: my-org/shared-workflows/.github/workflows/ci-node.yml@v1
with:
node-version: "20"
run-deploy: true
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}Things worth knowing about the contract:
- Inputs are typed (string, number, boolean) with defaults β callers get validation instead of silent surprises.
- Secrets are explicit by design. The called workflow can only use secrets the caller passes (individually, as above) or all of them via
secrets: inherit. Inherit is convenient; explicit is honest about what a shared pipeline can touch. For anything security-sensitive, pass explicitly. - Outputs flow back. Job outputs in the reusable workflow are exposed as workflow outputs, so callers can chain decisions on them.
- Callers can use a matrix to invoke the same reusable workflow across versions/environments, and reusable workflows can themselves call other reusable workflows (within documented limits) β a shared deploy workflow can build on a shared build workflow.
- The token rule: the
GITHUB_TOKENpermissions available inside the called workflow are limited by what the calling job grants β a reusable workflow cannot escalate beyond its caller. Declarepermissions:on the calling job deliberately.
Composite actions: share steps
A composite action packages a *sequence of steps* as a single action. It's a folder with an action.yml:
# .github/actions/setup-app/action.yml
name: "Setup app"
description: "Checkout + runtime + cached dependencies"
inputs:
node-version:
required: false
default: "20"
runs:
using: "composite"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
shell: bashJobs then consume it at step level, like any other action:
steps:
- uses: ./.github/actions/setup-app # same repo
- run: npm testKey properties: composite steps run inside the caller's job on the caller's runner (no new job, no fresh environment), every run step needs an explicit shell:, and a composite action has no direct access to the secrets context β credentials a step needs must be passed in as inputs (or env) by the caller, which keeps the secret flow visible in the calling workflow where reviewers can see it.
Which one when
| Question | Reusable workflow | Composite action |
|---|---|---|
| Unit shared | Whole job(s) | A sequence of steps |
| Invoked from | Job level (uses: on a job) | Step level (uses: on a step) |
| Runner | Gets its own runner per job | Caller's runner, mid-job |
| Secrets | Declared contract / inherit | Passed in as inputs/env by caller |
| Matrix support | Yes, callers can fan out | No (it's steps within one job) |
| Typical use | "Our standard CI/deploy pipeline" | "Our standard setup/bootstrap steps" |
The deciding question is granularity of the repetition. If five repositories run the same *pipeline*, that's a reusable workflow. If five jobs (in one or many workflows) run the same eight *setup steps* before doing different work, that's a composite action. They also compose: a reusable workflow's jobs can use composite actions internally, which is usually where a mature setup ends up.
Versioning, pinning and trust
Sharing creates a supply chain, so treat it like one:
- Reference by tag or SHA. Callers pin
ci-node.yml@v1(moving major tag) or a full commit SHA (immutable). SHA pinning is the strictest β the same advice as for third-party actions β with Dependabot configured to propose updates so pins don't fossilise. - Version the shared repo like a product. Breaking input changes get a new major tag; old tags keep working so application repos migrate on their own schedule, not in a flag-day panic.
- Keep the shared repo's permissions tight. Whoever can write your shared workflows can change every pipeline that calls them. That repository deserves the review standards of production code β because it is production code, for your delivery system.
Common mistakes
- Making one mega-workflow with a flag for everything. Twenty boolean inputs means nobody has "a pipeline"; they have a programming language made of YAML. Prefer a few focused workflows (build-test, deploy, release) over one universal one.
- Reaching for
secrets: inheriteverywhere. It's the right shortcut in a trusted org repo and the wrong default anywhere else. Explicit passing documents what the shared pipeline can access. - Putting step bundles in reusable workflows (or pipelines in composite actions). The granularity mismatch shows up fast: composite actions can't give you separate jobs or fresh runners; reusable workflows are clumsy for "just these three steps mid-job".
- Forgetting the token is the caller's. A reusable workflow that needs
packages: writefails mysteriously until the calling job grants it. Declare permissions on the caller and document what the workflow requires in its README. - No ownership of the shared repo. Shared CI with no maintainer becomes the new copy-paste: repos pin an ancient tag forever and fork the file locally "temporarily". Someone owns it, versions it, and answers issues β or it decays.
FAQ
Can a reusable workflow live in the same repository as its callers? Yes β reference it by local path (uses: ./.github/workflows/ci-node.yml). Same-repo reuse is a fine starting point; a dedicated shared repo pays off once other repositories need the same pipelines.
Do reusable workflows cost extra minutes? They run as normal jobs on normal runners, billed the same as if the YAML lived in the caller. The caller workflow itself consumes negligible time waiting.
Can composite actions call reusable workflows, or vice versa? Composite actions contain steps, so they can use other actions (including other composite actions) but not workflows. Reusable workflows contain jobs whose steps can use composite actions freely. The composition flows one way: workflows β actions.
How do I debug a failing reusable workflow? The run appears in the caller workflow's run page with the called jobs expanded β logs, annotations and all. One more reason to give the reusable workflow clear job/step names: you're debugging it from someone else's repo page.
Conclusion
The lesson Jenkins taught me was that capabilities you bolt on one by one become a maintenance fleet. Copy-pasted CI YAML is the same fleet, self-inflicted. Reusable workflows and composite actions are how you pay the abstraction cost once, deliberately: pipelines shared at job level with honest input/secret contracts, step bundles shared at step level, both pinned, versioned and owned.
If your repos are starting to drift, the first move is small: take the pipeline you trust most, convert it to workflow_call, and point one other repository at it. The second repository is where the value shows up β and where you'll find out whether your "standard" pipeline actually was one.
Related reading on this site:
- How to Build a CI/CD Pipeline with GitHub Actions (2026) β the single-repo pipeline this post teaches you to share
- GitHub Actions vs GitLab CI vs Jenkins (2026) β choosing the platform whose YAML you're standardising
- DevSecOps Checklist 2026 β the security gates your shared pipeline should bake in once, for everyone

