GitHub·6 min read·

Composite Actions: Between Step and Workflow Size

Bundle repeated step groups into reusable composite GitHub Actions that run inside the calling job with no extra checkout, to keep your pipeline files DRY.

NB

Netbay Engineering

Netbay Engineering

On this page

You have five steps — check out, set up Node, install deps, cache, run lint — that you paste into every job in every repo. That is a smell. A reusable workflow is overkill because you want these steps in the same job, sharing the same runner and working tree. The answer is a composite action: a named bundle of steps you can invoke with uses from anywhere, like a reusable action but built from your own run blocks and other actions. This post shows when to use one and how to build it.

The gap a composite action fills

Think of the size scale. A single step is one run or one action. A job is a unit of environment. A workflow is a collection of jobs. In between, you sometimes want to group several steps that must execute in the same job and be reusable. That is the slot composite actions occupy — smaller than a reusable workflow, more substantial than a step, and idiomatic for "setup the toolchain" type logic.

The decisive difference from a reusable workflow: a composite action runs inside the calling job, so it shares the checkout, the working directory, and any environment the job already set. You pay no new runner startup cost and no separate checkout.

Structure of a composite action

A composite action lives in a directory with a action.yml file plus any scripts it needs. The action.yml declares name, description, inputs, and the steps to run under runs.using: composite. Here is a small example that sets up Node with caching:

yaml
name: setup-node-cache
description: checkout, setup node and install deps with cache
inputs:
  node-version:
    required: true
    type: string
runs:
  using: composite
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
    - run: npm ci
      shell: bash
      working-directory: ${{ github.action_path }}

One important detail: because a composite action runs inside the caller's job, run steps must specify a shell explicitly using the shell key; it does not inherit automatically. Here shell: bash is set. Inside the action folder, scripts referenced by a relative path resolve relative to the action root, which you can reach via github.action_path.

Publishing and calling the composite action

The action lives in a repository (or a subdirectory of one). Callers reference it with uses pointing at a ref, exactly like reusable workflows — but this time it appears as a step inside a job. A caller looks like this:

yaml
jobs:
  lint:
    runs-on: ubuntu-22.04
    steps:
      - uses: acme-org/setup-node-cache@v1
        with:
          node-version: 20
      - run: npm run lint
  test:
    runs-on: ubuntu-22.04
    steps:
      - uses: acme-org/setup-node-cache@v1
        with:
          node-version: 20
      - run: npm test

Both jobs reuse the same composite action, but each gets its own checkout and cache because each is its own runner. That is the trade-off you accept when grouping at the job boundary rather than the step boundary.

Passing inputs and secrets

Inputs are declared in action.yml and referenced inside the action as inputs.NAME. For secrets, there is no dedicated secrets container in a composite action — pass what you need as an input or read it from the environment. The cleanest pattern is to declare an input and forward the secret at the call site:

yaml
# action.yml
inputs:
  npm-token:
    required: false
    type: string
runs:
  using: composite
  steps:
    - run: echo "//npm.pkg.dev/:_authToken=${{ inputs.npm-token }}" > ~/.npmrc
      shell: bash

Callers then write with npm-token referring to a secret. Keep secrets out of logs by never echo-ing them intentionally, and be aware that downloading a composite action runs code from the referenced ref, so pin it and review what it executes.

Keeping composite actions maintainable

Prefer a composite action when the grouping is a well-understood, stable unit (toolchain setup, release-local test, deploy assist) that you reuse in multiple jobs. Resist making one action path-specific or branch-specific. If the grouping spans multiple operating systems or needs its own environment, that is the reusable-workflow tier again. Composite actions shine precisely because they are small, focused, and composable.

composite action action.yml + steps uses in job uses in job job B step job C step shares caller job working tree no new runner / no extra checkout smaller than a reusable workflow

Takeaway

When a step feels too small and a reusable workflow feels too big, reach for a composite action. It reuses a bundle of steps inside the same job with no runner startup and no extra checkout, and it keeps your pipeline files DRY. Testing a composite action against a real target is easy on a spare Netbay Ubuntu VPS — provision one in under 60 seconds at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance