Reusable workflows vs composite actions in GitHub Actions
When to share GitHub Actions config as a reusable workflow or a composite action, covering secrets, runners, logs, nesting limits and versioning.
Hayssem Elsayed
Updated 14 min read
GitHub Actions has two ways to stop copying the same YAML between workflows. A reusable workflow packages whole jobs: the caller hands over a job to it, and the called workflow decides the runner, the steps and the job graph. A composite action packages steps: it runs as one step inside a job the caller already owns, on the caller's runner and in the caller's workspace.
Most of the differences below follow from that. If you're sharing a pipeline, use a reusable workflow. If you're sharing a sequence of steps that different jobs need, use a composite action. The edge cases come down to secrets, runners, logs, limits and versioning, covered below. The YAML examples pass actionlint.
The differences at a glance
| Reusable workflow | Composite action | |
|---|---|---|
| Defined in | .github/workflows/<file>.yml, with on: workflow_call | action.yml in any directory, with runs.using: composite |
| Called from | A job: jobs.<job_id>.uses | A step: steps[*].uses |
| Contains | One or more jobs | Steps only |
| Runner | Each job sets its own runs-on | The calling job's runner |
| Secrets | secrets block, or secrets: inherit | No secrets context; pass values as inputs |
Workflow-level env | Not passed from the caller | Inherited from the calling job |
| Logs | Every job and step logged separately | Logged as one step |
| Nesting | Up to 10 levels of workflows, 50 unique reusable workflows per file | Up to 10 composite actions |
| Marketplace | Can't be published | Can be published |
The rows come from GitHub's comparison of reusable workflows and composite actions and the reusable workflow reference.
Reusable workflows
A reusable workflow is an ordinary workflow file whose triggers include workflow_call. It declares the inputs, secrets and outputs its callers can use:
# my-org/shared-workflows/.github/workflows/node-ci.yml
name: Node CI
on:
workflow_call:
inputs:
node-version:
description: Node.js version to test with
type: string
default: "22"
runner:
description: runs-on label for the job
type: string
default: ubuntu-latest
secrets:
npm-token:
description: Token for the private npm registry
required: true
outputs:
version:
description: Version from package.json
value: ${{ jobs.build-and-test.outputs.version }}
permissions:
contents: read
jobs:
build-and-test:
runs-on: ${{ inputs.runner }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
registry-url: https://registry.npmjs.org
cache: npm
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
- run: npm test
- id: version
run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT"
registry-url matters here: setup-node only writes an .npmrc that reads NODE_AUTH_TOKEN when it's set. Outputs take two hops. A step output is mapped to a job output, and the job output is mapped to a workflow output under on.workflow_call.outputs.
The caller references the file in a job's uses, not in a step:
# .github/workflows/pr.yml in the service repo
name: PR checks
on: pull_request
jobs:
ci:
uses: my-org/shared-workflows/.github/workflows/node-ci.yml@v2
with:
node-version: "22"
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}
report:
needs: ci
runs-on: ubuntu-latest
steps:
- run: echo "Tested version $VERSION"
env:
VERSION: ${{ needs.ci.outputs.version }}
A job that calls a reusable workflow can only use a short list of keys: name, uses, with, secrets, strategy, needs, if, concurrency, permissions and cache-mode. It can't have runs-on, steps, env or environment, so you can't add a step before or after the call in the same job. Put that work in another job and connect them with needs, as report does above. The uses value also can't contain expressions, so the file and ref are fixed when the workflow is written.
Input types are checked: each input is a string, number or boolean, and the value passed in with has to match.
Composite actions
A composite action is an action.yml with runs.using: composite and a list of steps. It can live in its own repository or in any directory of the repository that uses it:
# .github/actions/setup-node-project/action.yml
name: Set up Node project
description: Install Node.js and the project's npm dependencies
inputs:
node-version:
description: Node.js version to install
default: "22"
npm-token:
description: Token for the private npm registry
required: true
outputs:
cache-hit:
description: Whether the npm cache was restored
value: ${{ steps.setup.outputs.cache-hit }}
runs:
using: composite
steps:
- id: setup
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
registry-url: https://registry.npmjs.org
cache: npm
- run: npm ci
shell: bash
env:
NODE_AUTH_TOKEN: ${{ inputs.npm-token }}
The job that uses it checks out the repository first, then calls the action like any other step and keeps adding steps after it:
# .github/workflows/pr.yml
name: PR checks
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- id: setup
uses: ./.github/actions/setup-node-project
with:
npm-token: ${{ secrets.NPM_TOKEN }}
- run: npm test
- run: npm run lint
The action's steps run in the same job, so files written by npm ci are there for npm test. A composite action's outputs are mapped from its steps with value, and the caller reads them as steps.setup.outputs.cache-hit. Composite inputs are always strings.
Mistakes that break a composite action
The runner checks action.yml against a strict schema, and actionlint doesn't catch all of it. These four mistakes fail the step:
- A
runstep withoutshell. In a workflow,runsteps fall back to a default shell. In a composite action,shellis required on everyrunstep. - A
usesstep withshell, which is the reverse mistake. Ausesstep acceptsname,id,if,uses,with,envandcontinue-on-error, and nothing else. - A local action called before checkout.
uses: ./.github/actions/...resolves against the runner's workspace, which is empty untilactions/checkoutruns. The runner stops with "Did you forget to run actions/checkout before running your local action?" Checking out inside the composite action doesn't help, because the runner has to findaction.ymlbefore any of its steps run. secretsorvarsread inside the action. Neither context exists inaction.yml, so pass the values in as inputs.
Secrets, variables and the token
Reusable workflows have a dedicated secrets block. The called workflow declares the secrets it needs and the caller maps them by name. A caller in the same organization or enterprise can pass secrets: inherit to forward all of its secrets instead. Secrets are only passed one level down: in a chain A → B → C, workflow C sees a secret from A only if B passes it on explicitly.
Environment secrets are the exception. on.workflow_call has no environment key, so a caller can't pass them. Instead, a job in the called workflow sets environment: and gets that environment's protection rules, required reviewers and secrets, which makes a reusable workflow the natural place for a gated production deploy. GitHub's docs say setting environment: is enough, but in practice the environment's secrets resolve to empty strings unless the caller also passes secrets: inherit (actions/runner#4453, open at the time of writing). Nothing fails, so check that the secret isn't empty before a deploy step uses it.
Composite actions have no secrets context. The caller passes a secret as an input, as npm-token does above. The value is still masked in logs because it came from a secret, but nothing in action.yml marks the input as sensitive. Document which inputs expect a secret, and never echo them.
Plain environment variables don't cross into a reusable workflow at all. Workflow-level env in the caller is not passed to a reusable workflow, and env set in the called workflow isn't visible to the caller; use inputs and outputs. For values that many workflows need, set configuration variables at the organization or repository level and read them with vars. A composite action runs inside the calling job, so it inherits that job's environment, and echo "NAME=value" >> "$GITHUB_ENV" in one of its steps sets a variable for the rest of the job.
For GITHUB_TOKEN, a called workflow gets github.token and secrets.GITHUB_TOKEN automatically. Its permissions can stay the same or be reduced down the chain, never raised: if the caller grants packages: read, the called workflow can't ask for packages: write. Set permissions on the calling job to what the called workflow needs. A composite action uses the calling job's token, available as github.token.
The github context inside a reusable workflow belongs to the caller. github.workflow is the caller's workflow name, and actions/checkout checks out the caller's repository, not the one the reusable workflow lives in. To know which file and commit defined the running job, use the job.workflow_ref and job.workflow_sha context properties.
Runners
Each job in a reusable workflow sets its own runs-on. The caller can't override it unless the workflow exposes it as an input, as the runner input above does. A composite action has no say in the runner; it runs wherever the calling job runs. If some steps need a bigger machine or a different OS than the rest, they need their own job, which a composite action can't create.
Runner access follows the caller. GitHub-hosted runners are assigned and billed in the caller's context. A called workflow can use the caller's self-hosted runners only if both are owned by the same user or organization.
That makes a shared workflow a handy place to set runner sizes for a whole organization. With Tenki Runners, the platform team changes runs-on in one file to a label from Runner Sizes & Labels, such as tenki-standard-medium-4c-8g, and every caller picks it up at its next version bump. GitHub treats Tenki runners as self-hosted, and each Tenki workspace connects to one GitHub organization, so keep the shared workflow in that organization.
Logs
In the run view, a reusable workflow's jobs appear as jobs of the caller's run, and every job and step has its own log. A composite action appears as one step, however many steps it contains. So when a long composite action fails, you read one combined log to find the step that broke. In a reusable workflow the failed step is marked on its own.
Nesting and limits
A reusable workflow can call other reusable workflows. You can connect up to ten levels: the top-level caller plus nine levels of reusable workflows. Loops aren't allowed. A single workflow file can call at most 50 unique reusable workflows, counting every workflow in the nested tree.
Composite actions can call other actions, including other composite actions. GitHub documents a limit of 10 nested composite actions, and past that the runner fails with "Composite action depth exceeded".
Two more limits catch people:
- Reusable workflows must sit directly in
.github/workflows/. Subdirectories aren't supported, so a repository with many shared workflows keeps them in one flat folder. Composite actions can live in any directory. - Because a reusable workflow is called from a job rather than a step,
GITHUB_ENVcan't carry values back to the caller. Use workflow outputs.
Where they can live and who can use them
Both can be referenced from the same repository or from another one:
- In a public repository, any workflow can use them, as long as the caller's Actions settings allow public actions and reusable workflows.
- In a private repository, other private repositories owned by the same user or organization can use them once you allow it under Settings → Actions → General → Access on the hosting repository. Public repositories can't.
- In an enterprise, an internal or private repository can be shared with every organization in the enterprise. Public repositories can't use what's in an internal one.
A private repository can't be shared with an unrelated organization. If you need that, make the repository public.
GitHub doesn't follow redirects for actions or reusable workflows. Renaming the repository or its owner breaks every caller that uses the old name.
Versioning and pinning
Cross-repository references take a ref after @: a commit SHA, a tag or a branch. If a tag and a branch share a name, the tag wins. GitHub recommends a full commit SHA as the safest option. A common setup for an internal shared repository:
- Tag releases with semver (
v1.2.0) and move a major tag (v1) forward with each compatible release. - Let internal callers use the major tag, and pin third-party code to a SHA.
- Use Dependabot or Renovate to propose ref updates. Dependabot updates reusable workflow references as well as actions.
Organizations can require SHA pinning through the allowed-actions policy. The policy applies to actions, composite ones included, while reusable workflows can still be referenced by tag.
Re-runs behave differently depending on the ref. If you re-run all jobs, GitHub resolves a tag or branch reference again and may pick up a newer version. If you re-run failed jobs or a single job, it uses the same commit SHA as the first attempt.
References within the same repository don't take a ref, so a change to the shared code and to its callers can land in one pull request. ./.github/workflows/x.yml always runs the reusable workflow from the caller's commit. ./.github/actions/x runs whatever is checked out in the workspace, which is usually the same commit. Inside a reusable workflow called from another repository, the workspace is the caller's repository, so ./ doesn't find the reusable workflow's own actions. GitHub added a $/ self-repository reference for this case: uses: $/.github/actions/x resolves to the repository of the file it's written in, at the running commit, with no checkout. It needs runner 2.336.0 or newer, doesn't take an @ref, and isn't available on GitHub Enterprise Server.
Concurrency and matrix
A job that calls a reusable workflow can set concurrency, for example to let only one production deploy run at a time:
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
with:
target: prod
concurrency:
group: deploy-prod
cancel-in-progress: false
Don't use the same concurrency group in the caller and the called workflow when cancel-in-progress is true. Since github.workflow is the caller's name inside the called workflow, a group like ${{ github.workflow }}-${{ github.ref }} at both levels makes the called workflow cancel the run that called it.
A matrix can call a reusable workflow once per combination:
jobs:
deploy:
strategy:
matrix:
target: [dev, staging, prod]
uses: ./.github/workflows/deploy.yml
with:
target: ${{ matrix.target }}
Outputs don't aggregate across the matrix. The caller gets the output of the last successful run that set a value, so if you need every result, have each run upload an artifact instead.
Composite actions don't have either setting. They take on the concurrency and matrix of the job that uses them.
Using both together
The two combine well. Reusable workflows own the job graph, runner choice and deploy gates, and composite actions hold the step sequences those jobs share. A central deploy workflow might look like this:
# my-org/shared-workflows/.github/workflows/build-and-deploy.yml
name: Build and deploy
on:
workflow_call:
inputs:
service:
description: Service to build and deploy
type: string
required: true
permissions:
contents: read
packages: write
jobs:
build:
runs-on: tenki-standard-medium-4c-8g
steps:
- uses: actions/checkout@v7
- uses: my-org/ci-actions/docker-build@v1
with:
image: ghcr.io/my-org/${{ inputs.service }}
token: ${{ github.token }}
deploy:
needs: build
runs-on: tenki-standard-small-2c-4g
environment: production
steps:
- uses: my-org/ci-actions/deploy-k8s@v1
with:
service: ${{ inputs.service }}
kubeconfig: ${{ secrets.KUBECONFIG }}
docker-build and deploy-k8s are composite actions in subdirectories of my-org/ci-actions, referenced as owner/repo/path@ref. The deploy job targets the production environment, so it waits for that environment's reviewers and reads KUBECONFIG from the environment's secrets. The caller has to grant the permissions the workflow asks for, since a called workflow can't raise them, and passes secrets: inherit so the environment secret resolves:
jobs:
release:
permissions:
contents: read
packages: write
uses: my-org/shared-workflows/.github/workflows/build-and-deploy.yml@v3
with:
service: api
secrets: inherit
For repetition inside a single workflow file, you may not need either. GitHub Actions supports YAML anchors and aliases, which can repeat a block of steps, an env map or a whole job definition within the file.


