Skip to content
compiler.dev

Python GitHub Actions Workflow Template

Last updated

This is a working .github/workflows/ci.yml for a Python project. It caches pip downloads, tests on three Python versions, lints with ruff and runs pytest. Adjust the install step if you use Poetry or uv.

The workflow

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        python: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}
          cache: pip
          cache-dependency-path: |
            requirements*.txt
            pyproject.toml

      - run: python -m pip install --upgrade pip
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: pip install ruff pytest
      - run: ruff check .
      - run: pytest -q

What the non-obvious parts do

  • Quoted versions. "3.11" must be a string. Unquoted 3.10 is read by YAML as the number 3.1, a classic bug.
  • cache: pip caches pip's download cache, not the virtual environment. The key is built from the files named in cache-dependency-path. If you leave it out, setup-python looks for requirements.txt only. See caching pip.
  • pip install ruff pytest is here so the template runs on a bare project. If these are already in your dev requirements, delete the line.
  • permissions, concurrency and timeout-minutes are the same safety trio used in every template: read-only token, cancel superseded pull request runs, and a job time cap.
  • pytest -q keeps logs short. Add -x to stop at the first failure while debugging.

Variants

Poetry

Install Poetry with pipx install poetry, then use cache: poetry in setup-python and poetry install --no-interaction. Run commands with poetry run.

uv

Use astral-sh/setup-uv@v5 with enable-cache: true, then uv sync and uv run pytest.

Monorepo

Use working-directory: on steps, or defaults.run.working-directory on the job, and point cache-dependency-path at each package's requirements file. Add a paths: filter so only affected packages run.

Database tests

Add a services: block with Postgres; the Django template shows the full pattern.

Deploy job

Add a second job with needs: test and if: github.ref == 'refs/heads/main'. Build the wheel with python -m build and upload it as an artifact first.

macOS or Windows

Add os: [ubuntu-latest, windows-latest] to the matrix if you publish a library that must work on both.

Run it on faster machines

Test suites are often the slow part. Change one line:

    runs-on: [self-hosted, Linux, compiler-ci]

The job then runs on a fresh 8 vCPU machine from compiler.dev, with automatic fallback to your original runner. Pair it with pytest-xdist (pytest -n auto) to use the extra cores, and see why pytest is slow and pricing.

FAQ

Should I cache the virtualenv?

Usually no. Caching pip's downloads is safer. A cached virtualenv can break when the Python patch version changes.

How do I test only one Python version on pull requests?

Use a matrix with all versions on main and a single version on pull requests, using include. See matrix builds.

Why does pip install take so long?

Packages with compiled extensions are built from source when no wheel exists for your Python version. Pin versions that ship wheels.

Can I speed up pytest?

Yes: pytest-xdist for cores, test sharding for machines.