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 typeruns.using valueWhat supplies the logic
Compositecompositeruns.steps, a list of workflow steps in action.yml
JavaScriptnode20 or node24A script file named by runs.main
Docker containerdockerA 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:

  1. shell is required on any step that sets run. GitHub documents runs.steps[*].shell as required in that case, and a composite step without it fails to parse.
  2. Inputs are read through the inputs context, written as ${{ inputs.name }}. GitHub notes that composite actions do not automatically receive the INPUT_<VARIABLE_NAME> environment variables that Docker container and JavaScript actions get.
  3. Outputs must be declared under a top level outputs block, and each one needs a value key mapping it to a step output such as ${{ steps.build.outputs.digest }}. Declaring the output alone leaves the caller with an empty string.
  4. 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@ref fetches 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/action reads the action from the caller's checked out working directory, so the workflow has to run actions/checkout before the step that uses it.
  • $/path/to/action is 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):

PropertyComposite action
Contains jobsNo, it runs as one step inside a caller job
Chooses its own runnerNo, it inherits the machine the caller job is already on
Logging granularityLogged as one step even when it contains many
Secrets contextUnavailable, so values arrive as inputs passed with with
Nesting depthUp to 10 composite actions in one workflow
MarketplaceCan 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.

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.