Every workflow in the repository starts with the same six steps: checkout, set up the language, restore the cache, install dependencies, configure the environment. A composite action makes those six steps one step, without needing to write any JavaScript.
The smallest useful example
Section titled “The smallest useful example”A composite action is a directory containing an action.yml:
{/* .github/actions/setup-project/action.yml */}name: Set up projectdescription: Checks out, installs Node and restores dependencies.
inputs: node-version: description: "Node major version" required: false default: "24"
outputs: cache-hit: description: "Whether the dependency 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 }} cache: npm
- name: Install dependencies run: npm ci shell: bashUsed from a workflow:
steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-project with: node-version: "22" - run: npm testname and description are required — the file fails to parse without them.
shell is required on every run step
Section titled “shell is required on every run step”- name: Install dependencies run: npm ci shell: bashWhat it doesNames the interpreter for a run step inside a composite action.
Why we run itWorkflows default to bash on Linux and macOS and PowerShell on Windows. Composite actions have no default at all, because an action can be used on any runner and guessing would make it silently behave differently per platform.
Expected resultOmitting it is a parse error, not a runtime one — the workflow fails before any step executes.
This is the single most common composite-action error. The message names the step, which helps, but the reason is not obvious the first time.
For an action intended to run everywhere, shell: bash works on all three GitHub-hosted platforms —
Windows runners include Git Bash. Use shell: pwsh when you genuinely need PowerShell semantics.
Inputs are strings
Section titled “Inputs are strings”Every input arrives as a string, whatever the caller wrote:
inputs: verbose: default: "false"- if: inputs.verbose == 'true' run: set -x shell: bashif: inputs.verbose is true for the string "false", because a non-empty string is truthy. This
catches people repeatedly. Compare against the string explicitly.
Unlike reusable workflows, composite action inputs
have no type key — there is nothing to declare.
Outputs need explicit mapping
Section titled “Outputs need explicit mapping”outputs: version: description: "Version read from package.json" value: ${{ steps.read.outputs.version }}
runs: using: composite steps: - id: read run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" shell: bashA step output does not become an action output automatically. Forgetting the value: mapping produces
an empty string in the caller with no error anywhere — the same failure shape as reusable workflow
outputs, and worth recognising once.
What a composite action cannot do
Section titled “What a composite action cannot do”The limits define when to reach for something else:
- No
runs-on. It inherits the caller’s runner. - No
permissions. It uses the caller’s token as-is. - No
strategyor matrix. - No
if:on the action as a whole from inside — the caller can skip the whole step, but the action cannot declare conditions on itself. continue-on-erroris not supported on the composite action’s own steps. A failing step fails the action.- No direct access to
secrets. Secrets must be passed in as inputs.
That last one deserves attention.
Local versus published
Section titled “Local versus published”Local — ./.github/actions/name — is checked out with the repository, versioned with it, and
needs no publishing. It requires actions/checkout to have run first, which is the other common
first-time error: a workflow whose first step is a local composite action fails because the file is
not on disk yet.
Published — OWNER/repo@v1 — lives in its own repository and is referenced by tag or SHA. The
action must be at the repository root (action.yml beside the README) or in a subdirectory referenced
as OWNER/repo/path@v1.
Start local. Publish when a second repository needs it.
Nesting
Section titled “Nesting”Composite actions can use other actions, including other composite actions, up to ten levels. That is deep enough that the practical limit is comprehension rather than the platform.
One consequence worth knowing: a composite action that uses a third-party action inherits that action’s
supply-chain risk, and the caller cannot see it from the uses: line. Pinning inside a shared
composite action matters more than pinning in a workflow, because more repositories are downstream of
it.
A realistic example
Section titled “A realistic example”name: Configure cloud accessdescription: Authenticates to AWS via OIDC and verifies the assumed identity.
inputs: role-arn: description: "IAM role to assume" required: true region: description: "AWS region" required: false default: "eu-west-1"
outputs: account-id: description: "Account the workflow authenticated into" value: ${{ steps.identity.outputs.account }}
runs: using: composite steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ inputs.role-arn }} aws-region: ${{ inputs.region }}
- id: identity run: | account="$(aws sts get-caller-identity --query Account --output text)" echo "account=${account}" >> "$GITHUB_OUTPUT" echo "Authenticated into ${account}" shell: bashThe caller still needs id-token: write in its own permissions block. The action cannot request it,
which is exactly the limit described above — and a good illustration of why permissions live with the
caller rather than the dependency.
Exercise
Section titled “Exercise”-
Create
.github/actions/setup-project/action.ymlpackaging your language setup and dependency install. -
Use it from a workflow. Deliberately omit
shell:from arunstep and read the error. -
Add a boolean-ish input defaulting to
"false"and writeif: inputs.flag. Confirm it runs anyway, then fix it withinputs.flag == 'true'. -
Add an output and read it in the calling workflow. Remove the
value:mapping and confirm the caller silently gets an empty string. -
Move the local action to a separate repository, tag it
v1, and call it by tag. Note what you had to change.