A team asks me this question every month: should we put this CI logic in a composite action or a reusable workflow? And every month I give the same five-minute answer that none of the official docs put in one place. So here it is, with the heuristic that's held up across a year of refactoring our org's pipelines.
The shape difference (the part the docs skip)
| Composite action | Reusable workflow | |
|---|---|---|
| Runs as | A sequence of steps inside a job | A whole workflow (one or more jobs) |
| Caller | A single step | A whole job |
| Receives | inputs.* |
inputs.* and secrets.* |
| Produces | outputs.* (per-step) |
outputs.* (per-job) |
| Sees the caller's | ${{ github.* }} context, runner, env |
A separate runner instance |
| Cost | Cheap, same job, same runner | An extra job billed separately |
The single most useful sentence: a composite action is a function call on the same machine; a reusable workflow is a separate process on a separate machine. If you wouldn't subprocess.run the work, you probably want a reusable workflow.
Composite action, when to reach for it
Use case: a sequence of 3-8 steps you copy-paste between three or more workflows. Things like "set up Node + restore cache + install + run lint" or "azure/login@v2 + az group create + tag the RG."
# .github/actions/azure-login-and-tag/action.yml
name: Azure Login and Tag RG
description: Logs into Azure with OIDC and ensures a resource group exists with org tags.
inputs:
client-id: { required: true }
tenant-id: { required: true }
subscription-id: { required: true }
resource-group: { required: true }
location: { default: eastus }
runs:
using: composite
steps:
- uses: azure/login@v2
with:
client-id: ${{ inputs.client-id }}
tenant-id: ${{ inputs.tenant-id }}
subscription-id: ${{ inputs.subscription-id }}
- name: Ensure RG with tags
shell: bash
env:
RG: ${{ inputs.resource-group }}
LOC: ${{ inputs.location }}
run: |
az group create --name "$RG" --location "$LOC" --tags \
'managed-by=ci' 'org=platform' 'env=${{ inputs.environment || ''shared'' }}'
Caller:
jobs:
deploy:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/azure-login-and-tag
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
resource-group: rg-prod
The id-token: write permission stays on the caller's job. Composite actions inherit it; reusable workflows don't (more on that below).
Reusable workflow, when to reach for it
Use case: a whole CI flow you want to standardise across repos, build, test, scan, publish, typically with multiple jobs, environment-scoped credentials, and concurrency control.
# .github/workflows/build-and-publish-image.yml
name: Build and Publish Image
on:
workflow_call:
inputs:
image-name: { required: true, type: string }
target-env: { required: true, type: string }
enable-trivy: { required: false, type: boolean, default: true }
secrets:
acr-login-server: { required: true }
outputs:
digest:
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
environment: ${{ inputs.target-env }}
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az acr login --name ${{ secrets.acr-login-server }}
- id: push
run: |
docker build -t ${{ secrets.acr-login-server }}/${{ inputs.image-name }}:${{ github.sha }} .
docker push ${{ secrets.acr-login-server }}/${{ inputs.image-name }}:${{ github.sha }}
digest=$(docker inspect --format='{{index .RepoDigests 0}}' \
${{ secrets.acr-login-server }}/${{ inputs.image-name }}:${{ github.sha }})
echo "digest=$digest" >> $GITHUB_OUTPUT
- if: inputs.enable-trivy
uses: aquasecurity/trivy-action@master
with: { image-ref: '${{ steps.push.outputs.digest }}' }
Caller:
jobs:
ship:
uses: org-name/.github-templates/.github/workflows/build-and-publish-image.yml@v3
with:
image-name: payments-api
target-env: production
secrets:
acr-login-server: ${{ secrets.ACR_LOGIN_SERVER }}
The @v3 is critical. Pin reusable workflows to a tag, not a branch. Branch references are silently re-pulled every run; a tag is immutable until you bump it.
The four things that bite you
1. id-token: write doesn't transit reusable workflows by default
If your reusable workflow needs OIDC, it must declare permissions: { id-token: write } inside the called workflow's jobs. The caller's permissions don't propagate. Composite actions inherit; reusable workflows do not.
2. Secrets are explicit on reusable workflows
# Caller
with: { ... }
secrets:
acr-login-server: ${{ secrets.ACR_LOGIN_SERVER }}
# OR, if you trust everything to flow through:
secrets: inherit
secrets: inherit is the lazy, sometimes-correct option. It's also the one that quietly sends your PROD_DEPLOY_TOKEN to a workflow that only needed NPM_TOKEN. Prefer explicit secret passing for anything privileged.
3. Composite actions can't run multiple jobs
A common confusion: "I want to fan out lint + test in parallel from a composite action." You can't. Composite actions are sequential steps in one job. If you need parallelism across machines, that's a reusable workflow with multiple jobs.
4. Reusable workflows charge per job
Your runner-minutes bill goes up with reusable workflows because each called workflow's jobs are separately billed jobs. Composite actions are free in that sense, they execute on the caller's runner. If your CI minutes budget is tight, audit reusable workflows that wrap a single short job; many should be composite actions instead.
The "use neither" case
A workflow that runs in three repos, with three different conventions for inputs, secrets, and triggers. Standardisation looks like the right call. But:
- Bumping the shared workflow forces all three repos to deal with it on the same day
- Debugging an issue in one repo means understanding the other two repos' conventions
- The "shared" thing accumulates
if:branches per repo
Sometimes the right move is to leave each repo's pipeline alone and instead share patterns via a docs page. Three slightly-different YAMLs are easier to debug than one heavily-conditional one.
What I'd do differently
I'd reach for composite actions earlier and reusable workflows later. The default reflex is "we have multiple repos, this should be a reusable workflow", and the composite action would have done the job for half the price. Reusable workflows are right when the work is genuinely a whole pipeline, not when it's a step or three.
I would NOT inline a composite action into a reusable workflow without versioning. Pin composite actions with a tag too, uses: org/repo/.github/actions/foo@v2, even when the action lives in your own org. Same reason: branches are mutable.

Conversation
Reactions & commentsLiked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.