GitHub Actions #5 Reuse: Composite Actions and Reusable Workflows

4 min read

Build a few workflows and the same step bundles start getting copied around. Checkout, language setup, and cache configuration — three steps stuck at the top of every workflow, and with several repositories, replicated once per repository. When fixing a single cache key means hunting through all of them, that is the signal that, in code, you would have extracted a function. GitHub Actions’ version of the function is this post’s pair of protagonists: composite actions and reusable workflows.

Composite actions: a bundle of steps as an action #

The easiest way to build your own action like the actions/checkout we have used since #1 is a composite action: packaging steps into an action.yml file. Let’s start with a local action that lives inside the repository.

.github/actions/setup-project/action.yml
# .github/actions/setup-project/action.yml
name: setup-project
description: Checkout + Python + dependency install in one go

inputs:
  python-version:
    description: Python version
    default: "3.13"

runs:
  using: composite
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: ${{ inputs.python-version }}
        cache: "pip"
    - run: pip install -r requirements.txt
      shell: bash

The consuming side references it by path.

.github/workflows/ci.yml
    steps:
      - uses: actions/checkout@v4          # checkout must come first to read the local action
      - uses: ./.github/actions/setup-project
        with:
          python-version: "3.12"

Two syntax snags are worth noting. A run step in a composite must specify shell: bash (unlike in a regular workflow, it cannot be omitted), and since a local action is a repository file, checkout must come first or it cannot be read. Inputs are declared under inputs and used as ${{ inputs.name }}, and you can declare outputs too, returning values the same way as the step outputs in #2.

Reusable workflows: calling an entire job #

If a composite is a bundle of steps, a reusable workflow reuses an entire job configuration. Give a workflow a workflow_call trigger and it becomes a component that other workflows can call.

.github/workflows/deploy-template.yml
# .github/workflows/deploy-template.yml (shared repository)
on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
    secrets:
      deploy-role:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          ROLE: ${{ secrets.deploy-role }}

The calling side puts uses where a job goes.

.github/workflows/deploy.yml
# workflow in each service repository
jobs:
  deploy-prod:
    uses: my-org/workflows/.github/workflows/deploy-template.yml@v1
    with:
      environment: production
    secrets:
      deploy-role: ${{ secrets.PROD_DEPLOY_ROLE }}

That this is uses on the job, not on a step, captures the essential difference between the two approaches. A reusable workflow brings the runner choice, the environment, even a multi-job configuration along wholesale, so the caller becomes a thin shell that just supplies values. Secrets do not flow through automatically and must be passed explicitly (secrets: inherit can pass everything, but passing only what is needed fits the least-privilege principle of #9), and there are constraints like a call-depth limit, so avoid designs that wrap components in components, layer upon layer.

Choosing between them, and organization-wide standardization #

The choice between the two comes down to one line: repetition inside a job means composite; repetition of the job configuration itself means reusable workflow. A preparation bundle like “checkout and install” is a composite; a pipeline like “our company’s standard deploy procedure” is a reusable workflow. The two are not mutually exclusive — a reusable workflow whose steps use composite actions is a natural combination.

Organization-level standardization starts with a single shared repository. Gather reusable workflows and composite actions in a repository like my-org/workflows, and each service repository pins a version with a tag like @v1 and pulls them in. When a shared component changes, you bump the tag, and each repository upgrades when it is ready — exactly the same principle as module version pinning in Terraform Basics #8. Even the adoption criterion is the same: extract a component once the repetition has actually appeared at least twice.

Recap #

What we covered in this post.

  • Package repeated step bundles as a composite action (action.yml). The syntax traps: run steps need an explicit shell, and local actions require checkout first
  • Reusing an entire job configuration is a reusable workflow with the workflow_call trigger, and the call is uses in a job position
  • Secrets do not pass to a reusable workflow automatically. Explicitly passing only what is needed is the default
  • The rule is “repetition inside a job means composite, repetition of the job configuration means reusable,” and combining the two is natural
  • Organization standardization is a shared repository plus tag-pinned versions — the same principle as operating Terraform modules

Starting with the next post (#6 Docker Build and GHCR Push), we assemble these components into real pipelines. First up: a pipeline that builds a container image and pushes it to GitHub’s registry.

X