TL;DR — In ~30 minutes you'll build a release pipeline that ties together five core Actions concepts: a composite action, a reusable workflow with a matrix, a
release.ymltriggered byworkflow_dispatchand git tags, all wired up using third-partyuses:actions to publish a GitHub Release.
Duration: ~30 minutes
Prerequisites: Exercise 2 (ci.yml) merged to main.
Goal: A release workflow you can fire manually with a version input (or by pushing a v* tag) that validates → builds → publishes a GitHub Release with attached artifacts.
┌──────────────────────────────────────────────────────────────────┐
│ release.yml (workflow_dispatch + push tags 'v*') │
│ │
│ ┌───────────────────┐ ┌───────────────┐ ┌────────────┐ │
│ │ validate (calls │ ──▶ │ build │──▶ │ publish │ │
│ │ reusable- │ │ (uses │ │ (creates │ │
│ │ validate.yml) │ │ composite │ │ GitHub │ │
│ │ matrix: 3 OS × │ │ action) │ │ Release) │ │
│ │ 3 Python │ │ │ │ │ │
│ └───────────────────┘ └───────────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Files you'll create:
.github/
├── actions/
│ └── setup-python-project/
│ └── action.yml ← composite action (Part A)
└── workflows/
├── reusable-validate.yml ← callable workflow w/ matrix (Part B)
└── release.yml ← dispatch + tag trigger (Part C)
Bash / macOS / Linux / Git Bash:
git checkout main && git pull
BRANCH="feature/$(whoami)/release-workflow"
git checkout -b "$BRANCH"
mkdir -p .github/actions/setup-python-project
mkdir -p .github/workflowsWindows PowerShell:
git checkout main; git pull
$BRANCH = "feature/$env:USERNAME/release-workflow"
git checkout -b $BRANCH
New-Item -ItemType Directory -Force -Path .github/actions/setup-python-project | Out-Null
New-Item -ItemType Directory -Force -Path .github/workflows | Out-Null💡 Keep the
$BRANCHvariable in the same shell session for the rest of the exercise — later commands (git push -u origin "$BRANCH",gh workflow run release.yml --ref "$BRANCH" ...) reference it. If you open a new terminal, re-run theBRANCH=...(bash) or$BRANCH = ...(PowerShell) line first.
A composite action bundles repeated steps into one reusable uses: block. We'll wrap "checkout + setup-python + cache + install deps" so every job in every workflow gets it for free.
Create .github/actions/setup-python-project/action.yml:
name: Setup Python Project
description: Checkout, install Python, restore pip cache, install requirements
inputs:
python-version:
description: Python version to install
required: false
default: "3.11"
# TODO ①: Wrap the steps below in a `runs:` block that declares this as a
# composite action. (See "Composite Actions" in the guide.)
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
# TODO ②: Set up Python using inputs.python-version
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-py${{ inputs.python-version }}-${{ hashFiles('requirements.txt') }}
restore-keys: ${{ runner.os }}-py${{ inputs.python-version }}-
# TODO ③: Add a step that runs `pip install -r requirements.txt`💡 Why composite actions? They live in your repo (no marketplace publish needed), are referenced as
uses: ./.github/actions/setup-python-project, and DRY up workflows. Read more: reusable-workflows.md → "Composite actions".
| # | TODO | Concept | Read this |
|---|---|---|---|
| ① | runs.using: "composite" |
Composite action declaration | reusable-workflows.md → "Composite Actions" |
| ② | Use input in composite step | ${{ inputs.<name> }} in actions |
reusable-workflows.md → composite example |
| ③ | shell: bash for run steps |
Composite action requirement | reusable-workflows.md → composite example |
→ Solution: solutions/03-release-workflow/.github/actions/setup-python-project/action.yml
A reusable workflow is a full workflow file that other workflows can uses:. Unlike a composite action (which is a step), a reusable workflow is a job. Perfect for "run our full validation suite from anywhere."
Create .github/workflows/reusable-validate.yml:
name: Reusable Validate
# TODO ④: Make this workflow callable from other workflows.
# It should accept a `python-versions` input (string, default '["3.11"]').
permissions:
contents: read
jobs:
validate:
name: Validate (${{ matrix.os }} / py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
# TODO ⑤: Build a matrix that fans out across:
# - os: ubuntu-latest, windows-latest, macos-latest
# - python-version: expanded from the `python-versions` input
steps:
# TODO ⑥a: Add `actions/checkout@v4` as the FIRST step.
# This is required so the runner has the local composite action
# (`./.github/actions/setup-python-project`) on disk before the
# next step tries to load its `action.yml`. Without checkout,
# you'll see: "Can't find 'action.yml' under
# '.github/actions/setup-python-project'."
# TODO ⑥b: Use the composite action from Part A,
# passing the matrix Python version through.
# The composite does: setup-python + pip cache
# + `pip install -r requirements.txt`. After it runs,
# `pytest` and `ruff` are on PATH (provided they're listed
# in requirements.txt — see note below).
- name: Lint
run: |
pip install ruff
ruff check src/
- name: Test
run: pytest src/ --junitxml=test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}-${{ matrix.python-version }}
path: test-results-*.xml
⚠️ actions/checkoutmust precede a local composite action. Even if your composite includes acheckoutstep internally, the runner can't load the composite'saction.ymluntil the repo is on disk. So the caller workflow (this one) must runactions/checkoutfirst. This is the most common stumble with local composites.
⚠️ Make surerequirements.txtlistspytest(and ideallyruff). The composite installs fromrequirements.txt; if pytest isn't there, theTeststep fails withpytest: command not found(exit code 127).
💡 Composite action vs reusable workflow:
Composite action Reusable workflow Granularity A single step A whole job (or many) Runs on Caller's runner Its own runner(s) Can use matrix ❌ ✅ Called via uses: ./path(insteps:)uses: ./path(underjobs.<id>.uses:)
| # | TODO | Concept | Read this |
|---|---|---|---|
| ④ | on: workflow_call: with python-versions input |
Reusable workflow trigger + the exact input shape | caching-and-matrix.md → workflow_call inputs block (copy this) |
| ⑤ | fromJSON() to expand matrix |
Dynamic matrix from input string | caching-and-matrix.md → "Dynamic matrix from a workflow input" |
| ⑥a | actions/checkout@v4 first |
Required before any local uses: ./... |
core-concepts.md → "Run on Pushes & PRs" / first checkout step |
| ⑥b | Local action via uses: ./path |
Calling a composite action | reusable-workflows.md → "Use the composite action" |
→ Solution: solutions/03-release-workflow/.github/workflows/reusable-validate.yml
Now the headline workflow. It triggers two ways:
- Manually via
workflow_dispatchwith aversioninput — operator-driven release. - Automatically when someone pushes a
v*git tag — release-by-tagging.
Create .github/workflows/release.yml:
name: Release
on:
push:
tags:
- "v*"
# TODO ⑦: Add a `workflow_dispatch` trigger with two inputs:
# - version: required string (e.g., 'v1.2.3')
# - prerelease: boolean, default false
permissions:
contents: write # required for creating releases
jobs:
# ── Job 1: Resolve version (from tag OR dispatch input) ─────────────
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
# ── Job 2: Validate via the reusable workflow ───────────────────────
validate:
needs: resolve
# TODO ⑧: Call the reusable workflow from Part B,
# passing python-versions: '["3.10","3.11","3.12"]'.
# ── Job 3: Build distribution ───────────────────────────────────────
build:
needs: [resolve, validate]
runs-on: ubuntu-latest
steps:
# TODO ⑨: Use the composite action (default Python is fine).
- name: Build wheel + sdist
run: |
pip install build
python -m build
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# ── Job 4: Publish GitHub Release ───────────────────────────────────
publish:
needs: [resolve, build]
runs-on: ubuntu-latest
steps:
- name: Download dist
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
# TODO ⑩: Create a GitHub Release using a third-party action
# (search the marketplace for one that publishes releases).
# It must:
# - tag the release with the resolved version
# - attach everything in `dist/` as release assets
# - auto-generate release notes
# - honor the `prerelease` dispatch input| # | TODO | Concept | Read this |
|---|---|---|---|
| ⑦ | workflow_dispatch inputs |
Manual triggers | manual-triggers.md → "Inputs" |
| ⑧ | jobs.<id>.uses: |
Calling a reusable workflow | reusable-workflows.md → "Call the reusable workflow" |
| ⑨ | Reusing the composite action | DRY principle | reusable-workflows.md → "Use the composite action" |
| ⑩ | Third-party action via version-pinned uses: |
Marketplace actions | security-best-practices.md → Create GitHub Release step (copy this block) |
→ Solution: solutions/03-release-workflow/.github/workflows/release.yml
git add .github/
git commit -m "ci: add release pipeline (dispatch + tags + reusable + matrix)"
git push -u origin "$BRANCH"
gh pr create --title "ci: add release workflow" --body "Adds release.yml with dispatch + reusable + composite."
# Merge the PR (via the GitHub UI, or with the CLI below):
gh pr merge --squash --delete-branch
git checkout main && git pull
# Now the workflow exists on the default branch — dispatch it:
gh workflow run release.yml -f version=v0.1.0 -f prerelease=true
gh run watchgit tag v0.1.1
git push origin v0.1.1-
gh workflow run release.ymlshows theversionandprereleaseinputs in the UI -
validatejob fans out to 9 matrix combinations (3 OS × 3 Python) — visible as separate jobs -
buildwaits for all matrix combos - A new entry appears under the repo's Releases page with
dist/*files attached - Auto-generated release notes list the commits since the previous tag
- Concurrency — add
concurrency: { group: release-${{ github.ref }}, cancel-in-progress: false }so two release runs can't race. - SHA-pin everything — replace every
@v4/@v2inrelease.ymlwith full commit SHAs. - Environment + approval — gate the
publishjob behindenvironment: productionwith required reviewers. - Skip matrix on dispatch — accept a boolean
quickinput that, when true, narrows the reusable's matrix to["3.11"]only. (Pass it through withwith: python-versions: ${{ inputs.quick && '["3.11"]' || '["3.10","3.11","3.12"]' }}.) - Reuse from another repo — move
reusable-validate.ymlto a shared.githuborg repo and call it asuses: my-org/.github/.github/workflows/reusable-validate.yml@main.
| Concept | Where used in this exercise |
|---|---|
| Workflow file structure | All three workflow files |
workflow_dispatch with inputs |
release.yml Part C |
| Tag-triggered releases | release.yml on: push: tags: |
| Matrix builds | reusable-validate.yml (OS × Python) |
| Reusable workflows | reusable-validate.yml + release.yml Job 2 |
| Composite actions | setup-python-project/action.yml |
Third-party actions via uses: |
softprops/action-gh-release, actions/checkout, actions/setup-python, actions/cache, actions/upload-artifact, actions/download-artifact |
| GitHub Releases | publish job |
Per-part solutions are linked under each part above. Full solution tree: solutions/03-release-workflow/.