Build Reusable GitHub Actions with Composite Actions and workflow_call
Turn duplicated CI YAML into one composite action and one reusable workflow you can call from any repo.
What you'll build
A tiny Node project whose CI is split into two shareable pieces: a composite action that installs Node and dependencies, and a reusable workflow (triggered by workflow_call) that runs the tests. Your main CI file shrinks to six lines, and both pieces are ready to be called from other repos.
Prerequisites
- A GitHub account and git installed.
- Node.js 24 (current LTS) locally, with npm.
- The GitHub CLI (
gh), authenticated viagh auth login. - Verified against GitHub Actions docs as of September 2026,
actions/checkoutv7.0.1, andactions/setup-nodev7.0.0. Any OS works; commands assume a POSIX shell (on Windows, use Git Bash or WSL).
1. Scaffold a Node project
mkdir actions-reuse-demo && cd actions-reuse-demo
git init
npm init -y
npm pkg set scripts.test="node --test"
npm install --package-lock-only
mkdir -p test .github/actions/node-setup .github/workflows
The --package-lock-only flag generates package-lock.json without a node_modules folder. CI needs that lockfile because npm ci refuses to run without one.
Create test/math.test.js:
const test = require('node:test');
const assert = require('node:assert');
test('adds two numbers', () => {
assert.strictEqual(1 + 1, 2);
});
2. Create the composite action
A composite action bundles several steps into one uses: line. Create .github/actions/node-setup/action.yml:
name: Node setup
description: Install a pinned Node.js version and project dependencies
inputs:
node-version:
description: Node.js version to install
required: false
default: '24'
runs:
using: composite
steps:
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
shell: bash
Two rules trip people up here. The file must be named action.yml (or action.yaml), and every run: step needs an explicit shell:, because a composite action doesn't know what runner it'll execute on.
3. Create the reusable workflow
A reusable workflow goes further than a composite action: it defines whole jobs, including the runner. The workflow_call trigger is what makes it callable. Create .github/workflows/ci-reusable.yml:
name: Reusable Node CI
on:
workflow_call:
inputs:
node-version:
description: Node.js version to test against
required: false
type: string
default: '24'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/node-setup
with:
node-version: ${{ inputs.node-version }}
- run: npm test
Note the layering: the reusable workflow owns checkout and the runner, then delegates setup to the composite action. Callers only see one input.
4. Call it from your CI workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
jobs:
test:
uses: ./.github/workflows/ci-reusable.yml
with:
node-version: '24'
The uses: sits at the job level, not inside steps:. Local reusable workflows use the ./ prefix with no @ref; cross-repo calls use the full form owner/repo/.github/workflows/ci-reusable.yml@v1.
5. Push and run
git add -A
git commit -m "CI via composite action and reusable workflow"
gh repo create actions-reuse-demo --private --source=. --push
The push to main triggers the CI workflow immediately.
Verify it works
gh run watch
You should see the nested job name and a green result:
✓ main CI · 17234567890
Triggered via push about 1 minute ago
JOBS
✓ test / test in 24s (ID 48123456789)
Open the job log (gh run view --log) and confirm the test output:
✔ adds two numbers (0.8ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
Troubleshooting
error parsing called workflow ... workflow was not found: the path or ref is wrong. Local calls must start with./and take no@ref; cross-repo calls need the fullowner/repo/.github/workflows/file.yml@refpath. If the workflow lives in another private repo, enable access under that repo's Settings > Actions > General > Access ("Accessible from repositories owned by ..."), which only works within the same user account or organization.Required property is missing: shell: arun:step in your composite action lacksshell: bash. Composite actions require it on everyrun:step; there's no default.npm cifails withThe 'npm ci' command can only install with an existing package-lock.json: you didn't commit the lockfile. Runnpm install --package-lock-only, commit, and push.- Secrets are empty inside the called workflow: reusable workflows don't see the caller's secrets by default. Add
secrets: inheritto the calling job, or declare each secret underon.workflow_call.secretsand pass it explicitly. Passing secrets viawith:won't work; inputs and secrets are separate channels.
Next steps
To share across repos, tag releases of the repo holding your reusable workflow (git tag v1) and call it by tag, so consumers upgrade deliberately. Reference the composite action cross-repo as owner/repo/.github/actions/node-setup@v1; inside a shared reusable workflow, prefer that full form over ./ paths, which resolve against whatever repo the caller checked out. From there, read GitHub's docs on reusing workflows for outputs, matrix calls, and the ten-level nesting limit.
Sources & further reading
- Reuse workflows — docs.github.com
- Create a composite action — docs.github.com
- Share across private repositories — docs.github.com
- actions/checkout releases (v7.0.1) — github.com
- actions/setup-node releases (v7.0.0) — github.com
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 0
No comments yet
Be the first to weigh in.