Skip to content

GitHub Actions Composite Actions

Lesson 3 of 11Intermediate4 min readGitHub Actions & CI/CD · Advanced ActionsVerified: composite action syntax, August 2026

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.

A composite action is a directory containing an action.yml:

{/* .github/actions/setup-project/action.yml */}
name: Set up project
description: 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: bash

Used from a workflow:

steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-project
with:
node-version: "22"
- run: npm test

name and description are required — the file fails to parse without them.

- name: Install dependencies
run: npm ci
shell: bash

What 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.

Every input arrives as a string, whatever the caller wrote:

inputs:
verbose:
default: "false"
- if: inputs.verbose == 'true'
run: set -x
shell: bash

if: 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:
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: bash

A 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.

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 strategy or 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-error is 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./.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.

PublishedOWNER/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.

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.

name: Configure cloud access
description: 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: bash

The 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.

  1. Create .github/actions/setup-project/action.yml packaging your language setup and dependency install.

  2. Use it from a workflow. Deliberately omit shell: from a run step and read the error.

  3. Add a boolean-ish input defaulting to "false" and write if: inputs.flag. Confirm it runs anyway, then fix it with inputs.flag == 'true'.

  4. Add an output and read it in the calling workflow. Remove the value: mapping and confirm the caller silently gets an empty string.

  5. Move the local action to a separate repository, tag it v1, and call it by tag. Note what you had to change.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.