How Do I Write a Composite Action?
Create an action.yml that sets runs.using to composite, list the steps, then call it with uses. Full example, the shell rule, and when to pick one.
Create an action.yml file that sets runs.using to composite and lists your steps under runs.steps, then call it from a job with uses pointing at either a path inside the repository or an owner/repo@ref reference. Add a shell key to every step that sets run, because GitHub requires it in composite actions and a missing one stops the workflow before the first command executes.
Answer
The action is a directory. Inside it sits a metadata file named action.yml or action.yaml, and GitHub names action.yml as the preferred spelling. The directory path is what a workflow references, so .github/actions/go-build/action.yml is addressed as ./.github/actions/go-build. Six keys carry the whole format (metadata syntax reference, checked on 2026-08-13).
| Key | Required | What it does |
|---|---|---|
name | Yes | Names the action and labels the collapsed step in the run log |
description | Yes | One line describing what the step sequence does |
inputs.<id> | No | Declares a parameter, read inside the action as ${{ inputs.<id> }} |
outputs.<id>.value | Yes for any declared output | Maps the output to a step output such as ${{ steps.test.outputs.path }} |
runs.using | Yes | Set to composite to select this action type |
runs.steps[*].shell | Yes on any step that sets run | Picks the shell for that command |
Three reference forms reach the finished action. ./path/to/action reads it from the caller's checked out workspace, so actions/checkout has to run first. owner/repo@ref fetches it from another repository at a tag, branch, or commit SHA, and GitHub recommends pinning to a SHA. The $/path/to/action self repository form resolves to the running commit with no prior checkout and is unavailable on GitHub Enterprise Server (workflow syntax reference, checked on 2026-08-13).
One structural fact shapes everything you write inside runs.steps: the action executes on the machine the calling job already booked. (cloud runners catalog), and the same action.yml has to survive on each platform your workflows target, which is why the shell value deserves a moment of thought rather than a reflexive bash on every step.
Detail
A complete action.yml with inputs, outputs, and two run steps
This action installs a Go toolchain, builds one package, and runs its tests. It lives at .github/actions/go-build/action.yml.
name: Build and test a Go package
description: Install Go with dependency caching, build one package, and run its tests
inputs:
go-version:
description: Go toolchain version to install
required: false
default: "1.22"
package-path:
description: Directory holding the package to build and test
required: true
test-flags:
description: Extra flags appended to go test
required: false
default: "-race"
outputs:
binary-path:
description: Path to the compiled binary, relative to the workspace
value: ${{ steps.build.outputs.binary-path }}
coverage-file:
description: Path to the coverage profile written by the test step
value: ${{ steps.test.outputs.coverage-file }}
runs:
using: "composite"
steps:
- uses: WarpBuilds/setup-go@v6
with:
go-version: ${{ inputs.go-version }}
cache: true
- name: Build
id: build
shell: bash
working-directory: ${{ inputs.package-path }}
run: |
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -o "$GITHUB_WORKSPACE/dist/app" .
echo "binary-path=dist/app" >> "$GITHUB_OUTPUT"
- name: Test
id: test
shell: bash
working-directory: ${{ inputs.package-path }}
run: |
go test ${{ inputs.test-flags }} -coverprofile="$GITHUB_WORKSPACE/coverage.out" ./...
echo "coverage-file=coverage.out" >> "$GITHUB_OUTPUT"Four wiring details in that file are worth naming. Inputs arrive through the inputs context and nothing else, since GitHub does not set the INPUT_<VARIABLE_NAME> environment variables for composite actions that Docker container and JavaScript actions receive (metadata syntax reference, checked on 2026-08-13). Each output reaches the caller only because value maps it to a step id, and an output block without a value hands back an empty string. Both run steps write their results to $GITHUB_OUTPUT in key=value form, which is what makes those steps.build.outputs references resolve. And the first step is a uses step, so runs.steps can mix published actions with your own commands; here it pulls a cache-enabled toolchain action from the setup actions documentation.
Calling it from a job
name: build
on:
pull_request:
jobs:
api:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- id: go
uses: ./.github/actions/go-build
with:
package-path: services/api
test-flags: "-race -count=1"
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-api
path: ${{ steps.go.outputs.coverage-file }}The checkout step comes first because the ./ reference reads the action out of the workspace. The id: go line is what lets a later step read steps.go.outputs.coverage-file. Everything else about the job is unchanged, including the runner label: swapping runs-on is a one-line edit that the composite action never sees (quick start). That label decides both the machine and the rate. warp-ubuntu-latest-x64-2x is 2 vCPU and 8 GB at $0.004 per minute, and warp-ubuntu-latest-x64-4x is 4 vCPU and 16 GB at $0.008 per minute, from the pricing page, checked on 2026-08-13.
The shell key, and the three errors that follow it
- A
runstep withoutshellfails validation. GitHub documentsruns.steps[*].shellas required wheneverrunis set, and the job reports the missing required property before any command executes. Usebashon Linux and macOS runners andpwshon Windows runners, or setshell: basheverywhere only after confirming every target platform in your matrix has it. - A
${{ secrets.NAME }}expression insideaction.ymlresolves to an empty string. The secrets context is unavailable to composite actions, so the caller reads the secret and passes it in throughwith, and the action reads it as an ordinary input. - A relative path to a file shipped next to
action.ymlresolves against the job workspace. Address bundled scripts through${{ github.action_path }}or$GITHUB_ACTION_PATH, and remember thatworking-directoryon a step defaults to the workspace rather than the action directory. - A declared output with no
valuekey silently returns an empty string to the caller, which is the failure that looks like a bug in the consuming step.
When a composite action is the right unit
| Question | Composite action | Reusable workflow |
|---|---|---|
| What you package | A sequence of steps | One or more whole jobs |
| Where the caller invokes it | A step, with uses in steps | A job, with uses at job level |
| Runner selection | Inherits the caller job's machine | Declares its own runs-on, or takes it as an input |
| Secrets | Passed as inputs with with | secrets: mapping or secrets: inherit |
| Run log | Collapsed into one step | Every job and step logged separately |
| Depth limit | Up to 10 composite actions in one workflow | Up to 4 levels of nested calls |
Source: GitHub's comparison of the two mechanisms (reusing workflow configurations) and the limits reference, both checked on 2026-08-13.
Read the runner row first. Shared logic that has to run somewhere the caller did not pick, such as a signing step that needs macOS while the caller sits on Linux, belongs in a reusable workflow. Shared logic that runs on the caller's machine and reuses its checkout, its cache, and its environment belongs in a composite action. The log row decides the rest: a composite action of twenty steps turns into one opaque line in the run view, so long sequences are easier to operate as a reusable workflow with real job boundaries. The reusable workflows guide covers versioning and rollout once shared configuration spans an organization.
Cost enters through the caller, since the composite action bills as part of the job it runs in. For the wider set of levers on job duration, see how to speed up GitHub Actions.
Related Questions
Does every step in a composite action need a shell key?
Every step that sets run needs one. GitHub documents runs.steps[*].shell as required when run is present, and an action missing it fails to parse, so the job stops before the first command executes. Steps that call another action with uses take no shell key. The composite action definition lists the rest of the per-step keys that behave the same way they do in a workflow file.
Can a composite action choose its own runner?
No. It runs as one step inside the caller's job and inherits the machine that job picked with runs-on. That constraint is the main reason to reach for a reusable workflow instead, since a called workflow declares its own runs-on or accepts the label as an input. How do I reuse a workflow across repositories shows both sides of that call, including the input that lets each repository pick its own runner size.
How do I pass a secret into a composite action?
Pass it as an input from the caller with with. The secrets context is unavailable inside a composite action, so a ${{ secrets.NAME }} expression written in action.yml resolves to an empty string, and the value has to be read in the workflow file and handed over. Reusable workflows take the other route with a secrets: mapping or secrets: inherit, which the reusable workflows guide covers alongside the versioning contract.
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.