Introduction
As your organization grows, you'll find similar workflow patterns repeated across many repositories. Reusable workflows let you define a workflow once and call it from multiple other workflows, applying the DRY (Don't Repeat Yourself) principle to your CI/CD.
This article explains how to create and use reusable workflows effectively.
Why Reusable Workflows?
flowchart TB
subgraph Before["Before: Duplicated Workflows"]
R1["Repo A: ci.yml<br/>(100 lines)"]
R2["Repo B: ci.yml<br/>(100 lines)"]
R3["Repo C: ci.yml<br/>(100 lines)"]
end
subgraph After["After: Reusable Workflow"]
Central["Central: build.yml<br/>(100 lines)"]
C1["Repo A: ci.yml<br/>(10 lines)"]
C2["Repo B: ci.yml<br/>(10 lines)"]
C3["Repo C: ci.yml<br/>(10 lines)"]
Central --> C1
Central --> C2
Central --> C3
end
style Before fill:#ef4444,color:#fff
style After fill:#22c55e,color:#fff
| Benefit | Description |
|---|---|
| Consistency | Same workflow logic across all repositories |
| Maintainability | Update once, apply everywhere |
| Reduced complexity | Caller workflows are simpler |
| Encapsulation | Hide implementation details |
Creating a Reusable Workflow
Basic Structure
A reusable workflow uses workflow_call trigger:
# .github/workflows/reusable-build.yml
name: Reusable Build Workflow
on:
workflow_call:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
Adding Inputs
Define inputs for customization:
name: Reusable Build
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version'
required: false
type: string
default: '20'
working-directory:
description: 'Working directory'
required: false
type: string
default: '.'
build-command:
description: 'Build command to run'
required: false
type: string
default: 'npm run build'
environment:
description: 'Deployment environment'
required: false
type: string
jobs:
build:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
- run: ${{ inputs.build-command }}
Input Types
| Type | Description | Example |
|---|---|---|
string |
Text value | 'production' |
boolean |
True/false | true |
number |
Numeric value | 42 |
Adding Secrets
Pass secrets securely:
on:
workflow_call:
inputs:
environment:
type: string
required: true
secrets:
npm-token:
description: 'NPM authentication token'
required: true
deploy-key:
description: 'Deployment SSH key'
required: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup NPM auth
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.npm-token }}" >> ~/.npmrc
- name: Deploy
if: secrets.deploy-key != ''
run: ./deploy.sh
env:
SSH_KEY: ${{ secrets.deploy-key }}
Inherit Secrets
Use secrets: inherit to pass all caller secrets:
on:
workflow_call:
secrets:
npm-token:
required: true
# Or use 'inherit' when calling to pass all secrets
Adding Outputs
Return values from reusable workflows:
name: Build and Get Version
on:
workflow_call:
outputs:
version:
description: 'The built version'
value: ${{ jobs.build.outputs.version }}
artifact-name:
description: 'Name of the uploaded artifact'
value: ${{ jobs.build.outputs.artifact-name }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
artifact-name: build-${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Get version
id: version
run: echo "version=$(cat package.json | jq -r .version)" >> $GITHUB_OUTPUT
- run: npm ci
- run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-${{ steps.version.outputs.version }}
path: dist/
Calling Reusable Workflows
Basic Call
name: CI
on:
push:
branches: [main]
jobs:
build:
uses: ./.github/workflows/reusable-build.yml
With Inputs and Secrets
name: CI
on:
push:
branches: [main]
jobs:
build:
uses: owner/repo/.github/workflows/reusable-build.yml@main
with:
node-version: '20'
working-directory: './frontend'
environment: 'production'
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}
deploy-key: ${{ secrets.DEPLOY_KEY }}
# Or inherit all secrets
build-inherit:
uses: ./.github/workflows/reusable-build.yml
with:
node-version: '20'
secrets: inherit
Using Outputs
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
uses: ./.github/workflows/reusable-build.yml
with:
node-version: '20'
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: ${{ needs.build.outputs.artifact-name }}
- name: Deploy version
run: echo "Deploying version ${{ needs.build.outputs.version }}"
Practical Examples
Reusable Node.js CI Workflow
# .github/workflows/reusable-node-ci.yml
name: Reusable Node.js CI
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
run-lint:
type: boolean
default: true
run-tests:
type: boolean
default: true
upload-coverage:
type: boolean
default: false
outputs:
test-result:
value: ${{ jobs.test.outputs.result }}
jobs:
lint:
if: inputs.run-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
if: inputs.run-tests
runs-on: ubuntu-latest
outputs:
result: ${{ steps.test.outputs.result }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- name: Run tests
id: test
run: |
npm test -- --coverage
echo "result=success" >> $GITHUB_OUTPUT
- name: Upload coverage
if: inputs.upload-coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
Reusable Docker Build and Push
# .github/workflows/reusable-docker.yml
name: Build and Push Docker Image
on:
workflow_call:
inputs:
image-name:
type: string
required: true
dockerfile:
type: string
default: 'Dockerfile'
context:
type: string
default: '.'
push:
type: boolean
default: false
platforms:
type: string
default: 'linux/amd64'
secrets:
registry-username:
required: true
registry-password:
required: true
outputs:
image-tag:
value: ${{ jobs.build.outputs.tag }}
image-digest:
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tags }}
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.registry-username }}
password: ${{ secrets.registry-password }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image-name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=semver,pattern={{version}}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Reusable Deployment Workflow
# .github/workflows/reusable-deploy.yml
name: Deploy to Environment
on:
workflow_call:
inputs:
environment:
type: string
required: true
artifact-name:
type: string
required: true
url:
type: string
required: true
secrets:
deploy-token:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ inputs.url }}
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ./dist
- name: Deploy
run: |
echo "Deploying to ${{ inputs.environment }}"
# Your deployment logic here
env:
DEPLOY_TOKEN: ${{ secrets.deploy-token }}
- name: Verify deployment
run: curl -f ${{ inputs.url }}/health
Starter Workflows
Create template workflows for your organization:
.github/
βββ workflow-templates/
β βββ node-ci.yml
β βββ node-ci.properties.json
β βββ docker-build.yml
β βββ docker-build.properties.json
node-ci.properties.json:
{
"name": "Node.js CI",
"description": "Standard Node.js CI workflow for the organization",
"iconName": "nodejs",
"categories": ["JavaScript", "Node"]
}
node-ci.yml:
name: Node.js CI
on:
push:
branches: [$default-branch]
pull_request:
branches: [$default-branch]
jobs:
build:
uses: org/.github/.github/workflows/reusable-node-ci.yml@main
with:
node-version: '20'
run-lint: true
run-tests: true
Limitations and Considerations
Nesting Depth
Reusable workflows can be nested up to 4 levels:
flowchart TB
A["Caller Workflow"] --> B["Reusable 1"]
B --> C["Reusable 2"]
C --> D["Reusable 3"]
D --> E["Reusable 4"]
E -.->|"NOT allowed"| F["Reusable 5"]
style E fill:#f59e0b,color:#fff
style F fill:#ef4444,color:#fff
Other Limitations
| Limitation | Description |
|---|---|
| Max nesting | 4 levels deep |
| Env variables | Cannot be set at caller job level |
| Strategy/matrix | Cannot use caller's matrix in reusable |
| Concurrency | Each workflow level has its own |
Workarounds
Pass matrix values as inputs:
# Caller
jobs:
test:
strategy:
matrix:
node: [18, 20, 22]
uses: ./.github/workflows/reusable-test.yml
with:
node-version: ${{ matrix.node }}
Best Practices
1. Version Your Workflows
# Use semantic versioning tags
uses: owner/repo/.github/workflows/build.yml@v1.2.0
# Or SHA for immutability
uses: owner/repo/.github/workflows/build.yml@abc123
2. Document Thoroughly
name: Reusable Build
# Add detailed description
# This workflow builds Node.js applications with customizable
# configuration for different environments.
on:
workflow_call:
inputs:
node-version:
description: |
Node.js version to use. Supports:
- LTS versions (18, 20)
- Current version (22)
type: string
default: '20'
3. Provide Sensible Defaults
inputs:
node-version:
default: '20' # Most common LTS
run-tests:
default: true # Safe default
deploy:
default: false # Dangerous operations off by default
4. Validate Inputs
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate environment
if: inputs.environment != 'staging' && inputs.environment != 'production'
run: |
echo "Invalid environment: ${{ inputs.environment }}"
exit 1
Summary
| Feature | Description |
|---|---|
| workflow_call | Trigger for reusable workflows |
| inputs | Customizable parameters |
| secrets | Secure credential passing |
| outputs | Return values to caller |
| secrets: inherit | Pass all caller secrets |
Reusable workflows help maintain consistency and reduce duplication across your organization's CI/CD pipelines.
References
- O'Reilly - Learning GitHub Actions, Chapter 12
- Packt - GitHub Actions Cookbook, Chapter 5
- GitHub Docs - Reusing Workflows