Composite actions: bundling steps with zero runtime overhead
A composite action packages a sequence of steps into one reusable action.yml with no separate runtime — it runs inline on the host runner in milliseconds, but inherits the runner shell and shares the filesystem instead of isolating dependencies.
The same eleven-step setup block — checkout, install a toolchain, restore a cache, authenticate to the registry, print a version banner — was copy-pasted across nineteen workflows in the monorepo. When the toolchain bumped a major version, the platform team edited it in nineteen files; three got missed and shipped broken nightly builds for a week. The fix was not a JavaScript action and not a Docker image: it was a composite action — those eleven steps lifted verbatim into a single action.yml, published as org/setup-toolchain@v2, and called as one uses: line. No new runtime, no bundling step, no container pull. The first run after the migration was faster than the inline version it replaced, because the only thing the composite added was a layer of indirection that the runner resolves before any step executes. The brittleness was never in the steps — it was in having nineteen copies of them.
By the end of this lesson you will know exactly when a composite action is the right tool, what two authoring rules catch experienced engineers off-guard, and where its zero-isolation design becomes a liability rather than a feature.
What a composite action actually is
Why bother with a composite at all — why not just a reusable workflow or a script? Because a composite is the only type that adds zero runtime cost: when you see eleven copy-pasted steps becoming a maintenance burden, a composite collapses them into one uses: line without spawning a container or a Node process. Here is exactly how that works.
A composite action is metadata, not a program. Its action.yml declares runs.using: "composite" and a list of steps, and at call time GitHub Actions splices those steps into the calling job as if you had written them inline — they execute on the same runner, in the same workspace, under the same $GITHUB_WORKSPACE. There is no Node.js process to boot and no container image to pull, so the overhead of being an action is essentially a YAML parse: single-digit milliseconds against a job that already costs seconds.
# .github/actions/setup-toolchain/action.yml
name: "Setup Toolchain"
description: "Install the toolchain, restore cache, authenticate"
inputs:
version:
description: "Toolchain version"
required: false
default: "20"
outputs:
cache-hit:
description: "Whether the dependency cache was restored"
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.version }}
- id: cache
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ hashFiles('package-lock.json') }}
- name: Install
shell: bash
run: npm ciTwo rules bite senior authors here. First, every run: step inside a composite must declare shell: — there is no job-level default to inherit, so a bare run: block fails validation. Second, inputs are not environment variables: inside the composite you read them as ${{ inputs.version }}, not $VERSION, and to surface a value to the caller you wire it through outputs[].value referencing a step’s output, as the cache-hit plumbing above shows.
A composite action's action.yml has a step with `run: npm ci` and no other keys. The workflow fails at action load with a validation error. What is missing?
The tradeoff: speed bought with shared state
The thing a composite gives you for free is also the thing that can hurt: no isolation. It runs in the caller’s environment, sees the caller’s files, and mutates the caller’s filesystem. If your composite runs npm install -g some-cli, that binary is now on PATH for every later step in the job — convenient until two composites install conflicting global versions. There is no node_modules sandbox, no dependency vendoring; a composite that depends on jq assumes jq is already on the runner. This is fine for orchestration — sequencing tools the runner already has — and wrong for logic that needs its own pinned dependencies. That latter case is exactly what JavaScript and Docker actions exist for.
When you install a global CLI in a composite, ask yourself: will the next composite in this job expect a clean PATH, or is it fine if your binary is already there? That question surfaces the real danger before it bites you in production.
Two further sharp edges. Composites can nest and call other actions, but error handling is coarse: a failed step aborts the composite the way a failed job step aborts a job, and pre-1.0 you could not even use continue-on-error per step (now supported, but if: conditions on composite steps still evaluate differently from workflow steps — they do not see job-level success()/failure() state the same way). And security pinning applies recursively: when your composite does uses: actions/cache@v4, that tag is mutable. For anything touching credentials, pin nested actions by full commit SHA — uses: actions/cache@<40-char-sha> — so a compromised tag in a dependency cannot inject code into every workflow that calls your composite.
Your team needs a reusable action that performs non-trivial logic with its own pinned third-party library dependencies, isolated from whatever the runner happens to have installed. Why is a composite action a poor fit?
- 01What runtime does a composite action use, and what overhead does it add to a job compared with a JavaScript or Docker action?
- 02Name three things that bite authors of composite actions specifically, beyond what a normal workflow step requires.
A composite action is the lightest way to make GitHub Actions steps reusable: its action.yml sets runs.using: "composite" and lists steps, and at call time those steps splice into the invoking job and run inline on the same host runner. There is no separate runtime — no Node process, no container — so the overhead of being an action is a YAML parse, single-digit milliseconds against a job already measured in seconds; a migration from copy-pasted inline steps to a composite is typically a net speedup plus the elimination of N divergent copies. The price is the absence of isolation: the action shares the runner’s shell, PATH, and $GITHUB_WORKSPACE, so global installs leak into later steps and the action presumes its tools already exist on the runner rather than vendoring its own dependency tree. That makes composites ideal for orchestrating tools the runner has and wrong for self-contained logic with pinned third-party libraries — the job JavaScript and Docker actions take. Authoring gotchas that catch seniors: every run: step needs an explicit shell: because there is no inherited default; inputs are read as ${{ inputs.name }}, not env vars, and outputs are surfaced through outputs[].value bound to a step output; and nested uses: references are mutable tags that should be pinned by full 40-character commit SHA whenever credentials are anywhere near the workflow, so a compromised dependency tag cannot inject code into every caller. Now when you see copy-pasted setup blocks across workflows, you know to reach for a composite — and you know to pin nested actions by SHA before the first consumer ever ships credentials through it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.