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.

DevOps Engineerci-cdgithub-actionscaching

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:

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

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

Speed Up a Slow GitHub Actions PipelineDevOps projectDebug a Failing GitHub Actions WorkflowDevOps projectShip a Blue-Green DeploymentDevOps projectDevOps roadmapStep by step to hiredDevOps interview questionsSTAR answersAll DevOps projectsProjects hub

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 →