Build a Python CI Pipeline

Automate Python testing across multiple versions with GitHub Actions.

Introduction

30 Second Summary

Think about the last time you accidentally broke something that was working fine. In software, teams push code dozens of times a day, and a single bad change can break everything for everyone.

In this project, you will build a CI pipeline using GitHub Actions that automatically tests your Python code every time you push a change. Your pipeline will catch bugs before they reach production, test across multiple Python versions, and produce downloadable test reports.

What You'll Build

Push a commit and watch three parallel jobs light up in your GitHub Actions tab, each testing a different Python version, finishing fast thanks to cached dependencies, with a coverage report ready to download.

By the end of this project, you'll have:

  • A working CI pipeline that automatically runs your tests on every push and pull request, catching bugs before they sneak into your main branch.
  • Matrix builds testing your code across Python 3.11, 3.12, and 3.13 in parallel, with dependency caching that speeds up every subsequent run.
  • Secrets for secure authenticated operations and environment variables for runtime introspection, plus downloadable test coverage reports stored as artifacts from any completed workflow.
  • Secret Mission: Add a scheduled nightly build and conditional failure notifications that only fire when tests break.

Are there any prerequisites?

You'll need Git installed, a free GitHub account, basic Python knowledge, and comfort with the terminal. Step 1 walks you through confirming everything is ready.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Python Project Repository

A CI pipeline needs real code to operate on. Without a project that has functions and tests, there is nothing for GitHub Actions to build, test, or report on.

In this step, you will create a Python project with a small calculator module and a test suite. This gives the pipeline something meaningful to run in every future step.

In this step, get ready to:
  • Create a public GitHub repository and clone it to your machine.
  • Build the project structure with a calculator module and tests.
  • Install dependencies and confirm all tests pass locally.
Create the GitHub repository and clone it

You need a remote repository on GitHub so that GitHub Actions has a place to detect pushes and run workflows.

  • Open your browser and go to github.com.
  • In the upper-right corner, click the + icon, then click New repository.
  • Type python-ci-actions in the Repository name field.
  • Select Public for repository visibility.
  • Check Add a README file.
  • Click the .gitignore template dropdown and select Python.
  • Click Create repository.

Why a public repository?

GitHub Actions is completely free and unlimited on public repositories using standard runners. A public repo means you never need to worry about the 2,000-minute monthly cap that applies to private repos on the free plan.

Now clone the repository to your machine so you can add files locally.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/python-ci-actions.git
  • Move into the project folder by running:
cd python-ci-actions
  • Confirm you are inside the repository by running:
ls

You should see README.md listed in the output. This confirms the clone worked.

Seeing 'repository not found'?

  • Double-check you replaced the username in the clone URL with your actual GitHub username.
  • Make sure you selected Public when creating the repository. Private repos require authentication to clone.
  • help me troubleshoot my git clone error
Build the project structure

The CI pipeline will test a calculator module. You need three things: the source code, the tests, and a file listing the dependencies.

  • Create the project directories and the requirements.txt file by running these commands:
mkdir src tests
printf 'pytest\npytest-cov\n' > requirements.txt
  • Confirm the directories and file were created by running:
ls

You should see README.md, requirements.txt, src, and tests in the output.

Now create the calculator module. Open the python-ci-actions folder in your code editor.

  • Create a new file at src/calculator.py and paste the following code:
"""A simple calculator module for demonstrating CI pipelines."""


def add(a: float, b: float) -> float:
    """Return the sum of two numbers."""
    return a + b


def subtract(a: float, b: float) -> float:
    """Return the difference of two numbers."""
    return a - b


def multiply(a: float, b: float) -> float:
    """Return the product of two numbers."""
    return a * b


def divide(a: float, b: float) -> float:
    """Return the quotient of two numbers. Raises ValueError if b is zero."""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

What does this code do?

  • Four arithmetic functions (add, subtract, multiply, divide) accept two floats and return the result.
  • The divide function raises a ValueError when dividing by zero. This gives the test suite an error case to verify.
  • Type annotations and docstrings keep the module readable for anyone reviewing your repo.
  • Save src/calculator.py.
  • Verify the module is importable by running this command in your terminal:
python -c "from src.calculator import add; print(add(2, 3))"

You should see 5 printed. This confirms Python can find and execute your module.

Seeing 'ModuleNotFoundError'?

  • Make sure your terminal is inside the python-ci-actions folder, not src/. Run pwd to check.
  • Confirm the file is saved at exactly src/calculator.py (not calculator.py in the root).
  • help me fix my ModuleNotFoundError for src.calculator

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/calculator.py before moving on.

ⓧ I'd like to double check the full code

"""A simple calculator module for demonstrating CI pipelines."""


def add(a: float, b: float) -> float:
    """Return the sum of two numbers."""
    return a + b


def subtract(a: float, b: float) -> float:
    """Return the difference of two numbers."""
    return a - b


def multiply(a: float, b: float) -> float:
    """Return the product of two numbers."""
    return a * b


def divide(a: float, b: float) -> float:
    """Return the quotient of two numbers. Raises ValueError if b is zero."""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Now create the test file that the CI pipeline will execute.

  • Create a new file at tests/test_calculator.py and paste the following code:
"""Tests for the calculator module."""

import pytest
from src.calculator import add, subtract, multiply, divide


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 5) == -5


def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(-2, 3) == -6


def test_divide():
    assert divide(10, 2) == 5.0
    with pytest.raises(ValueError):
        divide(1, 0)

What does this test file do?

  • Each test_ function exercises one calculator operation with at least two assertions.
  • The test_divide function uses pytest.raises(ValueError) to confirm the module correctly rejects division by zero.
  • Four test functions give the CI pipeline a concrete pass/fail signal on every run.
  • Save tests/test_calculator.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved tests/test_calculator.py before moving on.

ⓧ I'd like to double check the full code

"""Tests for the calculator module."""

import pytest
from src.calculator import add, subtract, multiply, divide


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 5) == -5


def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(-2, 3) == -6


def test_divide():
    assert divide(10, 2) == 5.0
    with pytest.raises(ValueError):
        divide(1, 0)
Install dependencies and run the tests

Before the CI pipeline can run your tests in the cloud, confirm they pass on your own machine. This catches typos and import errors early.

  • Install the project dependencies by running:
pip install -r requirements.txt

What does this install?

  • pytest is the test runner that discovers and executes your test_ functions.
  • pytest-cov is a plugin that measures code coverage. You will use it in a later step to generate reports.

Before you run the tests, what do you think will happen? Will all 4 tests pass, or will something fail?

  • Run the test suite by executing:
python -m pytest

You should see output ending with 4 passed in green. All four test functions ran successfully.

Tests failing or command not found?

  • If you see command not found: pytest, make sure you ran pip install -r requirements.txt first.
  • If you see ModuleNotFoundError, confirm your terminal is in the python-ci-actions root folder (not inside src/ or tests/).
  • If a test assertion fails, double-check your src/calculator.py returns the correct arithmetic result for each function.
  • help me debug my failing pytest run

Your Python project is ready with working code and a passing test suite. Next up, you will create a GitHub Actions workflow that runs these tests automatically in the cloud every time you push code.

Create Your First Workflow with Push Trigger

Your Python project has passing tests, but those tests only run when you remember to run them yourself. Right now, pushing code to GitHub triggers absolutely nothing.

GitHub Actions reads special YAML files in your repository to know what to automate. In this step, you'll create your first workflow file and watch it run your tests in the cloud the moment you push.

In this step, get ready to:
  • Write a GitHub Actions workflow file that triggers on push to main.
  • Push the file and watch your tests run automatically in the cloud.
Create the workflow file

GitHub Actions looks for workflow files inside a .github/workflows/ directory in your repository. You need to create this directory structure before adding your workflow.

  • Create the workflows directory by running this command from your python-ci-actions folder:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github

You should see workflows listed in the output.

  • Create a new file called ci.yml inside the .github/workflows/ directory and add the following code:
name: CI Pipeline

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - name: Set up Python 3.13
        uses: actions/setup-python@v7
        with:
          python-version: '3.13'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest -v

What does this workflow do?

  • name: CI Pipeline gives your workflow a human-readable name that appears in the GitHub Actions tab.
  • on: push: branches: [main] tells GitHub to run this workflow every time someone pushes to the main branch.
  • runs-on: ubuntu-latest spins up a fresh Ubuntu virtual machine (called a runner) to execute your job.
  • actions/checkout@v7 clones your repository code onto the runner so subsequent steps can access it.
  • actions/setup-python@v7 installs Python 3.13 on the runner.
  • The final two steps install your dependencies and run pytest with verbose output, exactly as you did locally.
  • Save .github/workflows/ci.yml.

Seeing a YAML syntax error?

YAML is sensitive to indentation. Make sure you're using spaces (not tabs) and that each nested level is indented by exactly 2 spaces. Double-check that steps: is indented under the job name, and each step starts with - uses: or - name: at the same indentation level.

help me fix a YAML indentation error in my GitHub Actions workflow file

✔️ Awesome, I've got everything!

Great. Double check you've saved .github/workflows/ci.yml before moving on.

ⓧ I'd like to double check the full code

name: CI Pipeline

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - name: Set up Python 3.13
        uses: actions/setup-python@v7
        with:
          python-version: '3.13'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest -v
Push the workflow and watch it run

Your workflow file is saved locally, but GitHub Actions only reads files that are committed and pushed to the remote repository. Time to push it and see the magic happen.

  • Commit and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI workflow"
git push

What happens when you push?

The moment your push lands on main, GitHub detects the new workflow file and matches the push event against your on: push trigger. It spins up a fresh Ubuntu runner, checks out your code, installs Python, installs your dependencies, and runs your tests. All automatically.

Before you check the Actions tab, take a guess: do you think all 4 tests will pass on the runner, or will something be different from your local environment?

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository page.
  • You should see a workflow run listed under CI Pipeline with an orange dot (in progress) or a green checkmark (complete).
  • Click on the run to open it, then click the test job to see each step's logs.

You should see all 4 tests passing in the Run tests step logs, with verbose output showing each test function name and a PASSED status. The overall workflow shows a green checkmark.

Workflow not appearing in the Actions tab?

Make sure your push actually went through by running git log --oneline -1 and comparing the commit hash to what GitHub shows. If the push succeeded but the workflow doesn't appear, confirm the file is at exactly .github/workflows/ci.yml (not .github/workflow/ci.yml or another path).

Also check that your commit message doesn't include a skip annotation like [skip ci] which tells GitHub to skip workflow runs on that push.

help me figure out why my GitHub Actions workflow isn't running

Your tests now run automatically every time you push to main. Next up, you'll expand this workflow with more triggers, secrets, and environment variables to make it production-ready.

Add Triggers, Secrets, and Environment Variables

Your pipeline now runs tests automatically whenever you push to main. But what about pull requests? What about triggering a run manually to debug something? And how do you inject sensitive values like API tokens without hardcoding them in your workflow file?

In this step, you will expand your workflow's triggers to cover more scenarios, explore the default environment variables GitHub Actions injects into every run, and securely wire up a repository secret.

In this step, get ready to:
  • Add pull request and manual dispatch triggers to your workflow.
  • Print default and custom environment variables from a workflow step.
  • Create and inject a repository secret without exposing its value.
Expand your workflow triggers and add a greeting step

Right now your workflow only fires on a push to main. Production pipelines also need to run on pull requests (to catch bugs before merging) and on demand (to debug without pushing a dummy commit).

  • In your code editor, open .github/workflows/ci.yml.
  • Find the existing on: block at the top of the file:
on:
  push:
    branches: [main]
  • Replace that entire on: block with the following expanded triggers:
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      greeting:
        description: 'A custom greeting message'
        required: false
        default: 'Hello from manual trigger'
        type: string

What do these triggers do?

  • pull_request fires the workflow whenever someone opens or updates a PR targeting main.
  • workflow_dispatch adds a "Run workflow" button in the GitHub Actions tab so you can trigger a run manually.
  • The inputs block under workflow_dispatch defines a form field that appears when you click that button. You will use this greeting input to prove the manual trigger works.

Now add the step that prints the greeting when the workflow is triggered manually.

  • Scroll down to the steps: section. After the Run tests step, add this new step:
      - name: Print greeting (manual trigger only)
        if: github.event_name == 'workflow_dispatch'
        run: echo "${{ github.event.inputs.greeting }}"

What does the if: condition do?

The if: line makes this step run ONLY when the workflow was triggered manually. On a regular push or pull request, GitHub skips it entirely. The value ${{ github.event.inputs.greeting }} reads whatever the user typed into the greeting form field.

This pattern is useful for toggling debug output, selecting deployment targets, or any behaviour you only want during a manual run.

Add environment info step and a custom env var

GitHub automatically injects environment variables into every workflow run. Variables like GITHUB_SHA and RUNNER_OS tell your steps exactly which commit triggered the run and what machine is executing it. You will print these to understand what information is available to your pipeline.

  • First, add a job-level env: block to define a custom environment variable. Find the line runs-on: ubuntu-latest and add the following directly below it:
    env:
      PROJECT_NAME: python-ci-actions

What is a job-level env var?

Placing env: at the job level makes the variable available to every step in that job. This is your own custom variable, separate from the ones GitHub sets automatically.

  • Now add the "Show environment info" step. Place it between the Run tests step and the Print greeting step you just added:
      - name: Show environment info
        run: |
          echo "=== Default Environment Variables (shell access) ==="
          echo "GITHUB_SHA: $GITHUB_SHA"
          echo "GITHUB_REF: $GITHUB_REF"
          echo "GITHUB_ACTOR: $GITHUB_ACTOR"
          echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY"
          echo "GITHUB_RUN_ID: $GITHUB_RUN_ID"
          echo "GITHUB_RUN_NUMBER: $GITHUB_RUN_NUMBER"
          echo "GITHUB_WORKFLOW: $GITHUB_WORKFLOW"
          echo "GITHUB_EVENT_NAME: $GITHUB_EVENT_NAME"
          echo "RUNNER_OS: $RUNNER_OS"
          echo "RUNNER_ARCH: $RUNNER_ARCH"
          echo ""
          echo "=== Context Properties (expression syntax) ==="
          echo "github.sha: ${{ github.sha }}"
          echo "github.ref: ${{ github.ref }}"
          echo "github.ref_name: ${{ github.ref_name }}"
          echo "github.actor: ${{ github.actor }}"
          echo "runner.os: ${{ runner.os }}"
          echo ""
          echo "=== Custom Environment Variable ==="
          echo "PROJECT_NAME: $PROJECT_NAME"

What does this step show?

This step demonstrates two ways to access GitHub's default variables. Shell access ($GITHUB_SHA) reads the environment variable directly. Context syntax (${{ github.sha }}) uses GitHub's expression engine, which works in YAML fields beyond just run: blocks.

The last section prints your custom PROJECT_NAME variable, showing the difference between GitHub-provided defaults and your own job-level variables.

  • Save ci.yml. Do not push yet.
Create a repository secret and add the secret injection step

Sensitive values like API tokens must never appear in your workflow file. GitHub lets you store them as encrypted secrets that your workflow accesses at runtime. The value is masked in logs so it never leaks.

  • In your browser, go to your python-ci-actions repository on GitHub.
  • Click the Settings tab.
  • In the left sidebar under Security, click Secrets and variables, then click Actions.
  • Click New repository secret.
  • In the Name field, enter NOTIFY_TOKEN.
  • In the Secret field, enter my-secret-value-123.
  • Click Add secret.

You should see NOTIFY_TOKEN listed on the repository secrets page.

Why a dummy value?

In a real project this would be a genuine API token or webhook URL. For this exercise, any string works because you are proving the injection mechanism, not calling a real service.

Don't see the Settings tab?

The Settings tab only appears if you are the repository owner. Make sure you are viewing the repo you created, not a fork or someone else's repository.

Help me find the Settings tab for my repository.

Now add the step that uses this secret in your workflow.

  • Back in your code editor, open ci.yml and add this step after the Print greeting step:
      - name: Verify secret injection
        env:
          NOTIFY_TOKEN: ${{ secrets.NOTIFY_TOKEN }}
        run: echo "Token length: ${#NOTIFY_TOKEN}"

How does secret injection work?

The env: block at the step level maps the repository secret into a local environment variable. The ${{ secrets.NOTIFY_TOKEN }} expression fetches the encrypted value at runtime.

The command prints the token's character length, not its value. GitHub Actions automatically masks any secret value that appears in logs, replacing it with ***.

  • Save ci.yml.
  • Commit and push all your changes by running these commands in your terminal:
git add .github/workflows/ci.yml
git commit -m "Add triggers, env vars, and secret step"
git push

The push triggers the workflow automatically. But to test your workflow_dispatch trigger and greeting input, you need to trigger a run manually.

  • On GitHub, click the Actions tab.
  • In the left sidebar, click CI Pipeline.
  • Click the Run workflow button on the right side (above the list of runs).
  • In the greeting input field, type Hi from NextWork!.
  • Click the green Run workflow button to confirm.

Before you click into the run, what do you expect to see in the logs? Think about which steps will execute and which will be skipped on a manual trigger.

  • Once the run appears in the list, click into it, then click the test job to view the step logs.

You should see three things confirming your work:

  • The "Show environment info" step prints your commit SHA, branch ref, actor name, runner OS, and your custom PROJECT_NAME variable.
  • The "Verify secret injection" step prints Token length: 19 (the length of my-secret-value-123). The actual value is masked as *** if it appears anywhere.
  • The "Print greeting" step shows Hi from NextWork! because you triggered manually.

Run workflow button not showing?

The button only appears once the workflow_dispatch trigger is present in the workflow file on your default branch (main). Make sure your push completed successfully and the Actions tab shows CI Pipeline in the left sidebar.

Help me get the Run workflow button to appear.

✔️ Awesome, I've got everything!

Great. Double check you have saved ci.yml and all three confirmation points appear in your workflow logs.

ⓧ I'd like to double check the full code

Your ci.yml should now contain all of the following sections in this order. Scroll up through your file and confirm each is present:

  • The expanded on: block with push, pull_request, and workflow_dispatch (with the greeting input).
  • The job-level env: block with PROJECT_NAME: python-ci-actions directly below runs-on: ubuntu-latest.
  • Steps in order: checkout, Set up Python 3.13, Install dependencies, Run tests, Show environment info, Print greeting (manual trigger only), Verify secret injection.
  • The if: github.event_name == 'workflow_dispatch' condition on the greeting step.
  • The step-level env: mapping NOTIFY_TOKEN: ${{ secrets.NOTIFY_TOKEN }} on the secret injection step.

Your pipeline now responds to pushes, pull requests, and manual triggers. It reads both GitHub-provided and custom environment variables, and it injects secrets without ever exposing them. Next up, you will test across multiple Python versions and cache dependencies for faster runs.

Add Matrix Builds and Dependency Caching

Your pipeline currently tests on Python 3.13 only. If someone runs your library on Python 3.11 and a behavior difference breaks things, your CI will not catch it.

On top of that, every workflow run installs dependencies from scratch. Matrix builds solve the first problem by running tests across multiple Python versions in parallel. Dependency caching solves the second by reusing downloaded packages between runs.

In this step, get ready to:
  • Add a matrix strategy to test across Python 3.11, 3.12, and 3.13.
  • Enable pip caching to skip redundant dependency downloads.
  • Verify parallel jobs and cache restoration on GitHub.
Add matrix strategy and enable pip caching

A matrix strategy tells GitHub Actions to run the same job multiple times with different configurations. Each combination spawns its own runner in parallel.

  • Open .github/workflows/ci.yml in your editor.
  • Find this section near the top of the test job:
  test:
    runs-on: ubuntu-latest
    env:
      PROJECT_NAME: python-ci-actions

    steps:
      - uses: actions/checkout@v7

      - name: Set up Python 3.13
        uses: actions/setup-python@v7
        with:
          python-version: '3.13'
  • Replace that section with the following, which adds the matrix strategy and enables caching:
  test:
    runs-on: ubuntu-latest
    env:
      PROJECT_NAME: python-ci-actions
    strategy:
      matrix:
        python-version: ['3.11', '3.12', '3.13']

    steps:
      - uses: actions/checkout@v7

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

What changed here?

  • strategy: matrix: python-version: defines a list of Python versions. GitHub Actions spawns one parallel job for each version in the list.
  • ${{ matrix.python-version }} injects the current iteration's version into the step name and the setup-python action.
  • cache: 'pip' tells actions/setup-python to cache pip's download directory between runs. On the first run it saves the cache. On subsequent runs it restores it, skipping network downloads.
  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you saved your file before moving on.

ⓧ I'd like to double check the full code

Your ci.yml is over 30 lines, so confirm these sections are present in order:

  • The on: block with push, pull_request, and workflow_dispatch (with a greeting input).
  • The test job with env: PROJECT_NAME: python-ci-actions followed by strategy: matrix: python-version: ['3.11', '3.12', '3.13'].
  • The Set up Python ${{ matrix.python-version }} step with python-version: ${{ matrix.python-version }} and cache: 'pip'.
  • The remaining steps unchanged: Install dependencies, Run tests, Show environment info, Print greeting, Verify secret injection.
Push and verify parallel jobs

Time to see the matrix in action. This first push triggers 3 parallel jobs and populates the pip cache for the first time.

  • Commit and push your changes by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add matrix builds and pip caching"
git push
  • Open your repository on GitHub.
  • Click the Actions tab.
  • Click into the latest workflow run.

Before you look at the result, how many jobs do you expect to see listed? Why?

You should see 3 separate jobs in the run: test (3.11), test (3.12), and test (3.13). All three run in parallel and should complete with green checkmarks.

Only seeing one job?

Check that your strategy: block is indented directly under test: at the same level as runs-on:. YAML is whitespace-sensitive, so incorrect indentation can silently break the matrix.

Also confirm that python-version in the matrix: block matches what you reference in the step: ${{ matrix.python-version }}.

Help me debug my matrix strategy in GitHub Actions.

Verify cache restoration on a second run

The first run saved the pip cache. Now push a trivial change to trigger a second run and confirm the cache gets restored.

  • Open src/calculator.py and add a comment at the top, below the docstring:
# Trigger second CI run to verify cache
  • Save the file, then commit and push by running:
git add src/calculator.py
git commit -m "Trigger cache verification run"
git push
  • Return to the Actions tab and click into the new workflow run.
  • Click on any of the three jobs (e.g., test (3.13)).
  • Expand the Set up Python step in the logs.

What do you expect to see differently in this step's logs compared to the first run?

You should see a "Cache restored" message in the Set up Python step logs. The Install dependencies step should also complete noticeably faster because pip reuses the cached packages instead of downloading them from the network.

Why not a separate actions/cache step?

The actions/setup-python action has built-in pip caching via the cache: 'pip' input. This handles the cache key, save, and restore automatically. A separate actions/cache step gives more control over cache keys, but for pip dependencies the built-in approach is simpler and covers most cases.

Not seeing a cache restored message?

Confirm that cache: 'pip' is inside the with: block of your setup-python step, at the same indentation level as python-version:. If it sits outside with:, the action ignores it.

Help me troubleshoot pip caching in GitHub Actions.

Your pipeline now tests across three Python versions in parallel and caches dependencies for faster subsequent runs. Next up, you'll add test coverage reports as downloadable artifacts and chain jobs together with dependencies.

Add Artifacts and Job Dependencies

Your pipeline now runs tests across three Python versions with cached dependencies. But once a workflow run finishes, the output vanishes. You have no downloadable proof of test coverage.

This step introduces two powerful features: artifacts for storing build outputs as downloadable files, and job dependencies for controlling when jobs execute. You'll generate HTML coverage reports, upload them to GitHub, and add a second job that only runs after all tests pass.

In this step, get ready to:
  • Generate test coverage reports and upload them as downloadable artifacts.
  • Add a dependent job that downloads and verifies artifacts across jobs.
  • Confirm the workflow completes with all artifacts visible on GitHub.
Add coverage reporting and artifact upload

Right now, the test step runs pytest but doesn't measure how much of your code the tests actually cover. You'll upgrade the test command to generate an HTML coverage report, then upload that report as a workflow artifact.

  • In .github/workflows/ci.yml, find the Run tests step:
      - name: Run tests
        run: python -m pytest -v
  • Replace both the step name and the command so it looks like this:
      - name: Run tests with coverage
        run: python -m pytest --cov=src --cov-report=html -v

What changed in the test command?

  • --cov=src tells pytest-cov to measure coverage for your src/ directory.
  • --cov-report=html generates a browsable HTML report in a folder called htmlcov/.
  • The -v flag keeps verbose test output so you can still see individual test results in the logs.

Now you need a step that takes the generated htmlcov/ folder and stores it as a downloadable artifact on the workflow run.

  • In the same file, find the Show environment info step.
  • Directly below that step (before the Print greeting step), add the following upload step:
      - name: Upload coverage report
        uses: actions/upload-artifact@v7
        with:
          name: coverage-report-${{ matrix.python-version }}
          path: htmlcov/

What does this upload step do?

The actions/upload-artifact@v7 action takes files from the runner and attaches them to the workflow run as downloadable artifacts.

  • name sets the artifact's display name. Using ${{ matrix.python-version }} means each Python version produces its own uniquely named report.
  • path points to the htmlcov/ directory that pytest-cov generates.
  • Save .github/workflows/ci.yml.
Create a dependent report job

You now have each matrix job uploading its own coverage report. But what if you want a job that runs only after all test jobs succeed?

The needs keyword creates a dependency between jobs. A job with needs: test waits for the test job to finish before it starts.

  • At the bottom of .github/workflows/ci.yml, after the entire test job block, add the following report job:
  report:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Download coverage artifact
        uses: actions/download-artifact@v8
        with:
          name: coverage-report-3.13
          path: coverage-report-3.13

      - name: List report contents
        run: ls -la coverage-report-3.13/

What does the report job do?

  • needs: test means this job will not start until every matrix variation of the test job has completed successfully.
  • actions/download-artifact@v8 pulls the coverage-report-3.13 artifact from the test job into a local directory. This proves artifacts can be shared between jobs in the same workflow run.
  • ls -la coverage-report-3.13/ lists the downloaded files so you can confirm the transfer worked in the logs.
  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Your ci.yml is long, so confirm these sections are present in order:

  • The on: block with push, pull_request, and workflow_dispatch triggers.
  • The test job with matrix strategy, pip caching, and these steps in order: checkout, setup-python, install dependencies, Run tests with coverage, Show environment info, Upload coverage report, Print greeting, Verify secret injection.
  • The report job at the bottom with needs: test, the download-artifact step, and the ls -la step.
Push and verify the artifacts
  • Commit and push your changes by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add coverage artifacts and report job"
git push

Before you check the Actions tab, think about what you expect to see. How many jobs should appear in this workflow run? Which one should run last?

  • Open your repository on GitHub and click the Actions tab.
  • Click the latest workflow run ("Add coverage artifacts and report job").
  • In the run visualization, confirm you see 3 test jobs and 1 report job. The report job should have started only after all 3 test jobs completed.
  • Scroll down to the Artifacts section on the run summary page. You should see coverage-report-3.11, coverage-report-3.12, and coverage-report-3.13 listed as downloadable zip files.
  • Click into the report job and expand the List report contents step. You should see the HTML files from the coverage report listed in the output.

What if a test job fails?

If any of the 3 matrix test jobs fail, the report job will show as Skipped in the run visualization. The needs keyword prevents it from running when its dependency hasn't succeeded.

Don't see the artifacts or report job?

  • Check the indentation of your report job. It must be at the same level as test (two spaces under jobs:).
  • Verify the upload step's path: htmlcov/ matches the directory pytest-cov creates. If the test step failed, no htmlcov/ folder exists to upload.
  • Make sure needs: test matches the job ID exactly. YAML is case-sensitive.

Why isn't my report job running after the test jobs?

Your pipeline now produces real proof. Every run uploads coverage reports you can download and review, and the report job proves that artifacts transfer cleanly between dependent jobs. Next up, you'll tackle the Secret Mission and add scheduled triggers with conditional notifications.

Secret mission

Scheduled Trigger and Conditional Notifications

Production pipelines don't just react to pushes. They run on a schedule, test your code every night, and alert you when something breaks. Can you wire up a nightly build that sends a failure notification only when tests fail?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on GitHub Actions with a public repository, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • GitHub repository python-ci-actions (public, with two workflow files).
  • Repository secret NOTIFY_TOKEN stored in the repository settings.
  • Local project folder python-ci-actions on your Desktop.
  • Installed Python packages pytest and pytest-cov.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your CI pipeline as a reference.

  • Your workflows will continue to run on every push and pull request at no cost.
  • The nightly workflow will trigger daily at midnight UTC. This is free on a public repository.
  • Your coverage report artifacts are stored for 90 days by default, then automatically cleaned up by GitHub.

Pause - I'll come back to this later

Disable scheduled workflows to stop the daily nightly runs while keeping all your files and configuration intact.

  • Go to your repository's **Actions** tab on GitHub.
  • Click **Nightly Build** in the left sidebar.
  • Click the three-dot menu (...) and select **Disable workflow**.
  • Your push and PR triggers on ci.yml will still fire if you push code. To pause those too, repeat for **CI Pipeline**.
  • When you return, re-enable workflows from the same menu.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Delete the GitHub repository: go to your repository on GitHub, click **Settings**, scroll down to the **Danger Zone** section, and click **Delete this repository**. Confirm by typing the repository name.
  • Deleting the repository also removes the NOTIFY_TOKEN secret, all workflow run history, and stored artifacts.
  • Delete the local project folder. On macOS/Linux, run:
  • On Windows, run:
cd ~/Desktop
rm -rf python-ci-actions
cd ~\Desktop
Remove-Item -Recurse -Force python-ci-actions
  • Uninstall the Python packages you installed for this project by running:
pip uninstall pytest pytest-cov -y

Nice Work!

Nice Work!

You just built a production-grade CI pipeline from scratch using GitHub Actions. Your python-ci-actions repository now automatically tests, caches, and reports on every code change you push.

You've learned how to:

  • Build a CI pipeline triggered by push, pull request, and manual dispatch that runs matrix builds across Python 3.11, 3.12, and 3.13 with pip caching and secrets injected securely.
  • Use default environment variables like GITHUB_SHA, GITHUB_REF, and RUNNER_OS alongside custom env vars to inspect and control workflow behaviour at runtime.
  • Upload test coverage reports as downloadable artifacts and chain jobs with needs to create dependent workflows that download artifacts across jobs.
  • Secret Mission: Add a scheduled nightly build with conditional failure notifications using if: failure() to alert only when something breaks.

Ready to quiz yourself?