# Setting up GitHub Actions with 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](https://pydevtools.com/handbook/reference/uv.md), running [pytest](https://pydevtools.com/handbook/reference/pytest.md) and [Ruff](https://pydevtools.com/handbook/reference/ruff.md) across multiple Python versions.

## Prerequisites

* [uv](https://pydevtools.com/handbook/reference/uv.md) installed ([installation guide](https://docs.astral.sh/uv/getting-started/installation/))
* A GitHub account
* Familiarity with [setting up testing with pytest and uv](https://pydevtools.com/handbook/tutorial/setting-up-testing-with-pytest-and-uv.md)

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

```console
$ 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:

```console
$ 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.

```console
$ 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`:

```console
$ 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:

```yaml
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:

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: astral-sh/setup-uv@v8.1.0
        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@v8.1.0` installs uv. The action uses a full version tag because setup-uv no longer publishes moving tags like `@v8` (see [how to upgrade setup-uv from v7 to v8](https://pydevtools.com/handbook/how-to/how-to-upgrade-setup-uv-from-v7-to-v8.md)). 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`:

```yaml
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@v8.1.0
        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`:

```yaml
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@v8.1.0
        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](https://github.com/python-developer-tooling-handbook/demo-uv-github-actions)) and point the local clone at it:

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

Now push the workflow:

```console
$ 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

* See the [completed example project](https://github.com/python-developer-tooling-handbook/demo-uv-github-actions/tree/with-ci) with the workflow already in place.
* Read the [`setup-uv` action documentation](https://github.com/astral-sh/setup-uv) for additional options like pinning a uv version or caching specific directories.
* If you have existing workflows using `@v7`, see [how to upgrade setup-uv from v7 to v8](https://pydevtools.com/handbook/how-to/how-to-upgrade-setup-uv-from-v7-to-v8.md) for migration steps and SHA-pinning for supply-chain security.
* See the [publishing tutorial](https://pydevtools.com/handbook/tutorial/publishing-your-first-python-package-to-pypi.md) to learn how to publish your package to PyPI, including how to use [trusted publishing](https://docs.pypi.org/trusted-publishers/) from GitHub Actions.
