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.
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.
.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
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:
- 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:
- 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:
- 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.