GitHub Actions — Python CI

A production-ready GitHub Actions workflow for Python projects: matrix testing across Python 3.10, 3.11, and 3.12, dependency installation from requirements.txt, pytest for tests, and flake8 for linting — all in one workflow file.

🛠 Paste this YAML into the validator to check it instantly.

Open Validator →

Overview

This workflow runs on every push to main and on pull requests targeting main. It uses a build matrix to test against three currently-supported Python versions simultaneously — 3.10, 3.11, and 3.12 — catching version-specific incompatibilities before they ship.

Each matrix job: checks out the code, sets up the correct Python interpreter via actions/setup-python, upgrades pip and installs project dependencies from requirements.txt, runs the test suite with pytest, and lints the codebase with flake8. The linting step runs after tests so a failing test doesn't hide lint errors.

Save this file as .github/workflows/ci.yml (or any descriptive name ending in .yml) at the root of your repository under the .github/workflows/ directory.

Full YAML Copy-paste ready

.github/workflows/ci.yml
name: Python CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - name: Run tests
        run: pytest
      - name: Lint with flake8
        run: flake8 .

Key sections explained

Python version matrix with quoted strings

Notice that the Python versions are written as quoted strings: "3.10", "3.11", "3.12". This is important. Without quotes, YAML would parse 3.10 as the floating-point number 3.1 (dropping the trailing zero), which would cause setup-python to fail when looking for a version named 3.1. Always quote Python version numbers in YAML matrix definitions.

Each matrix entry is substituted into the step via ${{ matrix.python-version }}, so the job name in GitHub's UI will show something like "test (3.10)", "test (3.11)", etc., making it easy to spot which version caused a failure.

Multi-line run steps

The "Install dependencies" step uses the YAML block scalar syntax with | (the literal block indicator) to run two commands in sequence within a single step:

multi-line run
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

The | preserves newlines, so each line becomes a separate shell command. The step fails if any command exits with a non-zero code. This is preferable to chaining commands with && when you have more than two commands — it's easier to read and maintain.

The pip upgrade pattern

python -m pip install --upgrade pip upgrades pip itself before installing project dependencies. This is a widely recommended practice because the pip bundled with older Python versions may lack support for newer wheel formats or resolver features. Running it through python -m pip (rather than just pip) ensures you're upgrading the pip associated with the active Python interpreter in the virtual environment.

pytest and flake8 as separate steps

Keeping pytest and flake8 as separate steps (rather than combining them in a single run: | block) gives you independent pass/fail status for each in GitHub's UI. If tests pass but linting fails, you'll see exactly which step failed — rather than just "the CI step failed." It also makes it easier to add step-level configuration like continue-on-error: true to the lint step if you want lint failures to be advisory rather than blocking.

flake8 . lints all Python files in the repository. If you have generated files, vendored code, or migration files you don't own, add a .flake8 config file or setup.cfg with an exclude list to prevent false positives.

Tips & variations

Cache pip dependencies

actions/setup-python@v5 supports pip caching out of the box. Add cache: 'pip' to the with block to cache the pip download cache between runs, keyed on your requirements files:

with pip caching
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
          cache-dependency-path: 'requirements*.txt'

Use separate requirements files for dev dependencies

Many projects keep test and lint dependencies in a separate file (e.g. requirements-dev.txt). Install both in the dependency step: pip install -r requirements.txt -r requirements-dev.txt. This keeps production dependencies clean while ensuring CI has everything it needs.

Add type checking with mypy

For typed Python projects, add a mypy step after flake8:

mypy step
      - name: Type check with mypy
        run: mypy src/

Make sure mypy is listed in your dev requirements file. Consider pairing this workflow with locally before they reach CI.

Use a pyproject.toml-based project

If your project uses pyproject.toml with a build backend like Hatch or PDM, replace pip install -r requirements.txt with pip install .[dev] or pip install --editable .[dev] to install the package and its extras in one step.