Skip to content
Français
CI/CD & Automatisation medium

Reusable workflows in GitHub Actions

40 min de lecture

Read this page in French

Reusable workflows let you factor out common CI/CD logic. Instead of copying the same jobs into ten repositories, you build one central workflow that all the others can call.

What you will learn

  • Create a reusable workflow with the workflow_call trigger
  • Define typed inputs, secrets and outputs
  • Call a reusable workflow from another repository or locally
  • Pass secrets explicitly or through secrets: inherit
  • Chain and version your CI/CD templates
  • Know the limitations: nesting depth, dynamic secrets

This guide is for people maintaining several pipelines. If you are starting out, read Conditions and if first.

Why reuse workflows?

The problem: you have 50 microservices with nearly identical pipelines. Every change (a new Node version, an added security scan) means editing 50 files.

The answer: a reusable workflow in a central repository:

org/ci-templates/
└── .github/workflows/
├── node-ci.yml # Standard Node.js CI
├── python-ci.yml # Standard Python CI
└── docker-build.yml # Image build and push

Each microservice calls those templates:

# In every repository: 5 lines instead of 100
jobs:
ci:
uses: org/ci-templates/.github/workflows/node-ci.yml@v1.0.0
with:
node-version: '20'
secrets: inherit

Creating a reusable workflow

A reusable workflow is a workflow file like any other, with one difference: its trigger makes it callable by other workflows.

The basic structure

A reusable workflow uses the workflow_call trigger:

.github/workflows/reusable-ci.yml
name: Reusable CI
on:
workflow_call: # This trigger makes the workflow callable
inputs:
node-version:
description: 'Node.js version'
required: false
type: string
default: '20'
secrets:
npm-token:
description: 'NPM token used to publish'
required: false
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test

Defining the inputs

Inputs make the workflow configurable:

on:
workflow_call:
inputs:
# A required input
environment:
description: 'Deployment environment'
required: true
type: string
# An optional input with a default value
node-version:
description: 'Node.js version'
required: false
type: string
default: '20'
# A boolean input
skip-tests:
description: 'Skip the tests'
required: false
type: boolean
default: false
# A numeric input
timeout:
description: 'Timeout in minutes'
required: false
type: number
default: 30

The available input types are string, boolean and number.

Defining the secrets

Secrets are declared separately from inputs:

on:
workflow_call:
secrets:
# A required secret
deploy-key:
description: 'SSH deployment key'
required: true
# An optional secret
slack-webhook:
description: 'Slack webhook for notifications'
required: false

Defining the outputs

Outputs return values to the calling workflow:

on:
workflow_call:
outputs:
version:
description: 'The version that was built'
value: ${{ jobs.build.outputs.version }}
image-digest:
description: "The Docker image digest"
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.value }}
digest: ${{ steps.push.outputs.digest }}
steps:
- id: version
run: echo "value=$(cat VERSION)" >> $GITHUB_OUTPUT
- id: push
run: echo "digest=sha256:abc123" >> $GITHUB_OUTPUT

Calling a reusable workflow

On the calling side, a reusable workflow is invoked in place of a job's steps:, with the uses: keyword.

The call syntax

The calling job holds no steps:, it delegates the work entirely to the reusable workflow.

name: CI
on: [push, pull_request]
jobs:
call-workflow:
uses: owner/repo/.github/workflows/reusable.yml@v1.0.0
with:
node-version: '20'
environment: 'production'
secrets:
deploy-key: ${{ secrets.DEPLOY_KEY }}

Referencing the workflow

Several formats are possible:

# Same repository (relative path)
uses: ./.github/workflows/reusable.yml
# External repository with a branch (mobile, avoid in production)
uses: org/repo/.github/workflows/reusable.yml@main
# External repository with a tag
uses: org/repo/.github/workflows/reusable.yml@v1.0.0
# External repository with a SHA (recommended for security)
uses: org/repo/.github/workflows/reusable.yml@a1b2c3d4e5f6

Passing the secrets

There are three ways to pass secrets to a reusable workflow.

1. Explicit secrets: you pass by name exactly what the template needs, and nothing else.

jobs:
call:
uses: org/repo/.github/workflows/reusable.yml@v1.0.0
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}
deploy-key: ${{ secrets.DEPLOY_KEY }}

2. Inherit every secret: secrets: inherit passes the whole secret set of the parent repository. Convenient, and the broadest option there is.

jobs:
call:
uses: org/repo/.github/workflows/reusable.yml@v1.0.0
secrets: inherit

3. No secret at all: if the reusable workflow needs none, pass none.

jobs:
call:
uses: org/repo/.github/workflows/reusable.yml@v1.0.0

Using the outputs

The calling workflow reads the values returned by the reusable workflow through needs.<job>.outputs.

jobs:
build:
uses: org/repo/.github/workflows/build.yml@v1.0.0
deploy:
needs: build
runs-on: ubuntu-24.04
env:
VERSION: ${{ needs.build.outputs.version }}
DIGEST: ${{ needs.build.outputs.image-digest }}
steps:
- run: |
echo "Deploying version $VERSION"
echo "Image digest: $DIGEST"

A complete example

Here is a real end-to-end case: a Node.js CI template and the workflow of a service that calls it.

The reusable workflow (the template)

This template centralises Node.js linting and testing, driven by inputs.

org/ci-templates/.github/workflows/node-ci.yml
name: Node.js CI
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
working-directory:
type: string
default: '.'
run-tests:
type: boolean
default: true
run-lint:
type: boolean
default: true
secrets:
npm-token:
required: false
outputs:
test-passed:
description: 'Whether the tests passed'
value: ${{ jobs.test.outputs.passed }}
permissions:
contents: read
jobs:
lint:
if: ${{ inputs.run-lint }}
runs-on: ubuntu-24.04
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
- run: npm run lint
test:
if: ${{ inputs.run-tests }}
runs-on: ubuntu-24.04
outputs:
passed: ${{ steps.test.outputs.passed }}
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
- id: test
run: |
npm test
echo "passed=true" >> $GITHUB_OUTPUT

The calling workflow

On the service side, the pipeline fits in a few lines: it calls the template then chains a conditional deployment.

my-service/.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: org/ci-templates/.github/workflows/node-ci.yml@v1.0.0
with:
node-version: '20'
run-lint: true
run-tests: true
secrets: inherit
deploy:
needs: ci
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
env:
TESTS_PASSED: ${{ needs.ci.outputs.test-passed }}
steps:
- run: echo "Tests passed: $TESTS_PASSED"
- run: ./deploy.sh

Advanced patterns

Once the basics are in place, these patterns cover the cases you meet across a real portfolio of projects.

A workflow with a matrix

A reusable workflow can hold a matrix:

reusable-test.yml
on:
workflow_call:
inputs:
os:
type: string
default: '["ubuntu-24.04"]'
node:
type: string
default: '["18", "20"]'
jobs:
test:
strategy:
matrix:
os: ${{ fromJSON(inputs.os) }}
node: ${{ fromJSON(inputs.node) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test

Calling it with a custom matrix:

jobs:
test:
uses: org/templates/.github/workflows/reusable-test.yml@v1.0.0
with:
os: '["ubuntu-24.04", "windows-2025"]'
node: '["18", "20", "22"]'

Chaining several reusable workflows

Several reusable workflows chain with needs, exactly like ordinary jobs.

jobs:
lint:
uses: org/templates/.github/workflows/lint.yml@v1.0.0
test:
needs: lint
uses: org/templates/.github/workflows/test.yml@v1.0.0
build:
needs: test
uses: org/templates/.github/workflows/build.yml@v1.0.0
with:
version: ${{ needs.test.outputs.version }}
deploy:
needs: build
uses: org/templates/.github/workflows/deploy.yml@v1.0.0
with:
image: ${{ needs.build.outputs.image }}
secrets:
deploy-key: ${{ secrets.DEPLOY_KEY }}

A reusable workflow with permissions

A reusable workflow declares its own permissions:, and the calling workflow must grant at least that level.

reusable-deploy.yml
on:
workflow_call:
inputs:
environment:
type: string
required: true
# Permissions declared inside the reusable workflow
permissions:
contents: read
id-token: write # For OIDC
jobs:
deploy:
runs-on: ubuntu-24.04
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Deploy
run: ./deploy.sh

Good practices

Four rules avoid the costliest traps once your templates are shared by many repositories.

1. Version your templates

A reusable workflow is third-party code: it is pinned exactly like an action. Never call a template at @main, a mobile reference the maintainer can point anywhere. Use a version tag or, better, a commit SHA:

# Acceptable: a version tag
uses: org/templates/.github/workflows/ci.yml@v1.2.0
# Safest: a commit SHA
uses: org/templates/.github/workflows/ci.yml@a1b2c3d4e5f6

The pinning principle and its tooling are covered in Pinning actions by SHA.

2. Document the inputs and outputs

A clear description on every input saves consuming teams from reading the template's code.

on:
workflow_call:
inputs:
environment:
description: |
Target environment for the deployment.
Accepted values: staging, production.
Default: staging.
type: string
default: 'staging'

3. Pick sensible defaults

Well-chosen default: values make the template usable without configuration in the most common case.

inputs:
node-version:
type: string
default: '20' # The current LTS
timeout:
type: number
default: 30 # Reasonable for most builds

4. Restrict the permissions

Declare the minimum at workflow level, and widen only on the job that genuinely needs it.

# Inside the reusable workflow
permissions:
contents: read # The minimum, by default
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # Only where required
steps:
- run: ./publish.sh

Limitations

Reusable workflows carry a few structural constraints, and it is better to know them before building a whole architecture on top.

  • Nesting depth: reusable workflows can nest, but over four levels maximum
  • Call count: at most 20 unique reusable workflows in the call tree
  • Secrets: secrets cannot be passed dynamically through expressions
  • Permissions: the calling workflow must hold at least the permissions the reusable workflow requires

Key points

  • A reusable workflow is declared with the workflow_call trigger and called through uses: in place of a job's steps:.
  • Inputs are typed (string, boolean, number); secrets are declared separately.
  • Outputs flow back to the calling workflow, which reads them through needs.<job>.outputs.
  • secrets: inherit passes every secret of the parent repository: convenient, but to be avoided when the template needs one or two.
  • Version your templates by semver tag or by SHA, never @main in production.
  • Limits to know: nesting over four levels maximum, 20 unique reusable workflows per call tree, no secret passed dynamically.

For the complete reference, see the official documentation on reusing workflows.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn