DevOps · Cicd

GitHub Actions Reusable Workflows

GitHub Actions Reusable Workflows is the work that turns a deploy into a system. The deployment is one moment; the system is the next 18 months of uptime, incidents, and.

John Kihiu12 min read

Reusable workflows let you define a CI/CD job once and call it from as many workflow files, and as many repositories, as you need. They are the difference between fifteen copy-pasted deploy.yml files that quietly drift apart and one workflow that every service calls with different inputs. The mechanics are simple once you've seen them, but the failure modes — nesting limits, secret propagation, version pinning — trip people up in ways a generic "just use workflow_call" tutorial doesn't cover.

What makes a workflow reusable

Any workflow file becomes callable by another workflow the moment its trigger includes on: workflow_call. That's the entire distinction — there is no separate file type or extension. A reusable workflow can still keep push or pull_request triggers alongside workflow_call if you want it to run standalone as well as be invoked by others. Inside the workflow_call block you declare the inputs the caller must or may supply, the secrets it needs to pass through, and any outputs the calling workflow can read back from a job.

Calling a reusable workflow

A caller invokes it with jobs.<job_id>.uses instead of the usual runs-on and steps. Within the same repository you can reference the file by relative path (./.github/workflows/deploy.yml). Across repositories you reference {owner}/{repo}/.github/workflows/{filename}@{ref}, where ref is a branch, tag, or full commit SHA. Inputs go under with:, secrets under secrets:. If the caller and the called workflow live in the same organization and you trust every input, secrets: inherit passes the caller's entire secret set through without listing each one individually — convenient, but it also means the reusable workflow gets everything, which is worth thinking about before you use it on a workflow you didn't write.

YAML · CALLING A REUSABLE WORKFLOW
jobs:
  call-deploy:
    uses: my-org/shared-workflows/.github/workflows/deploy.yml@v2.1.0
    with:
      environment: production
      service-name: billing-api
    secrets:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
    # or, within the same org and trusting the called workflow fully:
    # secrets: inherit

Passing outputs back to the caller

A reusable workflow can define top-level outputs that map to a specific job's step outputs (jobs.<job_id>.outputs.<name>: ${{ jobs.<job_id>.outputs.<name> }} inside the reusable file itself). The calling workflow then reads them as jobs.<job_id>.outputs.<name>, the same way it would read outputs from a normal job. This is what lets a shared "build and push image" workflow hand the resulting image tag back to whatever pipeline called it, instead of every caller re-deriving the tag itself.

Nesting has a hard limit

A reusable workflow can itself call another reusable workflow, but GitHub caps the chain at four levels of nesting. Beyond that the run fails outright. If you find yourself stacking workflow_call chains five deep, that's usually a sign the shared logic belongs in a composite action instead, called from inside the reusable workflow rather than nested another layer down.

Pinning by tag vs. by SHA

Cross-repo references should be pinned, not left on a floating branch like @main. Pinning to a release tag (@v2.1.0) is readable and works for most internal shared-workflow repos where you control releases. Pinning to a full commit SHA is stricter — it guarantees the exact bytes that ran last time run again, which matters more for third-party or security-sensitive reusable workflows, since a tag can technically be moved to point at different content after the fact. Internally, a well-tagged shared-workflows repo with semver releases is usually enough; treat SHA pinning as the tool you reach for when supply-chain integrity is the concern, not the default for every internal call.

Reusable workflows vs. composite actions

These solve overlapping but different problems, and the confusion between them causes most of the "why can't I just call this" questions. A composite action lives in an action.yml with runs: using: composite and a list of steps; it's invoked with a normal uses: step inside an existing job, runs on the same runner as the rest of that job, and shares its environment. A reusable workflow is invoked at the job level, always spins up its own runner and its own fresh job context, and can define multiple jobs with dependencies between them. If you need to bundle a handful of steps to drop into an existing job, use a composite action. If you need an entire job — or a whole pipeline with its own matrix, its own secrets scope, its own runner — reach for a reusable workflow.

Composite actionReusable workflow
Defined in action.ymlDefined in a workflow YAML file with on: workflow_call
Runs inside the caller's job/runnerRuns as its own job on its own runner
Called with a step-level uses:Called with a job-level uses:
Good for a handful of shared stepsGood for an entire shared pipeline or job graph

Once teams settle on this split — composite actions for step-level snippets, reusable workflows for whole jobs — the CI folder stops accumulating near-duplicate YAML files, and a change to the deploy logic becomes a one-file edit instead of a grep-and-replace across a dozen repos.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.