How to Build a Multi-Stage GitHub Actions CI/CD Pipeline
A single-job GitHub Actions CI/CD workflow that lints, tests, and builds in sequence gives slow, all-or-nothing feedback. The fix is a multi-stage pipeline: run lint and test in parallel, matrix test across Python 3.10, 3.11, and 3.12, gate the build behind 'needs: test', and cache pip. Here is how to write it.
Why a single-job workflow is the wrong shape
A common starter workflow puts everything in one job: check out, install dependencies, run the linter, run the tests, then build. It works, but it has three problems at scale:
- No parallelism. Lint waits behind nothing but test waits behind lint, and the build waits behind both, even though lint and test are independent.
- One Python version. A single runner tests one interpreter, so a break on 3.10 that passes on 3.12 slips through.
- Cold installs every run. Without caching,
pip install -r requirements.txtre-downloads every package on every push.
The fix is to split the work into three jobs - lint, test, build - with
explicit ordering, a version matrix, and dependency caching. actionlint
validates the whole file so mistakes never reach a real runner.
Split into lint, test, and build jobs
lint and test have no needs, so GitHub schedules them at the same time.
build declares needs: test, which means it starts only after every matrix
leg of test passes:
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install ruff
- run: ruff check .
Every job's first step is actions/checkout@v4 - without it the runner has an
empty working directory. Pinning to a major version (@v4, @v5) is what
actionlint expects and keeps the workflow reproducible.
Matrix the test job and cache pip
strategy.matrix.python-version fans the test job out into one runner per
version, all running in parallel. actions/setup-python@v5 with cache: pip
caches downloaded packages keyed on the requirements.txt hash automatically -
no separate actions/cache step needed:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install build
- run: python -m build
${{ matrix.python-version }} feeds each runner its own interpreter, so a
regression on any of the three versions turns the pipeline red.
Add concurrency control and lint the file
The top-level concurrency block groups runs by branch (ci-${{ github.ref }})
and sets cancel-in-progress: true, so pushing a new commit cancels the
in-flight run on that branch instead of queueing a redundant one. That saves
minutes on active branches.
Validate the workflow before it ever runs on GitHub:
# Static-check the whole workflow (must exit 0)
actionlint .github/workflows/ci.yaml
# Dry-run: print the job execution plan without launching containers
act push -n
actionlint catches a missing checkout, an unpinned action, or a malformed
matrix - all before you burn CI minutes finding out the hard way.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Ordering jobs with needs so build runs only after the test matrix passes
- Fanning a job across interpreters with strategy.matrix.python-version
- Caching pip via setup-python cache pip and cancelling stale runs with concurrency
FAQ
How do I build a multi-stage CI/CD pipeline in GitHub Actions?
Define separate jobs (for example lint, test, build) under jobs, and use needs to order them - lint and test with no needs run in parallel, and build with needs: test runs only after test passes. Each job checks out the repo, sets up its runtime, and runs its stage.
How do I run a test matrix across Python versions in GitHub Actions?
Add strategy.matrix.python-version: ['3.10', '3.11', '3.12'] to the job and reference ${{ matrix.python-version }} in actions/setup-python. GitHub launches one runner per version in parallel, so a break on any interpreter fails the pipeline.
How do I cancel an in-progress GitHub Actions run when I push again?
Add a top-level concurrency block with group set to ci-${{ github.ref }} and cancel-in-progress: true. A new push on the same branch cancels the running workflow instead of queueing a second one, saving CI minutes on active branches.
How do I cache dependencies in GitHub Actions without actions/cache?
Set cache: pip on actions/setup-python@v5 (or cache: npm on setup-node). It caches downloaded packages keyed on the lockfile hash automatically, so you do not need a separate actions/cache step for the common dependency-install case.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →