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, running pytest and Ruff across multiple Python versions.
Prerequisites
- uv installed (installation guide)
- A GitHub account
- Familiarity with setting up testing with pytest and uv
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 pytestHere is what each step does:
actions/checkout@v6clones the repository into the runner.astral-sh/setup-uv@v7installs uv. Settingenable-cache: truecaches the uv package store between runs, so subsequent CI builds skip re-downloading dependencies.uv sync --locked --all-extras --devinstalls every dependency fromuv.lockwithout modifying the lockfile. The--lockedflag ensures CI fails if the lockfile is out of date.uv run ruff check .runs the Ruff linter.uv run ruff format --check .checks that all files are formatted according to Ruff’s formatter (without modifying them).uv run pytestruns 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 pytestGitHub 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 pytestPush 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
- See the completed example project with the workflow already in place.
- Read the
setup-uvaction documentation for additional options like pinning a uv version or caching specific directories. - See the publishing tutorial to learn how to publish your package to PyPI, including how to use trusted publishing from GitHub Actions.