Guides/GitHub ActionsGitHub Actions/Reusable Workflows and Composite Actions

Reusable Workflows and Composite Actions

Stop copy-pasting CI and deploy logic across repos. Reusable workflows share whole jobs with inputs and secrets; composite actions bundle steps. When to use each, and the limits.


At some point every GitHub Actions setup hits the same wall: the same build-test-lint block, or the same deploy job, gets copy-pasted into a dozen workflows across a dozen repos. Then a build step changes and you have to hunt it down in all of them, and inevitably one gets missed. This is the DRY problem, and GitHub gives you two tools to solve it at different levels. Reusable workflows let one workflow call another whole workflow - entire jobs, wired up with inputs and secrets. Composite actions bundle a sequence of steps into a single action you drop into a job like any other. This guide covers both, when to reach for which, and the limits that will bite you if you do not know them.

The DRY problem, concretely

Say you have twenty services, each in its own repo, each with a CI workflow that does the same thing: check out, set up the language, install deps, run tests, run the linter, upload coverage. That is thirty lines of YAML duplicated twenty times. When you bump the Node version, or add a caching step, or switch coverage providers, you are editing twenty files and hoping you got them all. The duplication is not just tedious - it drifts. Repos fall out of sync, and "the CI" stops meaning one thing.

The fix is to define the shared logic once and reference it everywhere. The only real decision is what unit you are sharing: a whole job (or set of jobs), or a run of steps inside someone else's job. That distinction is exactly the split between reusable workflows and composite actions.

Reusable workflows: sharing whole jobs

A reusable workflow is a normal workflow file with one difference: it is triggered by workflow_call instead of push or pull_request. That makes it callable from other workflows. It declares an interface - the inputs it accepts and the secrets it needs - and the caller passes values in.

Here is a reusable deploy workflow. It lives at .github/workflows/deploy.yml in a repo (often a shared org/ci-workflows repo):

# .github/workflows/deploy.yml  (the reusable workflow)
name: Deploy

on:
  workflow_call:
    inputs:
      environment:
        description: "Target environment (staging or production)"
        required: true
        type: string
      image-tag:
        description: "Container image tag to deploy"
        required: false
        type: string
        default: "latest"
    secrets:
      REGISTRY_TOKEN:
        required: true
    outputs:
      url:
        description: "The deployed service URL"
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.release.outputs.url }}
    steps:
      - uses: actions/checkout@v4
      - name: Log in to registry
        run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ci --password-stdin
      - name: Deploy
        id: release
        run: |
          ./deploy.sh --env "${{ inputs.environment }}" --tag "${{ inputs.image-tag }}"
          echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"

Two things to notice. Inputs are typed (string, boolean, or number) and can have defaults. Secrets are declared explicitly - a reusable workflow does not automatically see the caller's secrets. And this one passes an output back up: the deploy job exposes a url, and the workflow re-exports it under on.workflow_call.outputs so the caller can read it.

Calling a reusable workflow

The caller does not use steps to invoke it. It calls the workflow at the job level with uses:

# .github/workflows/ci.yml  (the caller, in any repo)
name: CI

on:
  push:
    branches: [main]

jobs:
  ship:
    uses: my-org/ci-workflows/.github/workflows/deploy.yml@v1
    with:
      environment: production
      image-tag: ${{ github.sha }}
    secrets:
      REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

  notify:
    needs: ship
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deployed to ${{ needs.ship.outputs.url }}"

The uses value is owner/repo/.github/workflows/deploy.yml@ref, where ref is a branch, tag, or full commit SHA - pin it (@v1 or a SHA) rather than tracking a moving branch, so an upstream change cannot silently alter your pipeline. Inputs go under with:, secrets under secrets:. A calling job that uses a reusable workflow cannot also define its own steps - the whole job is the call. Downstream jobs read the reusable workflow's outputs through needs.<job>.outputs.<name>, exactly as they would for a normal job's outputs.

If you want to forward every secret the caller has without listing each one, use secrets: inherit. Convenient for internal repos, but explicit passing is safer because it makes the secret surface obvious.

Composite actions: sharing a sequence of steps

A composite action solves a smaller-grained version of the same problem. Instead of sharing whole jobs, you bundle a run of steps into a single reusable action, then drop it into a job with uses: like any Marketplace action. Where a reusable workflow is "call this pipeline," a composite action is "run these five steps right here, in the middle of my job."

A composite action is defined by an action.yml file. The giveaway is runs.using: "composite":

# .github/actions/setup-node-app/action.yml
name: "Setup Node app"
description: "Checkout, set up Node with caching, and install dependencies"

inputs:
  node-version:
    description: "Node.js version"
    required: false
    default: "20"

runs:
  using: "composite"
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: "npm"
    - name: Install dependencies
      run: npm ci
      shell: bash

Every run step in a composite action must specify shell: explicitly - that is the most common thing people forget, and it fails with a confusing error. Inputs work like any action's inputs, referenced as ${{ inputs.node-version }}. Composite actions can define outputs too, wired from a step's output.

Using it is the same as any action - a uses step inside a job:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/actions/setup-node-app   # local action in this repo
        with:
          node-version: "20"
      - run: npm test
      - run: npm run lint

The path ./.github/actions/setup-node-app points at a local action in the same repo. To share across repos, publish it in its own repo and reference it as my-org/setup-node-app@v1, the same way you would reference a Marketplace action. For more on authoring and publishing standalone actions, see the custom actions guide.

When to use which

The two tools operate at different altitudes, and that is the whole basis for choosing:

  • Reach for a reusable workflow when you want to share whole jobs or a whole pipeline - the deploy job, the full build-test-scan sequence with its own runs-on, matrix, and multiple jobs with needs between them. It owns the runner and the job structure.
  • Reach for a composite action when you want to share a sequence of steps inside a job you already have - "check out, set up the toolchain, install deps." It does not own a runner; it runs on whatever runner the calling job provides, in the middle of that job's step list.

A simple test: if the thing you want to share includes runs-on and looks like a job, it is a reusable workflow. If it is a chunk of steps that slots into an existing job, it is a composite action. Many real setups use both - a reusable CI workflow whose jobs each call a composite action for the common setup steps.

The limits that bite

Both tools have hard limits worth knowing before you build a tower of them:

  • Nesting depth. Reusable workflows can call other reusable workflows, but only up to four levels deep from the top-level caller. Composite actions can nest composite actions, capped at ten levels. Do not try to build a deep call tree; keep it shallow.
  • Secrets are not automatic. A reusable workflow only sees the secrets you pass under secrets: (or all of them via secrets: inherit). A composite action cannot access secrets at all through a secrets context - if a step inside it needs a secret, pass the value in as an input from the calling workflow (which read it from secrets). Passing secrets as inputs works but means the value flows through an input, so mask it and be deliberate.
  • env does not flow into composite actions. Environment variables set in the calling workflow are not visible inside a composite action's steps. Pass what you need as inputs.
  • A calling job cannot mix a reusable-workflow uses with its own steps. The job is either a call or a normal job, not both.
  • GITHUB_ENV and GITHUB_OUTPUT still work inside composite steps, so you can pass data between the steps of a composite action and export outputs - but remember the shell: requirement on every run.

The shape of it

The DRY problem in CI is always the same: the same logic copy-pasted until it drifts. Fix it by defining the logic once and referencing it. A reusable workflow shares whole jobs - triggered by on: workflow_call, typed inputs and declared secrets, called at the job level with uses: owner/repo/.github/workflows/x.yml@ref plus with: and secrets:, passing results back as outputs. A composite action shares a run of steps - an action.yml with runs.using: composite, dropped into a job with uses: like any action, remembering shell: on every run. Whole jobs go in reusable workflows; sequences of steps go in composite actions. Keep the nesting shallow, pass secrets and env explicitly, and one edit updates every repo at once instead of twenty.