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_calltrigger 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:
| Feature | Reusable Workflows | Composite Actions |
|---|---|---|
| Scope | Entire workflow (multiple jobs) | Single step within a job |
| How called | uses: at the job level | uses: at the step level |
| Can define jobs | Yes | No |
| Can specify runners | Yes, per job | No — inherits caller’s runner |
| Nesting depth | Up to 10 levels | Up to 10 levels |
| Logging | Each job/step logged separately | Collapsed into one step |
| Storage | .github/workflows/ directory | Own 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
| Problem | Cause | Fix |
|---|---|---|
invalid value workflow reference | Wrong path or missing @ref for cross-repo calls | Verify the full path: org/repo/.github/workflows/file.yml@ref |
| Called workflow not found | Repo is private and caller doesn’t have access | Enable “Accessible from repositories in the organization” in the shared repo’s Actions settings |
| Secrets are empty in called workflow | Secrets not passed explicitly and inherit not used | Add secrets: inherit or pass each secret explicitly |
env variables from caller are empty | Env context doesn’t propagate to called workflows | Pass values as inputs instead of relying on env |
| Permissions denied in called workflow | Called workflow requesting higher permissions than caller | Ensure the caller’s permissions block includes everything the called workflow needs |
| Workflow not triggering | Reusable workflow file missing workflow_call trigger | Add workflow_call under the on: key |
| Matrix not working in called workflow | Matrix defined inside the reusable workflow when it should be in the caller | Move 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!