Skip to content

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.txt

build 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-env

Important

If you’re creating a GitHub Action, the metadata file must be named action.yml or action.yaml.

.github/actions/setup-python-env/action.yml
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.txt

A couple of details specific to composite actions:

  • inputs works the same way as inputs on any action — summarize doesn’t need dependencies installed, so it can simply omit install-deps and get the default "false", skipping that step entirely. But python-version is required.
  • shell: bash on the run step 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’s run steps don’t have that context to inherit from, so you have to state it explicitly every time.

Wiring It Into Both Jobs

.github/workflows/ci.yml
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: bash

Both 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 push

Open 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.

Last updated on