Composite Action
A composite action bundles several GitHub Actions workflow steps into one reusable action defined by a single action.yml file. Syntax, limits, example.
A composite action is a reusable GitHub Actions action whose body is a list of workflow steps written directly in a single action.yml file, rather than JavaScript source or a container image. A workflow calls it with uses like any other action, and every step inside it runs within the calling job.
The point of the format is that the unit of reuse is a step sequence you already wrote. Three run commands repeated across ten workflow files move into one directory holding an action.yml, and each of those ten files keeps a single uses line where the three commands used to be.
Definition
GitHub supports three kinds of custom action, and the runs.using key in the action metadata file selects which one you are writing (metadata syntax reference, checked on 2026-08-13).
| Action type | runs.using value | What supplies the logic |
|---|---|---|
| Composite | composite | runs.steps, a list of workflow steps in action.yml |
| JavaScript | node20 or node24 | A script file named by runs.main |
| Docker container | docker | A Dockerfile or a published image named by runs.image |
The metadata file must be called action.yml or action.yaml, and GitHub states action.yml as the preferred form. Its name and description keys are required for every action type. The file lives at the root of a directory, and that directory is what a workflow references.
What goes inside runs.steps
runs.steps accepts both run steps and uses steps, so a composite action can shell out and call other published actions in the same sequence. Four rules govern the steps:
shellis required on any step that setsrun. GitHub documentsruns.steps[*].shellas required in that case, and a composite step without it fails to parse.- Inputs are read through the
inputscontext, written as${{ inputs.name }}. GitHub notes that composite actions do not automatically receive theINPUT_<VARIABLE_NAME>environment variables that Docker container and JavaScript actions get. - Outputs must be declared under a top level
outputsblock, and each one needs avaluekey mapping it to a step output such as${{ steps.build.outputs.digest }}. Declaring the output alone leaves the caller with an empty string. - Files shipped alongside
action.yml, such as a helper script, are addressed through${{ github.action_path }}or$GITHUB_ACTION_PATH. A bare relative path resolves against the job workspace instead of the action directory.
Per step keys id, name, env, if, working-directory, with, and continue-on-error all work the same way they do in a workflow file.
How a workflow references one
Three reference forms reach a composite action, and they differ in what has to exist on disk first:
owner/repo@reffetches the action from another repository at a tag, branch, or commit SHA. GitHub recommends pinning to a commit SHA for stability and security../path/to/actionreads the action from the caller's checked out working directory, so the workflow has to runactions/checkoutbefore the step that uses it.$/path/to/actionis the self repository reference. GitHub calls it the recommended way to reference an action inside its own repository, because it resolves to that repository at the running commit with no prior checkout. The prefix carries no@{ref}suffix and is unavailable on GitHub Enterprise Server (workflow syntax reference, checked on 2026-08-13).
Documented behavior and limits
GitHub's comparison of the two reuse mechanisms records the following for composite actions (reusing workflow configurations, checked on 2026-08-13):
| Property | Composite action |
|---|---|
| Contains jobs | No, it runs as one step inside a caller job |
| Chooses its own runner | No, it inherits the machine the caller job is already on |
| Logging granularity | Logged as one step even when it contains many |
| Secrets context | Unavailable, so values arrive as inputs passed with with |
| Nesting depth | Up to 10 composite actions in one workflow |
| Marketplace | Can be published |
Two of those rows drive most design decisions. Because a composite action inherits the caller's runner, steps that need a different operating system belong in a reusable workflow instead. Because it collapses into one log line, a composite action that grows past a handful of steps becomes hard to debug from the run view.
GitHub also caps each workflow file in .github/workflows at 500 KB and names composite actions as one of the two ways to shrink an oversized file (GitHub Actions limits, checked on 2026-08-13).
Example
This composite action installs dependencies and runs a test suite in two run steps. It lives at .github/actions/unit-tests/action.yml in the same repository as the workflow that calls it.
name: Unit tests
description: Install dependencies and run the unit test suite for one package
inputs:
working-directory:
description: Directory holding the package.json to build and test
required: false
default: '.'
outputs:
report-path:
description: Path to the JUnit report written by the test run
value: ${{ steps.test.outputs.report-path }}
runs:
using: "composite"
steps:
- name: Install dependencies
run: npm ci
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Run tests
id: test
run: |
npm test -- --reporters=default --reporters=jest-junit
echo "report-path=${{ inputs.working-directory }}/reports/junit.xml" >> "$GITHUB_OUTPUT"
shell: bash
working-directory: ${{ inputs.working-directory }}The calling job references the directory with a relative path. actions/checkout runs first, because the ./ form reads the action from the workspace the job has already checked out:
name: tests
on:
push:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- id: suite
uses: ./.github/actions/unit-tests
with:
working-directory: packages/api
- run: echo "Report at ${{ steps.suite.outputs.report-path }}"Three details of that run are worth noting. The job log shows one step named after the action, and the install and test commands appear nested under it rather than as siblings of the checkout step. The report-path output reaches the caller only because outputs.report-path.value maps it to the id of the test step. And the working-directory input is readable inside the action as ${{ inputs.working-directory }} with no INPUT_WORKING_DIRECTORY environment variable behind it.
Swapping ./.github/actions/unit-tests for $/.github/actions/unit-tests removes the ordering constraint, since the self repository reference pulls the action at the running commit whether or not the workspace has been checked out.
Moving the action to its own repository changes one line in the caller. uses: ./.github/actions/unit-tests becomes uses: my-org/ci-actions/unit-tests@a1b2c3d, the checkout requirement for the action itself goes away, and the pinned SHA decides which version of the step sequence runs.
Related Terms
- Reusable workflow: the other GitHub Actions reuse mechanism, which packages whole jobs, picks its own runners, and can accept secrets.
- How do I write a composite action: the file layout, input and output wiring, and testing loop for building one.
- Reusable workflows across many repositories: versioning, pinning, and rollout patterns once shared configuration spans an organization.
- Cache-enabled setup-* actions: the forked
setup-*actions and their cache inputs, callable from ausesstep inside a composite action. - Runner rates per minute: per-minute rates by runner type.
FAQ
What makes an action composite rather than JavaScript or Docker?
The runs.using value in action.yml. A composite action sets runs.using to composite and supplies runs.steps, a list of workflow steps. A JavaScript action sets runs.using to a Node.js runtime such as node20 or node24 and points runs.main at a script file. A Docker container action sets runs.using to docker and points runs.image at a Dockerfile or a registry image.
Do composite action steps need a shell key?
Yes, whenever the step uses run. GitHub documents runs.steps[*].shell as required if run is set, so a composite step that omits it fails to parse. Steps that use uses instead of run do not need a shell key.
Why do the individual steps of a composite action not appear in the run log?
GitHub logs a composite action as the single caller step that invoked it, even when it contains many steps inside. Reusable workflows log every job and step separately, so a long composite action is harder to read in the run view than the same logic split across workflow steps.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.