Reusable Workflows

Once you have more than one or two CI/CD pipelines, you start copy-pasting the same build, test, and deploy steps between workflow files. Reusable workflows solve this: you define a workflow once with the workflow_call trigger, and other workflows invoke it as if it were a single job, passing in typed inputs, secrets, and reading back outputs. This lesson covers how to design, call, version, and secure reusable workflows so they behave like a stable internal API rather than a fragile copy of YAML.

Overview / How it works

A reusable workflow is a normal workflow file that adds on: workflow_call as one of its triggers. That trigger can declare inputs (typed values passed in), secrets (explicitly declared secret names the caller must supply), and outputs (values the caller can read after the workflow finishes). A caller workflow then has a job whose uses: key points at the reusable workflow’s file path instead of running its own steps:. GitHub Actions runs the reusable workflow’s jobs as if they were jobs of the caller, on their own runner, with their own logs and job summary.

This is different from a composite action. A composite action runs its steps inside the caller’s existing job, on the caller’s existing runner, and is invoked from a step with uses:. A reusable workflow is invoked from a job, gets its own runner and its own permissions, can use its own environment: protection rules, and can itself contain multiple jobs with dependencies between them. Reusable workflows can call other reusable workflows, but GitHub limits the nesting depth to four levels, so treat deep chains as a design smell rather than a pattern to lean on.

Secrets are never inherited automatically. A reusable workflow only receives the secrets it explicitly declares under on.workflow_call.secrets, and the caller must either list them one by one under the job’s secrets: key or use the shorthand secrets: inherit, which forwards every secret the caller has access to. secrets: inherit is convenient, but it also means the called workflow can read secrets it never explicitly asked for, so it should only be used when caller and callee are maintained by the same trusted team.

Syntax or workflow structure

A reusable workflow definition declares its contract at the top of the file:

name: Reusable Build and Test

on:
  workflow_call:
    inputs:
      node-version:
        description: "Node.js version to use"
        required: false
        type: string
        default: "20"
      run-lint:
        description: "Whether to run the lint step"
        required: false
        type: boolean
        default: true
    secrets:
      NPM_TOKEN:
        description: "Token for the private npm registry"
        required: true

permissions:
  contents: read

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          registry-url: "https://registry.npmjs.org"

      - name: Install dependencies
        run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Lint
        if: ${{ inputs.run-lint }}
        run: npm run lint

      - name: Run tests
        run: npm test

Note the permissions: block at the workflow level. A reusable workflow should request only the permissions its own steps need, following the same least-privilege rule as any other workflow. When a caller invokes it, the effective permissions are the intersection of what the caller grants the job and what the reusable workflow itself declares — the callee can never escalate beyond what the caller allows, but it can further restrict itself, which is good practice for a shared workflow used by many teams.

Examples

Example 1: calling the workflow with a matrix. A job that calls a reusable workflow can still use strategy.matrix, so one caller job can invoke the reusable workflow multiple times with different inputs:

name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build-test:
    strategy:
      matrix:
        node-version: ["18", "20"]
    uses: ./.github/workflows/reusable-build-test.yml
    with:
      node-version: ${{ matrix.node-version }}
      run-lint: true
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Expected behavior: two separate calls to the reusable workflow run in parallel, one per Node.js version, each appearing as its own job in the run summary (for example build-test / build-test (18) and build-test / build-test (20)).

Example 2: returning outputs from a reusable deploy workflow. Outputs let the caller act on data produced deep inside the called workflow, such as a deployment URL:

name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      DEPLOY_HOST:
        required: true
      DEPLOY_TOKEN:
        required: true
    outputs:
      deployment-url:
        description: "Public URL of the deployed environment"
        value: ${{ jobs.deploy.outputs.url }}

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      url: ${{ steps.publish.outputs.url }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Deploy application
        id: publish
        run: |
          echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"

      - name: Health check
        run: curl --fail "https://${{ inputs.environment }}.example.com/healthz"

The caller reads that output through the usual needs context, using the reusable workflow’s job id as the step id:

jobs:
  deploy-staging:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
    secrets:
      DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

  announce:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - name: Print deployment URL
        run: echo "Deployed to ${{ needs.deploy-staging.outputs.deployment-url }}"

Expected behavior: the announce job waits for deploy-staging to finish, then prints the URL that the reusable workflow computed, without the caller needing to know how that URL was built.

Step by step

  1. Identify duplicated logic across two or more existing workflows — usually a build-and-test sequence or a deploy sequence.
  2. Create a new file such as .github/workflows/reusable-build-test.yml and add on: workflow_call alongside the steps you extracted.
  3. Replace any hard-coded values (Node version, environment name, feature flags) with inputs, giving each a type, a default where sensible, and a description.
  4. Declare every secret the reusable workflow’s steps actually use under on.workflow_call.secrets; do not assume secrets will simply be visible.
  5. Set the narrowest permissions: the steps need — usually contents: read, plus id-token: write only if the workflow performs OIDC authentication to a cloud provider.
  6. In each caller workflow, replace the duplicated steps with a job that has uses: ./.github/workflows/reusable-build-test.yml (same repository) or uses: org/repo/.github/workflows/file.yml@ref (different repository), plus a with: block for inputs and a secrets: block or secrets: inherit.
  7. If the reusable workflow produces a value another job needs, add it under on.workflow_call.outputs and reference it from the caller as needs.<job-id>.outputs.<name>.
  8. Tag a release (for example v1.0.0) once the reusable workflow’s contract is stable, and have callers pin to that tag or a commit SHA rather than a branch.

Common Mistakes

Mistake 1: assuming secrets are automatically available. A caller that omits the secrets: block entirely will invoke the reusable workflow with every declared secret empty, which usually fails silently until an authentication step errors out:

# Mistake: secrets are never passed to the called workflow
jobs:
  build-test:
    uses: ./.github/workflows/reusable-build-test.yml
    with:
      node-version: "20"
    # NPM_TOKEN is required by the reusable workflow but nothing is supplied here
# Fix: pass the secret explicitly, or inherit every secret the caller can see
jobs:
  build-test:
    uses: ./.github/workflows/reusable-build-test.yml
    with:
      node-version: "20"
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
    # Alternative, only within a trusted org/repo boundary:
    # secrets: inherit

Mistake 2: pinning a shared reusable workflow to a mutable branch. Pointing a production deploy at @main means a future commit to that branch — even an unrelated bug fix — changes production deploy behavior with no version history to review or roll back to:

# Mistake: a mutable ref for a production deployment
jobs:
  deploy-prod:
    uses: my-org/shared-workflows/.github/workflows/reusable-deploy.yml@main
    with:
      environment: production
    secrets: inherit
# Fix: pin to an immutable release tag or full commit SHA
jobs:
  deploy-prod:
    uses: my-org/shared-workflows/.github/workflows/reusable-deploy.yml@v1.4.0
    with:
      environment: production
    secrets: inherit

A tag is easier to read in a diff; a full commit SHA is immutable even if the tag is later moved. Choose based on how much you trust the shared-workflow repository’s tag discipline — for third-party or cross-team workflows, prefer the SHA.

Best Practices

  • Treat a reusable workflow’s inputs, secrets, and outputs as a public API: changing a required input’s name or removing an output breaks every caller, so add fields as optional first and deprecate slowly.
  • Set explicit, minimal permissions: inside the reusable workflow itself rather than relying on the caller to restrict it correctly.
  • Prefer explicit secrets: mappings over secrets: inherit when the reusable workflow is used outside your immediate team, so the exact trust boundary is visible in the caller’s YAML.
  • Version shared workflows with tags (or SHAs for stricter immutability) and document breaking changes in a changelog next to the workflow file.
  • Keep nesting shallow — one reusable workflow calling another is fine, but a long chain becomes hard to reason about and risks hitting the four-level nesting limit.
  • Use environment: protection rules inside the reusable deploy workflow’s job so required reviewers and wait timers apply no matter which caller invokes it.
  • Check what a shared workflow will do before wiring it into a pipeline that runs on every push — use the GitHub CLI to inspect its recent runs and definition:
gh workflow list
gh workflow view reusable-build-test.yml --repo my-org/shared-workflows
gh run list --workflow=ci.yml --limit 5

Practice Exercises

  1. Take two existing workflows in a repository that both check out code, install dependencies, and run tests, and extract the shared steps into a reusable workflow with a node-version input.
  2. Add a required secret to your reusable workflow, then write a caller that forgets to pass it; observe the failure, then fix the caller with an explicit secrets: mapping.
  3. Add an outputs entry to a reusable deploy workflow that returns a deployment URL, then add a second job in the caller that consumes that output and prints it.
  4. Convert a caller that repeats the same job twice for two environments into a single job using strategy.matrix against the reusable workflow.
  5. Publish your reusable workflow at tag v1.0.0, point a caller at that tag, then make a breaking change on main and confirm the caller is unaffected until it’s repointed.

Summary

Reusable workflows let you define a build, test, or deploy pipeline once, with a typed contract of inputs, secrets, and outputs, and call it from as many workflows as you need — including with a matrix to fan out across variants. They differ from composite actions by running as full jobs with their own runner and permissions, and from copy-pasted YAML by staying in one place to update. Treat the reusable workflow’s interface as an API: declare only the permissions and secrets it needs, version it deliberately, and pin callers to a stable tag or SHA so a change to the shared workflow never surprises a production deploy.