Skip to content
C Codeloom
CI/CD

GitHub Actions Reusable Workflows Tutorial

Stop copy-pasting CI YAML across repos. Learn how to build reusable GitHub Actions workflows with inputs, secrets, outputs, and per-environment overrides.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • The difference between composite actions and reusable workflows
  • How to define and call reusable workflows
  • Passing inputs, secrets, and outputs
  • Versioning and pinning patterns
  • A real multi-repo CI structure

Prerequisites

  • Basic experience writing GitHub Actions YAML

What and Why

Most teams accumulate near-identical CI YAML in every repo: same Node setup, same lint, same test, same Docker build. When you need to change one step (say, swap actions/checkout v3 for v4), you fan out PRs to 20 repos. Reusable workflows solve that. One workflow lives in a central repo, and other repos call it.

Mental Model

GitHub Actions has two reuse mechanisms:

  • Composite actions: small reusable steps. They look like an action.yml with runs.using: composite. Called like any other action via uses:.
  • Reusable workflows: whole workflows you call from another workflow with uses:. They include a workflow_call trigger and accept inputs, secrets, and produce outputs.

Composite actions are for sequences of steps; reusable workflows are for entire jobs.

  repo-a/.github/workflows/ci.yml
   |
   | jobs:
   |   build:
   |     uses: org/ci-workflows/.github/workflows/node-ci.yml@v1
   |     with:  { node-version: '20' }
   |     secrets: inherit
   v
org/ci-workflows/.github/workflows/node-ci.yml
   on: workflow_call
   jobs: lint, test, build
Caller and callee workflow relationship

Hands-on Example

A reusable workflow in org/ci-workflows:

# .github/workflows/node-ci.yml
name: Node CI
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '20'
      run-coverage:
        type: boolean
        default: false
    secrets:
      NPM_TOKEN:
        required: false
    outputs:
      image-tag:
        description: Built image tag
        value: ${{ jobs.build.outputs.tag }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - if: inputs.run-coverage
        run: npm run coverage

  build:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - id: meta
        run: echo "tag=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_OUTPUT

A consumer in another repo:

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push: { branches: [main] }

jobs:
  ci:
    uses: org/ci-workflows/.github/workflows/node-ci.yml@v1
    with:
      node-version: '20'
      run-coverage: true
    secrets: inherit

  deploy:
    needs: ci
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ needs.ci.outputs.image-tag }}"

secrets: inherit forwards all org-level and repo-level secrets without listing them. Use it when you trust the callee; otherwise pass only what is needed.

Common Pitfalls

Pinning to a moving tag. @main means every workflow run uses whatever is currently on the callee’s main. Pin to a specific tag (@v1.2.0) or a SHA for production-critical workflows.

Forgetting permissions. Each job in a reusable workflow inherits the caller’s permissions: block by default. If the caller restricts permissions, callee actions may fail. Set explicit permissions in the reusable workflow:

permissions:
  contents: read
  packages: write

Secrets that do not exist. A required secret declared in workflow_call.secrets must be passed by the caller, or the workflow fails to start.

Cross-repo private callees. Calling a private reusable workflow from another private repo requires the callee repo to allow it under “Actions settings > Access”.

Nested matrix madness. A reusable workflow run as part of a matrix job multiplies cost quickly. Confirm runtime expectations before flipping the switch.

Practical Tips

Keep a single repo (often ci-workflows or actions-platform) for your reusable workflows and composite actions. Version it with semantic tags:

git tag v1.2.0
git push --tags

Maintain a sliding v1 tag that points to the latest non-breaking 1.x release. Most callers track v1; cautious ones pin to v1.2.0.

Combine reusable workflows with environments for staged deploys:

deploy:
  uses: org/ci-workflows/.github/workflows/deploy.yml@v1
  with: { env: production }
  secrets: inherit

The callee references environment: ${{ inputs.env }} so deploy approvals, environment secrets, and protection rules apply.

Combine with OIDC for cloud auth. No long-lived AWS keys; the workflow exchanges a GitHub OIDC token for short-lived STS credentials.

Use composite actions for things like “setup repo”: checkout, language runtime, dependency cache. Compose multiple composite actions inside a reusable workflow for readable, layered YAML.

Add a workflow_dispatch trigger to your reusable workflow during development so you can run it standalone and test changes without modifying every caller.

Wrap-up

Reusable workflows turn duplicated CI YAML into a shared library you maintain in one place. Define them with workflow_call, accept inputs and secrets, expose outputs to chain into later jobs, and pin callers to immutable tags. Use composite actions for step-level reuse and reusable workflows for job-level reuse. Combine with OIDC and environments for safe deploys. Once the pattern is in place, updating CI for 50 repos becomes a single PR — which is exactly the leverage CI is supposed to give you.