Skip to content
Pipelines and Pizza 🍕
Go back

GitHub Actions Reusable Workflows

12 min read

Last month I audited a customer’s GitHub organization and found forty-seven repositories with nearly identical CI/CD workflow files. Same Terraform plan/apply logic, same Ansible lint steps, same Docker build-and-push pattern — copied, pasted, and slowly drifting apart. One repo had a bug fix in the checkout step that never made it to the other forty-six. Another had a hardcoded runner label from a server that was decommissioned six months ago.

I’ve done this myself, too. You build a workflow for one project, it works great, so you copy it to the next repo. Then the next. Before long you’re maintaining dozens of slightly different versions of the same pipeline and every change becomes a game of find-and-replace across repositories.

GitHub Actions reusable workflows solve this problem. You write the workflow once, publish it in a central location, and every repo calls it with a single uses: line. One place to update, one place to fix bugs, one place to enforce standards.

Let’s dig in.


How Reusable Workflows Work

A reusable workflow is a standard GitHub Actions workflow file with one key difference: it uses the workflow_call trigger instead of (or in addition to) triggers like push or pull_request. This tells GitHub “other workflows are allowed to call me.”

There are two roles:

  • Called workflow (the reusable one) — defines the workflow_call trigger and does the actual work.
  • Caller workflow — references the called workflow with uses: inside a job.

The called workflow runs in the context of the caller. The github context (repo, branch, actor, event) comes from the caller, not the called workflow’s repository. This is important — it means actions/checkout@v4 inside a reusable workflow checks out the caller’s code, not the reusable workflow’s code.


Your First Reusable Workflow

Let’s start simple. Here’s a reusable workflow that runs a Terraform plan:

The Called Workflow (Reusable)

File: my-org/shared-workflows/.github/workflows/terraform-plan.yml

name: Terraform Plan (Reusable)

on:
  workflow_call:
    inputs:
      working-directory:
        description: "Directory containing Terraform files"
        required: true
        type: string
      terraform-version:
        description: "Terraform version to install"
        required: false
        type: string
        default: "1.9.0"
    secrets:
      ARM_CLIENT_ID:
        required: true
      ARM_CLIENT_SECRET:
        required: true
      ARM_SUBSCRIPTION_ID:
        required: true
      ARM_TENANT_ID:
        required: true

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - name: Checkout caller repo
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ inputs.terraform-version }}

      - name: Terraform Init
        env:
          ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
        run: terraform init

      - name: Terraform Plan
        env:
          ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
        run: terraform plan -out=tfplan

The Caller Workflow

File: my-app-repo/.github/workflows/ci.yml

name: CI Pipeline

on:
  pull_request:
    branches: [main]

jobs:
  terraform-plan:
    uses: my-org/shared-workflows/.github/workflows/[email protected]
    with:
      working-directory: infra/azure
      terraform-version: "1.9.0"
    secrets:
      ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
      ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
      ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}

That’s it. The caller is eight lines of meaningful configuration. All the Terraform setup logic, the init, the plan — that lives in the shared workflow and every repo gets the exact same behavior.


Inputs, Outputs, and Secrets

Reusable workflows support three mechanisms for passing data:

Inputs

Defined under on.workflow_call.inputs. Each input has a type (string, number, or boolean), an optional default, and a required flag.

on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
      dry-run:
        type: boolean
        required: false
        default: false

Inside the called workflow, reference them with ${{ inputs.environment }}.

Secrets

Defined under on.workflow_call.secrets. Secrets are always strings and are masked in logs automatically.

on:
  workflow_call:
    secrets:
      DEPLOY_KEY:
        required: true

Referenced with ${{ secrets.DEPLOY_KEY }} in the called workflow.

The shortcut: secrets: inherit. If the caller and called workflow are in the same organization, you can skip declaring individual secrets entirely:

jobs:
  deploy:
    uses: my-org/shared-workflows/.github/workflows/deploy.yml@v1
    secrets: inherit

This passes all of the caller’s secrets to the called workflow. It’s convenient, but I’d recommend explicit secrets for production workflows — it makes dependencies visible and avoids accidentally exposing secrets the called workflow doesn’t need.

Outputs

Reusable workflows can return data to the caller. This is useful when one workflow generates something the next job needs — like a Docker image tag or a Terraform plan artifact ID.

Called workflow:

on:
  workflow_call:
    outputs:
      image-tag:
        description: "The built Docker image tag"
        value: ${{ jobs.build.outputs.tag }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - id: meta
        run: echo "tag=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

Caller workflow:

jobs:
  build:
    uses: my-org/shared-workflows/.github/workflows/docker-build.yml@v1
    secrets: inherit

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ needs.build.outputs.image-tag }}"

Reusable Workflows vs Composite Actions

This comes up constantly, so let’s clear it up:

FeatureReusable WorkflowsComposite Actions
ScopeEntire workflow (multiple jobs)Single step within a job
How calleduses: at the job leveluses: at the step level
Can define jobsYesNo
Can specify runnersYes, per jobNo — inherits caller’s runner
Nesting depthUp to 10 levelsUp to 10 levels
LoggingEach job/step logged separatelyCollapsed into one step
Storage.github/workflows/ directoryOwn directory with action.yml

My rule of thumb: if you need multiple jobs, different runner types, or full workflow structure — use a reusable workflow. If you need a handful of steps that run inside an existing job — use a composite action. They complement each other.


Versioning Strategy

This is where teams get burned. You reference a reusable workflow with uses: org/repo/.github/workflows/file.yml@ref — that @ref can be a branch, tag, or commit SHA.

Branch References

uses: my-org/shared-workflows/.github/workflows/deploy.yml@main

Easy to start with, dangerous in production. Any push to main in the shared-workflows repo instantly changes behavior for every caller. A broken commit breaks every repo that references it.

Tag References

uses: my-org/shared-workflows/.github/workflows/[email protected]

Better. Tags are stable points in time. Use semantic versioning: v1.0.0, v1.1.0, v2.0.0. You can also maintain floating major-version tags (v1, v2) that point to the latest minor/patch in that major version — similar to how most GitHub Actions are versioned.

SHA Pinning

uses: my-org/shared-workflows/.github/workflows/deploy.yml@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2

Most secure. A SHA is immutable — it can never be changed or overwritten. This is the gold standard for supply chain security. The tradeoff is readability and manual updates.

My recommendation: use tags for development velocity, SHA pinning for production-critical workflows. Add a comment with the tag name next to pinned SHAs so humans can still read it:

# v1.2.3
uses: my-org/shared-workflows/.github/workflows/deploy.yml@a1b2c3d4e5f6...

Organization-Level Patterns

For teams managing dozens or hundreds of repos, a central shared-workflows repository is the play.

The .github Repository

Every GitHub organization can have a special repository named .github. Workflows stored in .github/workflows/ in this repo can be referenced by any repository in the organization. It’s the natural home for reusable workflows that should be available org-wide.

Required Workflows via Repository Rulesets

GitHub moved required workflows into repository rulesets. This lets organization admins enforce that specific workflows must pass before a pull request can merge — across selected or all repositories.

Configure this under Organization Settings > Rulesets. You can target repos by name pattern (e.g., all repos matching infra-*) and require specific workflows to succeed. This is how you enforce security scanning, compliance checks, or standard build pipelines without relying on every team to configure their own branch protection rules.

A Real-World Shared Workflow Repository Structure

shared-workflows/
  .github/
    workflows/
      terraform-plan.yml
      terraform-apply.yml
      ansible-lint.yml
      ansible-run.yml
      docker-build.yml
      security-scan.yml
  README.md

Each workflow is independently versioned through the repository’s tags. When you tag the repo at v1.5.0, all workflows in it are available at that version.


Constraints Worth Knowing

Reusable workflows have guardrails. Know them before they surprise you.

Nesting depth: 10 levels max. Caller A can call B, which calls C, and so on — up to 10 levels deep. In practice, if you’re nesting more than 2-3 levels, simplify your design.

50 unique reusable workflows per file. A single caller workflow can reference at most 50 distinct reusable workflows, including any nested calls.

No loops. Workflow A cannot call B if B calls A. GitHub detects and rejects circular references.

Permissions only ratchet down. If the caller grants packages: read, the called workflow cannot escalate to packages: write. Permissions can be restricted in called workflows, never expanded.

Environment variables don’t propagate. env: variables defined at the workflow level in the caller are not available in the called workflow. You must pass data through inputs. This catches people off guard constantly.

strategy.matrix in the caller, not the called. If you want to fan out across environments or versions, define the matrix in the caller and pass values as inputs. The called workflow receives one set of inputs per matrix combination.

jobs:
  deploy:
    strategy:
      matrix:
        environment: [dev, staging, production]
    uses: my-org/shared-workflows/.github/workflows/deploy.yml@v1
    with:
      environment: ${{ matrix.environment }}
    secrets: inherit

Hands-On Lab

Let’s build a reusable workflow from scratch and call it from another workflow in the same repository.

Step 1: Create the Reusable Workflow

Create .github/workflows/reusable-lint.yml:

name: Reusable Lint

on:
  workflow_call:
    inputs:
      python-version:
        description: "Python version for linting"
        required: false
        type: string
        default: "3.12"

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

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ inputs.python-version }}

      - name: Install linters
        run: pip install flake8 yamllint

      - name: Run flake8
        run: flake8 . --count --show-source --statistics || true

      - name: Run yamllint
        run: yamllint . || true

Step 2: Create the Caller Workflow

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    uses: ./.github/workflows/reusable-lint.yml
    with:
      python-version: "3.12"

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Tests would run here"

Note the uses: path — when calling a reusable workflow from the same repository, use the relative ./.github/workflows/ path. No @ref is needed; it uses the same commit as the caller.

Step 3: Push and Watch

git add .github/workflows/reusable-lint.yml .github/workflows/ci.yml
git commit -m "feat: add reusable lint workflow"
git push

Open the Actions tab in your repository. You’ll see the CI workflow trigger, and within it, the lint job runs the reusable workflow. Each step is logged individually — you get full visibility into what the reusable workflow did.

Step 4: Add an Output

Update reusable-lint.yml to report whether linting passed:

on:
  workflow_call:
    inputs:
      python-version:
        description: "Python version for linting"
        required: false
        type: string
        default: "3.12"
    outputs:
      lint-result:
        description: "Whether linting passed"
        value: ${{ jobs.lint.outputs.result }}

jobs:
  lint:
    runs-on: ubuntu-latest
    outputs:
      result: ${{ steps.flake8.outputs.status }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ inputs.python-version }}
      - run: pip install flake8 yamllint
      - id: flake8
        run: |
          if flake8 . --count --show-source --statistics; then
            echo "status=clean" >> "$GITHUB_OUTPUT"
          else
            echo "status=issues-found" >> "$GITHUB_OUTPUT"
          fi

Now the caller can read needs.lint.outputs.lint-result and make decisions based on it.


Troubleshooting Guide

ProblemCauseFix
invalid value workflow referenceWrong path or missing @ref for cross-repo callsVerify the full path: org/repo/.github/workflows/file.yml@ref
Called workflow not foundRepo is private and caller doesn’t have accessEnable “Accessible from repositories in the organization” in the shared repo’s Actions settings
Secrets are empty in called workflowSecrets not passed explicitly and inherit not usedAdd secrets: inherit or pass each secret explicitly
env variables from caller are emptyEnv context doesn’t propagate to called workflowsPass values as inputs instead of relying on env
Permissions denied in called workflowCalled workflow requesting higher permissions than callerEnsure the caller’s permissions block includes everything the called workflow needs
Workflow not triggeringReusable workflow file missing workflow_call triggerAdd workflow_call under the on: key
Matrix not working in called workflowMatrix defined inside the reusable workflow when it should be in the callerMove strategy.matrix to the caller and pass values as inputs

Patterns That Eliminate YAML Duplication

Here are three patterns I use regularly in real environments:

Pattern 1: Standardized Terraform Pipeline. One reusable workflow handles init, plan, and apply with an approval gate. Every infrastructure repo calls it with just a working directory and environment name. Forty-seven repos, one workflow file.

Pattern 2: Ansible Playbook Runner. A reusable workflow that sets up SSH keys from secrets, installs the correct Ansible version, and runs a playbook. Caller repos just specify the inventory file and playbook path.

Pattern 3: Build-Test-Deploy Chain. Three reusable workflows (build.yml, test.yml, deploy.yml) called in sequence. The build outputs an artifact ID, the test workflow takes that as input, and deploy uses the test-validated artifact. Each piece is independently testable and reusable.

The common thread: the shared workflow owns the “how,” the caller owns the “what.” The caller says “deploy this to staging.” The reusable workflow knows all the steps to make that happen.


What’s Next

Next post: GitHub Actions for Terraform and Ansible — we’ll take these reusable workflow patterns and build production-ready pipelines for infrastructure code, including plan-on-PR, apply-on-merge, and Ansible playbook runs with proper secret handling.

Happy automating!