Composite Steps Using Composite Action
Look at build and summarize side by side and the duplication is obvious: both start with a checkout, both set up the same Python version with the same caching configuration. Right now, if you wanted to bump the Python version this course targets, you’d have to remember to change it in two places. This chapter folds the repeated part into one reusable unit.
What’s Actually Duplicated Right Now
Both jobs open with:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: requirements.txtbuild additionally installs dependencies afterward; summarize doesn’t need to, since its parsing script only uses the standard library. Small as this is, it’s exactly the kind of duplication that’s easy to let drift — two copies of “which Python version are we on” that someone has to remember to keep in sync by hand.
Composite Actions: A Reusable Bundle Of Steps
A composite action lets you define a named sequence of steps once, in its own file, and then call that whole sequence with a single uses: line — same as you’d reference any action from the Marketplace, except this one lives in your own repository.
runs:
using: "composite"
steps:
- ...That runs: using: composite block is what makes a action count as a composite action rather than an ordinary directory. Anything under steps: there runs in sequence, exactly like steps in a normal job, with one difference worth flagging up front.
Why Matrix Can’t Live Inside The Composite Action
Matrix is a job level thing whereas Composite Actions is a set of steps. A matrix spawns separate parallel jobs, which requires spinning up new runner infrastructure. Because a composite action is just a flat sequence of steps meant to run inside a single, already-existing job, it lacks the system authority to create or manage new jobs. So what you you instead is pass each variable of Matrix as inputs for the action.
Why Checkout Can’t Live Inside The Composite Action
It’s tempting to fold Checkout into the composite action along with Setup Python, so a single call handles everything. That doesn’t work, and the reason is worth understanding rather than hitting by trial and error: a local composite action is referenced by a path — uses: ./.github/actions/something — and for the runner to find action.yml at that path, your repository has to already be checked out on the runner first. If Checkout were the first step inside the composite action, the runner would need the repo checked out to find the action definition that performs the checkout — a chicken-and-egg problem with no valid starting point.
Note
This is specific to composite actions that live in your own repository, referenced by a relative path. It’s a genuinely common trip-up the first time someone tries to fold “the whole setup” into one reusable block — the fix isn’t a workaround, it’s just recognizing that checkout has to stay a step in the job itself, and the composite action wraps everything that comes after it.
So the composite action here wraps Setup Python and the optional dependency install — the part that’s genuinely safe to share — while Checkout stays as its own step in each job.
Building The Composite Action
It is not necessary to put composite action under .github/actions directory but it’s recommended to place it there for better organization.
Create the action’s directory and definition:
mkdir -p .github/actions/setup-python-envImportant
If you’re creating a GitHub Action, the metadata file must be named action.yml or action.yaml.
name: Setup Python Environment
description: Set up Python with pip caching, optionally installing project dependencies
inputs:
python-version:
description: Version of Python to Setup Environment With
required: true
install-deps:
description: Whether to install dependencies from requirements.txt
required: false
default: "false"
runs:
using: composite
steps:
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
cache: "pip"
cache-dependency-path: requirements.txt
- name: Install dependencies
if: inputs.install-deps == 'true'
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r requirements.txtA couple of details specific to composite actions:
inputsworks the same way as inputs on any action —summarizedoesn’t need dependencies installed, so it can simply omitinstall-depsand get the default"false", skipping that step entirely. Butpython-versionis required.shell: bashon therunstep is required inside a composite action, unlike in an ordinary job step. A normal job step inherits a default shell from the runner; a composite action’srunsteps don’t have that context to inherit from, so you have to state it explicitly every time.
Wiring It Into Both Jobs
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python + Install Dependencies
uses: ./.github/actions/setup-python-env
with:
python-version: ${{ matrix.python-version }}
install-deps: "true" # Install dependencies too
- name: Cache Ruff Cache
uses: actions/cache@v6
with:
path: .ruff_cache
key: ruff-${{ runner.os }}-${{ hashFiles('**/*.py') }}
restore-keys: |
ruff-${{ runner.os }}-
- name: Lint (ruff)
uses: astral-sh/[email protected]
- name: Run Tests
run: python -m pytest --junitxml=test-report.xml
# Force workflow to continue even if this fails (because test can fail)
continue-on-error: true
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: test-report-${{ matrix.os }}-${{ matrix.python-version }}
path: test-report.xml
summarize:
needs: build
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python Environment With Caching
uses: ./.github/actions/setup-python-env
with:
python-version: ${{ matrix.python-version }}
- name: Download Test Report
uses: actions/download-artifact@v8
with:
name: test-report-${{ matrix.os }}-${{ matrix.python-version }}
- name: Parse Report And Write Job Summary
env:
PYTHONIOENCODING: utf-8
run: python scripts/summarize_report.py >> "$GITHUB_STEP_SUMMARY"
shell: bashBoth jobs now call the same Setup Python Environment step. Bump the Python version this course targets, and there’s exactly one place in the whole workflow to change it.
Hands-On: Confirming It Still Works
Commit everything:
git add .github/actions/setup-python-env/action.yml .github/workflows/ci.yml
git commit -m "Extract shared Python setup into a composite action"
git pushOpen the Actions tab. Both jobs should run exactly as before — build still lints, tests, and uploads its report; summarize still waits on build, downloads the report, and writes its job summary table. The behavior hasn’t changed at all. What’s changed is that the setup logic now exists in exactly one place, doing exactly one job, referenced from both places that need it.
That closes out this section. You’ve taken this pipeline from a single job that runs on one Python version, on one OS, to one that tests across a real matrix of platforms and versions with a properly reasoned fail-fast policy, produces a genuine artifact trail between independent jobs, caches what’s worth caching, handles failure without losing the data you need most when things break, and shares its own setup logic instead of repeating it. That’s a real CI pipeline — not a toy one. And if you need reference anytime, you know where the project lives.