GitHub Actions Reusable Workflows: Inputs, Secrets & Composition
Build maintainable CI/CD by creating reusable workflows with typed inputs, secret inheritance, and output chaining across repositories.
What you'll learn
- ✓How to define a reusable workflow with workflow_call triggers
- ✓Passing typed inputs and secrets between caller and callee
- ✓Chaining workflow outputs for multi-stage pipelines
- ✓Versioning strategies for shared workflow repositories
- ✓Composing multiple reusable workflows into a single pipeline
Prerequisites
- •Basic GitHub Actions YAML experience
- •Familiarity with Git branching
Managing dozens of repositories with nearly identical CI/CD pipelines is a maintenance nightmare. When a security patch requires updating a checkout action version, you end up opening pull requests across every repo. Reusable workflows let you define a pipeline once in a central repository and call it from any other repo, keeping your CI/CD DRY and auditable.
Defining a Reusable Workflow
A reusable workflow is a standard workflow file that uses the workflow_call trigger. Place it in a dedicated repository (e.g., your-org/shared-workflows) under .github/workflows/.
# .github/workflows/node-ci.yml (in your-org/shared-workflows)
name: Node.js CI
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version to use'
required: false
type: string
default: '20'
working-directory:
description: 'Directory containing package.json'
required: false
type: string
default: '.'
run-lint:
description: 'Whether to run linting'
required: false
type: boolean
default: true
secrets:
NPM_TOKEN:
description: 'npm registry auth token'
required: false
outputs:
test-result:
description: 'Test suite pass/fail status'
value: ${{ jobs.test.outputs.result }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
result: ${{ steps.test-step.outputs.status }}
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
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- if: ${{ inputs.run-lint }}
run: npm run lint
- id: test-step
run: |
npm test && echo "status=pass" >> "$GITHUB_OUTPUT" || echo "status=fail" >> "$GITHUB_OUTPUT"
Key points about this definition:
- Inputs are typed. You can use
string,boolean, ornumber. The caller gets validation before the workflow runs. - Secrets are declared explicitly. The called workflow cannot access the caller’s secrets unless they are passed through.
- Outputs flow upward. Job outputs are mapped to workflow outputs so the caller can use them.
Calling a Reusable Workflow
From any repository, reference the reusable workflow with its full path and ref:
# .github/workflows/ci.yml (in your-org/my-app)
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
run-tests:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@v2
with:
node-version: '22'
run-lint: true
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
deploy:
needs: run-tests
if: github.ref == 'refs/heads/main' && needs.run-tests.outputs.test-result == 'pass'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Deploying after successful tests..."
The @v2 ref can be a tag, branch, or full SHA. Using a tag is the recommended approach for stability.
Secret Inheritance with inherit
If you trust the called workflow and want to pass all secrets without listing them individually, use the inherit keyword:
jobs:
run-tests:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@v2
with:
node-version: '20'
secrets: inherit
This passes every secret the caller has access to. It is convenient but less explicit. For production pipelines where auditability matters, prefer listing secrets individually.
Chaining Multiple Reusable Workflows
You can compose a full pipeline from multiple reusable workflows, each handling a different concern:
name: Full Pipeline
on:
push:
branches: [main]
jobs:
lint-and-test:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@v2
with:
node-version: '22'
secrets: inherit
security-scan:
uses: your-org/shared-workflows/.github/workflows/security-scan.yml@v2
secrets: inherit
build-docker:
needs: [lint-and-test, security-scan]
uses: your-org/shared-workflows/.github/workflows/docker-build.yml@v2
with:
image-name: 'my-app'
push: true
secrets: inherit
deploy-staging:
needs: build-docker
uses: your-org/shared-workflows/.github/workflows/deploy-k8s.yml@v2
with:
environment: staging
image-tag: ${{ needs.build-docker.outputs.image-tag }}
secrets: inherit
Each uses job is isolated. The needs keyword enforces ordering, and outputs from one workflow feed into inputs of the next.
Versioning Strategies
How you version your shared workflows directly affects stability across consuming repositories.
Git Tags (Recommended)
Create semver tags on your shared-workflows repo:
git tag -a v2.1.0 -m "Add Docker build caching"
git push origin v2.1.0
Consumers reference @v2 (a moving major tag) or @v2.1.0 (pinned). Create a moving major tag that points to the latest minor:
git tag -f v2 v2.1.0
git push --force origin v2
SHA Pinning for Maximum Security
For regulated environments, pin to the full commit SHA:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
This guarantees no one can alter the workflow code without changing the reference. Tools like Dependabot or Renovate can automate SHA updates.
Branch References for Development
During development of new workflow features, reference a branch:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@feature/add-coverage
Never use branch references in production workflows.
Handling Permissions
Reusable workflows run with the permissions of the caller. If your reusable workflow needs to write packages or create deployments, the caller must grant those permissions:
jobs:
deploy:
uses: your-org/shared-workflows/.github/workflows/deploy.yml@v2
permissions:
contents: read
packages: write
deployments: write
secrets: inherit
Limitations to Know
Reusable workflows have a few constraints that affect how you design them:
- Nesting depth is limited to 4 levels. A reusable workflow can call another reusable workflow, but you cannot go deeper than four levels.
- Maximum of 20 unique reusable workflows per file. A single workflow file can reference up to 20 different reusable workflows.
- Environment variables set in the caller are not inherited. Only inputs and secrets are passed. If the called workflow needs
envvalues, pass them as inputs. - Reusable workflows must be in a public repo or the same org. Private repos in different organizations cannot share workflows.
Debugging Failed Calls
When a reusable workflow call fails, the error messages can be cryptic. Common issues:
# Error: "Input required and not provided"
# Fix: Check that all required inputs have values
jobs:
ci:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@v2
with:
node-version: '20' # Don't forget required inputs
# Error: "Workflow not found or not accessible"
# Fix: Verify the path, ref, and repo visibility
# The workflow file must exist at the exact path in the referenced ref
Enable debug logging by setting the ACTIONS_STEP_DEBUG repository secret to true. This adds verbose output for every step in the called workflow.
Practical Pattern: Monorepo with Shared Workflows
For a monorepo with multiple services, create a matrix caller that invokes the shared workflow for each service:
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
web:
- 'services/web/**'
worker:
- 'services/worker/**'
test-service:
needs: detect-changes
if: needs.detect-changes.outputs.services != '[]'
strategy:
matrix:
service: ${{ fromJson(needs.detect-changes.outputs.services) }}
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@v2
with:
working-directory: 'services/${{ matrix.service }}'
secrets: inherit
This runs the shared CI workflow only for services that have changed files, saving both time and runner minutes.
Summary
Reusable workflows transform CI/CD from a per-repo copy-paste exercise into a centrally managed, versioned infrastructure. Define workflows in a shared repository, version them with semver tags, pass inputs and secrets explicitly, and chain them with needs for complex pipelines. The upfront investment pays off the moment you need to update a workflow step across ten or more repositories.
Related articles
- CI/CD GitHub Actions vs Jenkins: CI/CD Compared
Compare GitHub Actions and Jenkins for CI/CD pipelines. Covers setup, configuration, plugins, pricing, and when to choose each tool for your team.
- CI/CD GitHub Actions: Complete CI/CD Guide for Any Project
Set up GitHub Actions CI/CD from scratch — workflows, jobs, caching, secrets, matrix builds, deployment, and reusable patterns for any language or framework.
- CI/CD CI/CD Artifact Management: Caching, Storage & Build Outputs
Master CI/CD artifact management with dependency caching, build artifact storage, retention policies, and cross-job artifact sharing patterns.
- CI/CD CI/CD Pipeline Best Practices for Faster Deployments
Build fast, reliable CI/CD pipelines — parallel stages, caching, fast feedback, trunk-based development, and deployment automation patterns.