Skip to content

Setting up GitHub Actions with uv

uv

Every push to main and every pull request should be tested and linted automatically. This tutorial sets up that pipeline using GitHub Actions and uv, running pytest and Ruff across multiple Python versions.

Prerequisites

Clone the Example Project

This tutorial uses a small temperature-converter project with code and tests already written but no CI configured. Clone it and move into the directory:

$ git clone https://github.com/python-developer-tooling-handbook/demo-uv-github-actions.git
Cloning into 'demo-uv-github-actions'...
$ cd demo-uv-github-actions

Notice that the cloned repo already contains pyproject.toml, uv.lock, and a .python-version file. The lockfile is the part CI cares about most. It pins every transitive dependency to an exact version, so the workflow can install the same set on every run.

Verify the project works locally:

$ uv sync --locked --dev
Using CPython 3.13.5
Creating virtual environment at: .venv
Resolved 8 packages in 1ms
   Building demo-uv-github-actions @ file:///path/to/demo-uv-github-actions
Prepared 7 packages in 1.29s
Installed 7 packages in 16ms
 + demo-uv-github-actions==0.1.0 (from file:///path/to/demo-uv-github-actions)
 + iniconfig==2.3.0
 + packaging==26.0
 + pluggy==1.6.0
 + pygments==2.19.2
 + pytest==9.0.2
 + ruff==0.15.5

Note

If you see error: No pyproject.toml found in current directory or any parent directory, you ran the command outside the project. cd demo-uv-github-actions first.

$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.13.5, pytest-9.0.2, pluggy-1.6.0
rootdir: /path/to/demo-uv-github-actions
configfile: pyproject.toml
collected 5 items

tests/test_converter.py .....                                            [100%]

============================== 5 passed in 0.02s ===============================

Five passing tests confirm the local environment is wired up correctly. The workflow will run the same command on GitHub’s runners.

Create the Workflow File

GitHub Actions workflows live in .github/workflows/. Create the directory and a new file called ci.yml:

$ mkdir -p .github/workflows

The path matters: GitHub Actions auto-discovers any .yml or .yaml file inside .github/workflows/. A workflow file in any other location is ignored.

Open .github/workflows/ci.yml in your editor and add the following:

name: CI

on:
  push:
    branches: [main]
  pull_request:

The on block tells GitHub to run this workflow on every push to main and on every pull request.

Add the Test Job

Below the on block, add a jobs section:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - run: uv sync --locked --all-extras --dev
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run pytest

Here is what each step does:

  1. actions/checkout@v6 clones the repository into the runner.
  2. astral-sh/setup-uv@v7 installs uv. Setting enable-cache: true caches the uv package store between runs, so subsequent CI builds skip re-downloading dependencies.
  3. uv sync --locked --all-extras --dev installs every dependency from uv.lock without modifying the lockfile. The --locked flag ensures CI fails if the lockfile is out of date.
  4. uv run ruff check . runs the Ruff linter.
  5. uv run ruff format --check . checks that all files are formatted according to Ruff’s formatter (without modifying them).
  6. uv run pytest runs the test suite.

Test Across Multiple Python Versions

To verify your code works on more than one Python version, add a strategy.matrix to the job and pass the version to setup-uv:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v7
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
      - run: uv sync --locked --all-extras --dev
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run pytest

GitHub Actions will now run a parallel job for each Python version in the matrix. Each job is independent: a failure on 3.12 does not cancel the 3.13 or 3.14 jobs, so a single matrix run tells you exactly which versions are broken.

The Complete Workflow

Here is the full .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v7
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
      - run: uv sync --locked --all-extras --dev
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run pytest

Push and Verify

You cannot push to the handbook’s example repo directly. Create a fork on GitHub (use the Fork button on the repo page) and point the local clone at it:

$ git remote set-url origin https://github.com/<your-username>/demo-uv-github-actions.git

Now push the workflow:

$ git add .github/workflows/ci.yml
$ git commit -m "Add CI workflow"
$ git push

Note

If git push fails with remote: Permission to ... denied, the remote still points at the upstream demo repo. Run git remote set-url origin <your-fork-url> to fix it.

Open your fork on GitHub and click the Actions tab. The workflow run appears as soon as the push lands on the default branch, with one job per Python version. Each job checks out the code, installs dependencies, lints, and runs tests.

If all checks pass, you’ll see green checkmarks. If any step fails, click into the job to read the logs and identify the issue. The first run is slower than later runs because setup-uv’s cache is empty; subsequent runs reuse the cached uv package store and skip re-downloading dependencies.

Next Steps

Last updated on