Once you have more than a couple of repos, the same 30 lines of YAML start showing up in each ci.yml. GitHub Actions provides three reusability primitives.
1. Composite Actions
A composite action bundles several steps into a single uses: step. Perfect for things like "set up Node, install deps, cache".
File layout:
my-org/setup-app/
├── action.yml
└── README.md
# action.yml
name: Setup App
description: Checkout, install Node, install deps with cache
inputs:
node-version:
description: Node.js version
required: false
default: '20'
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
Use it from any workflow:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: my-org/setup-app@v1
with:
node-version: '22'
- run: npm test
Trade-offs:
- ✅ Simple, lightweight, no separate runner
- ✅ Can be in the same repo (
uses: ./.github/actions/setup-app) - ❌ Can't have its own jobs — only steps
- ❌ Secrets aren't passed automatically; you must forward them as inputs
2. Reusable Workflows
A reusable workflow is a complete workflow that can be called by other workflows. It runs in its own job and can have its own concurrency, environments, and matrix.
# .github/workflows/build-and-test.yml in shared repo
name: Build & Test (reusable)
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
secrets:
NPM_TOKEN:
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm test
Call it from another workflow:
jobs:
ci:
uses: my-org/shared-workflows/.github/workflows/build-and-test.yml@v1
with:
node-version: '20'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Trade-offs:
- ✅ Encapsulates whole jobs, including matrix and parallelism
- ✅ Single source of truth for organisational pipelines
- ✅ Can require approvals and use environments
- ❌ Slightly heavier; runs as a child workflow with its own setup
3. JavaScript / Docker Actions
For logic that's awkward in YAML — parsing JSON, calling APIs, complex string handling — write the action in code.
my-org/release-notes/
├── action.yml
├── package.json
├── src/
│ └── index.js
# action.yml
name: Generate Release Notes
inputs:
from-tag:
required: true
runs:
using: 'node20'
main: 'dist/index.js'
// src/index.js
import * as core from '@actions/core';
import * as github from '@actions/github';
const fromTag = core.getInput('from-tag');
const token = process.env.GITHUB_TOKEN;
const octokit = github.getOctokit(token);
const { data } = await octokit.rest.repos.compareCommits({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
base: fromTag,
head: 'HEAD',
});
core.setOutput('notes', data.commits.map(c => '- ' + c.commit.message).join('\n'));
Docker actions work similarly but run in a container — useful for non-Node tooling. They're slower (image pull) but trivially portable.
Choosing
| Need | Best fit |
|---|---|
| "Run these 5 setup steps everywhere" | Composite action |
| "Whole CI pipeline shared across repos" | Reusable workflow |
| "Call an API and produce structured output" | JavaScript action |
| "Run a shell tool that needs a specific environment" | Docker action |
Versioning Shared Actions
Treat shared actions as products: tag releases, write release notes, and publish.
- Major tags (
@v1) auto-update — convenient but trust-based - Pinned SHAs (
@a1b2c3d) — immutable, secure, reproducible — preferred for production critical workflows - Use Dependabot to keep SHAs current safely
Centralised Pipelines: An Org Pattern
Many large orgs run a single shared-workflows repo:
shared-workflows/
└── .github/
└── workflows/
├── nodejs-build-test.yml
├── docker-build-push.yml
├── terraform-plan-apply.yml
└── compliance-scan.yml
Application repos call into them with three lines of YAML. Adding a new security scan? Update one file. Hundreds of repos pick it up automatically.
Permissions and Secrets
- Reusable workflows must be in a public repo or in the same org with permission granted
- Inputs are public; sensitive data goes through
secrets: - Use
secrets: inheritto forward all caller secrets — convenient but be deliberate - Restrict who can edit shared workflow repos with branch protection and CODEOWNERS
Anti-Patterns
- Copy-pasting the same 50 lines of YAML across 30 repos. Make it a reusable workflow.
- Reusable workflow that takes 20 inputs — usually a sign you should split it
- Hardcoded secrets in actions — always pass via
secrets: - One mega-workflow that does everything — split by concern; small composable pieces beat monoliths in CI too
Reusability is what turns CI from a per-repo chore into a platform.