Composite Actions
By now you can write a workflow that checks out code, runs tests, and reports status. As pipelines grow, the same handful of steps — installing a toolchain, authenticating to a registry, posting a notification — start showing up in every workflow file in a repository, and often across many repositories. A composite action packages that repeated sequence of steps into a single, named, reusable unit with its own inputs and outputs, so a workflow calls it with one uses: line instead of duplicating ten lines of YAML.
This lesson assumes you can already write a basic workflow and trigger it on push or pull_request. It focuses on building, versioning, and safely consuming composite actions — one of two GitHub-native reuse mechanisms, alongside reusable workflows (workflow_call), which reuses whole jobs rather than individual steps.
Overview / How it works
A composite action is defined by an action.yml file that sets runs.using to composite and lists one or more steps. Those steps run inside the calling job, on the same runner, in the same working directory, and can mix run shell commands with other uses: steps, including other actions. From a workflow’s point of view, a composite action looks exactly like any other action: you reference it with uses: and pass data through with:.
Composite actions live in one of two places. In the same repository, typically under .github/actions/<name>/action.yml, referenced locally as uses: ./.github/actions/<name> — the right choice when the logic only matters inside one repository. Or in a dedicated repository, referenced as uses: owner/repo@ref, where ref is a tag, branch, or commit SHA — the right choice when several repositories need the same logic, since it gives you one place to fix a bug and one version history to audit.
Two properties separate a composite action from copy-pasted YAML. First, it accepts typed inputs with descriptions, defaults, and a required flag, so callers get an explicit contract instead of guessing which environment variables matter. Second, it can expose outputs computed from its internal steps, so a calling workflow can branch on what the action produced — a version string, a coverage number, an image digest.
Syntax or workflow structure
Every composite action file follows the same shape:
name: "Skeleton composite action"
description: "Shape every action.yml file follows"
inputs:
example-input:
description: "Describe what this input controls"
required: false
default: "default-value"
outputs:
example-output:
description: "Describe what this output contains"
value: ${{ steps.step-id.outputs.value }}
runs:
using: "composite"
steps:
- name: A shell step
shell: bash
run: echo "Reads ${{ inputs.example-input }}"
- name: A step that sets an output
id: step-id
shell: bash
run: echo "value=hello" >> "$GITHUB_OUTPUT"
Two details trip up almost everyone the first time. Every run step inside a composite action must declare shell: explicitly — ordinary job steps infer a default shell from the runner OS, but composite action steps do not, so leaving it out fails validation. And inputs are read with ${{ inputs.name }}, never ${{ secrets.name }} directly, because a composite action has no secrets context of its own; secrets must be passed in explicitly as inputs from the calling workflow.
Examples
Example 1: a local setup action
Suppose three workflows in one repository each set up Node.js and run npm ci. Pull that into a local composite action:
# .github/actions/setup-node-and-install/action.yml
name: "Setup Node and Install"
description: "Installs a pinned Node.js version and project dependencies"
inputs:
node-version:
description: "Node.js version to install"
required: false
default: "20"
runs:
using: "composite"
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install dependencies
shell: bash
run: npm ci
Each consuming workflow now shrinks to one step:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node and install
uses: ./.github/actions/setup-node-and-install
with:
node-version: "20"
- name: Run tests
run: npm test
Expected behavior: the job checks out the repository, delegates to the composite action — which installs Node 20, restores the npm cache, and runs npm ci — and then runs the test suite. If the composite action’s internal npm ci step fails, the job fails at that step and npm test never runs, exactly as if the steps had been inlined.
Example 2: an action with outputs
Now make an action that runs tests and reports coverage, so callers can enforce a threshold without duplicating the parsing logic:
# .github/actions/test-with-coverage/action.yml
name: "Run Tests With Coverage"
description: "Runs the test suite and exposes total statement coverage as an output"
inputs:
test-command:
description: "Command used to run tests with coverage enabled"
required: false
default: "npm test -- --coverage"
outputs:
coverage-percent:
description: "Total statement coverage percentage"
value: ${{ steps.coverage.outputs.percent }}
runs:
using: "composite"
steps:
- name: Run tests
shell: bash
run: ${{ inputs.test-command }}
- name: Read coverage summary
id: coverage
shell: bash
run: |
PCT=$(node -p "require('./coverage/coverage-summary.json').total.statements.pct")
echo "percent=$PCT" >> "$GITHUB_OUTPUT"
A workflow consumes the output through steps.<id>.outputs.<name>, exactly as it would for a built-in action:
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Test with coverage
id: run-tests
uses: ./.github/actions/test-with-coverage
- name: Enforce coverage threshold
shell: bash
run: |
PCT="${{ steps.run-tests.outputs.coverage-percent }}"
if awk "BEGIN {exit !($PCT < 80)}"; then
echo "Coverage $PCT% is below the 80% threshold" >&2
exit 1
fi
Expected behavior: the composite action runs the tests, reads the generated coverage summary, and writes percent to GITHUB_OUTPUT, which GitHub Actions maps to the action’s declared coverage-percent output. The calling job reads that value through the step’s id and fails the job if coverage is below 80%, with no coverage-parsing logic duplicated in the workflow file itself.
Example 3: a versioned action from a dedicated repository
Once a composite action is useful outside one repository, move it to its own repository, tag releases, and pin consumers by commit SHA rather than by a mutable tag, since a tag can be moved to point at different code after you have already reviewed it:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Build and publish image
id: publish
uses: my-org/actions-publish-image@a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 # v3.2.1
with:
image-name: my-org/api
registry-token: ${{ secrets.REGISTRY_TOKEN }}
- name: Record published digest
shell: bash
run: echo "Published image digest ${{ steps.publish.outputs.image-digest }}"
Expected behavior: the pinned commit SHA guarantees the exact code that ran during your last review is the code that runs now, even if the v3.2.1 tag is later re-pointed. The job also declares only contents: read and packages: write under permissions: instead of relying on default token scope, and the composite action’s image-digest output — an immutable content hash, unlike a tag such as latest which can refer to different bytes over time — is what a later deploy step should reference, not the mutable tag.
Step by step
- Identify a sequence of steps duplicated across two or more workflows or repositories — that duplication is the signal a composite action is worth creating, not a hunch.
- Create
.github/actions/<name>/action.yml(or a new repository for cross-repo use) and definename,description, and everyinputsentry with a description and a sensible default where one exists. - Move the duplicated steps into
runs.steps, addingshell: bash(orpwsh,python, and so on) to everyrunstep. - Replace any secret usage inside the steps with a reference to
${{ inputs.<name> }}, and declare that input asrequired: truewith no default value. - If a later step or the calling workflow needs a value produced inside the action, give the producing step an
idand declare anoutputsentry whosevaluemaps tosteps.<id>.outputs.<key>. - Replace the duplicated YAML in each workflow with a single
uses:step, passing the required inputs throughwith:. - For a shared, cross-repository action, tag a release and pin consumers to the commit SHA behind that tag rather than to the mutable tag itself.
- Add a small workflow inside the action’s own repository that exercises it directly on
push, so a broken action fails its own tests instead of silently breaking every consumer downstream.
Common Mistakes
Mistake 1: omitting shell on a run step
A composite action step written like a normal job step fails validation:
runs:
using: "composite"
steps:
- name: Install dependencies
run: npm ci
GitHub rejects this with an error to the effect of “Required property is missing: shell”, because composite action steps, unlike normal job steps, do not infer a default shell from the runner OS. The fix is to add shell: explicitly:
runs:
using: "composite"
steps:
- name: Install dependencies
shell: bash
run: npm ci
Mistake 2: reaching for secrets directly inside the action
It is tempting to reference a secret the same way you would in an ordinary workflow step:
runs:
using: "composite"
steps:
- name: Push image
shell: bash
run: docker login -u user -p ${{ secrets.REGISTRY_TOKEN }} registry.example.com
This fails silently in a confusing way: ${{ secrets.REGISTRY_TOKEN }} resolves to an empty string, because a composite action has no secrets context of its own — only the top-level workflow file does. Even where a value is available, passing a secret as a command-line flag is risky because it can end up in process listings. The fix is to accept the secret as a required input and pass it through the environment instead of the argument list:
inputs:
registry-token:
description: "Token used to authenticate to the container registry"
required: true
runs:
using: "composite"
steps:
- name: Push image
shell: bash
env:
REGISTRY_TOKEN: ${{ inputs.registry-token }}
run: echo "$REGISTRY_TOKEN" | docker login -u user --password-stdin registry.example.com
The calling workflow then supplies the secret explicitly, the same way it would supply any other input: with: registry-token: ${{ secrets.REGISTRY_TOKEN }}, as shown in Example 3. Never echo, print, or interpolate the resolved value into a log line.
Best Practices
- Keep each composite action focused on one job — “set up the toolchain” or “publish the image” — rather than one action that does everything; small actions are easier to test, version, and reuse partially.
- Document every input and output with a clear
description, and mark anything without a safe default asrequired: trueso misuse fails fast instead of silently using an empty value. - Pass secrets in as required inputs and route them through
env:inside the composite action rather than command-line arguments; never give a secret input a default value. - Pin external composite actions to a commit SHA for anything that touches credentials or production, with a version tag as a trailing comment for readability. A mutable tag is convenient but means a compromised or careless maintainer can change what code you run without you noticing; a pinned SHA trades that convenience for an auditable, unchanging reference.
- Set
permissions:to the minimum the job needs at the calling workflow, not inside the composite action — an action has no permissions block of its own and simply inherits whateverGITHUB_TOKENscope the calling job was granted. - Test a shared action inside its own repository before consumers pick it up, and treat a breaking change to its inputs or outputs the same way you would a breaking change to any published interface.
- Reach for a composite action when you are reusing a handful of steps inside one job; reach for a reusable workflow (
workflow_call) when you need to reuse whole jobs, matrices, or several dependent jobs together.
Practice Exercises
- Find two workflows in a personal repository that both check out code and set up the same language runtime. Extract that into a local composite action under
.github/actions/and update both workflows to call it. - Extend that action so a step captures its own version string with an
id, expose it as a declaredoutput, and add a step in a calling workflow that prints the value usingsteps.<id>.outputs.<name>. - Deliberately remove
shell:from one of your composite action’srunsteps, trigger the workflow, and read the resulting error message. Restore the fix and confirm the run succeeds. - Add an input to your action that would normally hold a secret. Wire a placeholder secret through from a calling workflow using
with: my-input: ${{ secrets.EXAMPLE_TOKEN }}and consume it inside the action viaenv:rather than inline in arun:command. - If you have access to a second repository, move the action there, tag a release, and reference it from the first repository pinned to the release’s commit SHA instead of the tag.
Summary
A composite action turns a sequence of duplicated workflow steps into a single, versioned, reusable unit with an explicit contract: typed inputs, declared outputs, and steps that must each name their own shell. Keep actions small and single-purpose, pass secrets in as required inputs rather than reading a nonexistent secrets context, pin external actions by commit SHA when credentials or production are involved, and set minimal permissions: on the calling job rather than expecting the action to scope itself. Used this way, composite actions remove copy-pasted YAML from a repository’s workflows without hiding what each pipeline actually does.
