GitHub Actions #5 Reuse: Composite Actions and Reusable Workflows
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
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: bashThe consuming side references it by path.
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 (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.
# 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.