Reusable Workflows vs Composite Actions in GitHub Actions

Photo by RICHI Manufacture via Wikimedia Commons (CC BY 4.0)
A composite action bundles a sequence of steps that plug into a job the caller still controls, and it runs on the caller's runner. A reusable workflow bundles one or more entire jobs, defines its own runner, and is called at the job level rather than as a step. In short, composite actions share steps and reusable workflows share jobs.
Not directly. Composite actions have no secrets block and cannot read the secrets context on their own. To use a secret you must pass its value in as an ordinary input from a calling step. If your shared logic depends heavily on secrets, that is a strong signal to use a reusable workflow instead.
Use secrets: inherit on the calling job instead of listing each secret. This hands over all of the calling workflow's secrets to the called workflow. It only works when the caller and the reusable workflow live in the same organization or enterprise.
Put your composite actions and reusable workflows in one central repository, often named ci-templates, and reference them from every other repo. Pin references to a tag or full commit SHA rather than a moving branch so a change cannot silently break every downstream pipeline. Publish new versions behind tags like v1 and v2 so consumers upgrade deliberately.
Every run step inside a composite action must declare its own shell, such as shell: bash. Unlike normal workflow steps, the shell is not inherited from the caller. Forgetting it is the most common reason a composite action fails to load with a confusing validation error.

Photo by RICHI Manufacture via Wikimedia Commons (CC BY 4.0)
Key Takeaway
Use a composite action to share a sequence of steps inside one job, like a checkout-and-setup preamble that runs on the caller's runner. Use a reusable workflow to share one or more whole jobs, control the runner, gate environments, and receive secrets. Composite actions plug into a job; reusable workflows are called as a job.
Every team that runs CI on more than a handful of repositories hits the same wall: the same forty lines of checkout, language setup, cache restore, lint, and test get copy-pasted into every workflow file. When the Node version bumps or a cache key changes, you edit it in twelve places and miss two. GitHub Actions gives you two tools to kill that duplication, and they are constantly confused with each other because both are described as reuse. They are not interchangeable.
The distinction is simpler than most articles make it. A composite action bundles steps. A reusable workflow bundles jobs. Once that clicks, almost every decision falls out of it automatically, including where secrets can flow and which runner your code lands on. I run CI across several small services on a self-hosted VPS, and getting this split right is what keeps my deploy logic in one file instead of scattered across every repo.
A composite action is referenced from inside a job's steps list with the same uses syntax you already use for third-party actions. It cannot define its own runner, cannot contain jobs, and its internal steps collapse into a single line in the log. It runs on whatever machine the calling job chose. A reusable workflow is the opposite: it is referenced at the job level, defines its own runner with runs-on, can contain several jobs, and each step logs in real time. That difference in altitude drives everything else.
| Dimension | Composite action | Reusable workflow |
|---|---|---|
| Unit of reuse | A sequence of steps | One or more whole jobs |
| How it is called | As a step (uses inside steps) | At job level (uses on a job) |
| Runner control | Runs on the caller's runner | Defines its own runs-on |
| Secrets | No secrets block; pass values as inputs | Native secrets plus secrets inherit |
| Logging | Collapsed into one step | Each step logged separately |
| Marketplace | Publishable to the Marketplace | Not publishable; referenced by path |
Reach for a composite action when several workflows need the same preamble but each one continues differently afterward. The classic case is checkout, set up the language runtime, and restore the dependency cache. Your test job adds test steps after it, your build job adds build steps, and your release job adds packaging steps. A composite action lets each job keep full control of what comes next while sharing the boring setup. You can also stack multiple composite actions and inline steps in the same job in any order, which reusable workflows cannot do.
# .github/actions/setup-node-app/action.yml
name: "Setup Node app"
description: "Checkout, install Node, restore cache, install deps"
inputs:
node-version:
description: "Node version to install"
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: bash # required on every run step in a composite action# a workflow in the same repo consuming the composite action
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-node-app
with:
node-version: "22"
- run: npm test # this job keeps control after setupEvery run step inside a composite action must declare its own shell (for example shell: bash). It is not inherited from the caller, and forgetting it is the single most common reason a composite action fails to load with a confusing validation error.
Reach for a reusable workflow when you want to standardize an entire pipeline, not just a preamble. The platform team owns the runner selection, the environment gates, the job graph, and the secrets contract; consuming teams just call it and pass parameters. This is the right tool when the shared logic must run on a specific runner regardless of the caller, when it needs to consume secrets, or when execution depends on if conditionals evaluated at the job level. My deploy pipeline lives here: one reusable workflow defines build, push to the registry, and SSH deploy, and every service repo calls it with a different environment input.
# .github/workflows/deploy.yml in the org/ci-templates repo
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
SSH_KEY:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
SSH_KEY: ${{ secrets.SSH_KEY }}Inputs are typed and behave the same for both: you declare them and pass them with the with keyword. Secrets are where the two diverge sharply. A reusable workflow has a first-class secrets block in its workflow_call trigger, and the caller either passes each secret explicitly or uses secrets inherit to hand over all of the calling workflow's secrets at once. Inherit only works when the caller and the called workflow are in the same organization or enterprise.
# the caller: reusable workflow is invoked as the job itself
jobs:
call-deploy:
uses: my-org/ci-templates/.github/workflows/deploy.yml@v1
with:
environment: production
secrets: inherit # hands over all caller secrets at onceComposite actions have no secrets block and cannot read the secrets context on their own. To use a secret inside one you must pass its value in as an ordinary input from a calling step. GitHub still masks any registered secret value in logs, but you lose the typed secrets contract, so treat secret-heavy logic as a signal that you want a reusable workflow, not a composite action.
For cross-repo reuse, put both artifacts in one central repository, commonly named something like ci-templates, and reference them from every other repo. Pin your references to a tag or a full commit SHA, never to a moving branch, so a change to the shared logic cannot silently break every downstream pipeline at once. Roll new versions forward with a tag like v1, v2 and let consumers upgrade deliberately. Be aware of the platform ceilings when you compose these: a single workflow can reference a limited number of reusable workflows, and nesting has a maximum depth, so keep chains shallow and flat rather than building deep towers of workflows calling workflows.
A practical hybrid works best at scale: keep small, generic setup logic as composite actions so any job can compose it, and keep whole standardized pipelines as reusable workflows. A reusable workflow can itself use your composite actions internally, giving you one place for step-level reuse and one place for job-level reuse.
They are complementary, not competing. The teams with the cleanest CI I have seen use both deliberately: composite actions for the reusable step preambles and reusable workflows for the standardized pipelines that consume them. Get the steps-versus-jobs distinction right and the rest is just wiring inputs and, carefully, secrets.