Let's dive in advanced Python automation with nox, uv, and GitHub Actions — Part 5 of the Terminus Series
Building on Part 4, where we introduced fast, modern MLOps pipelines using uv, Docker, and CI/CD, this fifth installment focuses on automating your developer workflow using Nox — and why in 2025 it still reigns as one of the most flexible, transparent tools for Python-based projects.
This article walks through:
- ✅ What exactly Nox is and why it's still relevant in 2025
- 🧰 How we configure our noxfile in Terminus
- 🚀 How GitHub Actions can dynamically discover and run your nox sessions
- ⚖️ A brief but robust comparison of
nox,Poe the Poet, anduvCLI - 🏗️ Why Nox, despite the rise of CLI runners, still plays a crucial orchestration role
Absolutely! Here's a more comprehensive and pedantic rewrite of the section "Going Deeper with uv CLI Tools", structured to clearly guide the reader, explain motivations and use cases, and gracefully transition into the next section on nox:
Going Deeper with uv CLI Tools — Beyond uv sync
Up to this point, we've mostly used uv as a fast, lockfile-aware dependency resolver — replacing pip, pip-tools, or poetry for installing packages via uv sync. But that’s only scratching the surface.
In practice, uv is becoming a holistic developer toolchain manager — and in projects like Terminus, it eliminates the need for make, bash, or even task runners like poethepoet in many cases.
Let’s explore the full potential of uv as a modern CLI toolchain — both for local development and CI/CD pipelines.
uv tool install and uv tool run: Global CLI without Virtualenv Pain
While uv sync installs project-scoped dependencies into virtual environments, uv tool manages CLI tools globally, with user isolation:
uv tool install nox
uv tool run nox -- -s lintThis:
- Installs
nox(or any CLI tool) into a dedicated global environment at~/.local/share/uv/tools - Keeps these tools separate from your project env or system Python
- Ensures reproducibility across developer machines and CI — version-pinned via
uv.lockif needed -
Avoids
venvactivation, making developer onboarding zero-friction
In CI (as in Terminus), this means we don't need to activate a venv, use pipx, or worry about system-wide pip issues. We simply run:
uv tool install nox
uv tool run nox -- -s lintCaching CLI Tools Between CI Runs
One performance advantage of uv tool is that CLI tools are cached globally in:
~/.local/share/uv/toolsBy caching this directory in GitHub Actions:
- uses: actions/cache@v4
with:
path: ~/.local/share/uv/tools
key: uv-tools-${{ runner.os }}-${{ hashFiles('pyproject.toml', '**/uv.lock') }}You:
- Avoid repeated downloads or rebuilds of CLI tools (e.g.,
nox,ruff,pytest) - Keep your build minimal and declarative — no surprise installs during jobs
- Ensure clarity between “project dependencies” (
uv sync) and “development tools” (uv tool)
Defining CLI Commands with [tool.uv.cli] in pyproject.toml
Just like poethepoet, uv lets you define custom developer commands directly in pyproject.toml — no extra dependencies needed.
[tool.uv.cli]
lint = "ruff check src"
format = "ruff format src"
test = "pytest tests/"Run them with:
uv run lint
uv run testThis gives you the ergonomics of a task runner (like make or poe) without adding a new config language or runtime dependency.
You can think of it as task aliases bound to your dependency context, all managed by uv.
Recommendations:
- Use
uv runor[tool.uv.cli]for simple local dev flows:lint,serve,test, etc. - Use
poethepoetif you need cross-platformmake-like chaining with config inpyproject.toml - Use
noxif you need session orchestration, multi-version testing, and dynamic CI compatibility
Now that we understand how uv and uv tool fit into modern Python projects, let’s look at how we use nox in Terminus to automate linting, testing, and Docker builds — in both local and CI pipelines.
Let’s dive into nox and dynamic CI automation.
What is nox, and Why Use It?
Nox is a Python automation tool designed to run sessions — isolated environments where you install dependencies and run scripts.
Unlike make, poe, or raw shell scripts:
- Each session is run in a fresh virtual environment (by default)
- You can target multiple Python versions per session
- Dependencies can be scoped using a tool like
uv,pip, orpoetry - It’s Python-native: no new DSL, write Python functions
In Terminus, we use nox as the automation layer for tasks like linting and testing and can be extended to build the docs, clean up etc.
This makes Nox ideal for CI workflows — especially when the definition of what to run changes over time (via dynamic session discovery).
Terminus noxfile.py — Dissection
Let’s look at how Terminus’ noxfile.py enforces:
- Single source of truth for Python version (extracted from
pyproject.toml) - Group-based dependency installation via
uv - Dev-friendly modular automation
# Parses Python version from pyproject.toml
# Sets uv as the backend
# Defines two nox sessions: lint
@nox.session(python=PYTHON_VERSION)
def lint(session):
"""Run Ruff linter and formatter checks."""
session.run(
"uv", "sync", "--group", LINT_GROUP,
f"--python={session.python}",
env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
external=True
)
session.run("ruff", "check", CODE_DIR)
session.run("ruff", "format", "--check", CODE_DIR)Key takeaways:
- Uses
uvas venv backend for speed and lockfile support. - Reads the required Python version directly from
pyproject.tomlfor full alignment across local + CI workflows. - Dependency isolation: installs only the
lintgroup, not the full app stack.
Dynamic Matrix CI with GitHub Actions + Nox
Instead of hardcoding jobs, Terminus uses a modern matrix approach:
Step 1: Determine Python version and available nox sessions
jobs:
generate-matrix:
outputs:
sessions: ${{ steps.set-matrix.outputs.sessions }}
python-version: ${{ steps.get-python-version.outputs.version }}- Reads the Python version from
pyproject.toml - Extracts nox sessions via
nox --json -land filters withjq
All this happens before the test matrix runs, so any added sessions automatically trigger in CI without manual YAML changes.
Step 2: Run each session in parallel
jobs:
run-nox-sessions:
strategy:
matrix:
session: ${{ fromJson(needs.generate-matrix.outputs.sessions) }}
steps:
- run: nox -s "${{ matrix.session }}"Each session is isolated, fast, and reproducible — and uses uv as the environment and dependency manager.
Side-by-side Comparison: nox vs poe vs uv cli
| Feature | nox |
poethepoet |
uv CLI |
|---|---|---|---|
| Language | Python-native | Custom pyproject syntax |
Built-in to uv
|
| Virtual Envs | Yes (isolated per session) | No (uses current env) | Optional via uv venv
|
| CLI Task Syntax | nox -s lint |
poe lint |
uv run ruff check src |
| CI Friendly | ✅ Matrix + Python version | ⚠️ Mostly local convenience | ✅ (if you know the commands) |
| Session Scoping | Per-group / per-version | Globally defined | Command-line scoped |
| Test Matrix Discovery | ✅ (--json) |
❌ | ❌ |
| Modern Lock Support | ✅ via uv sync
|
✅ via uv if integrated |
✅ |
So when to use each?
- Use
poeoruv clifor small, script-oriented workflows and fast local CLI access. - Use
noxwhen you:- Need multiple Python versions
- Want true isolation per task
- Want to share automation across dev + CI reliably
Full Automation: From Code to Container
Once lint and test sessions pass, our CI continues to:
- Build the image (multi-stage Docker build)
- Authenticate to Docker Hub
- Push image tagged by short SHA +
latest
Defined here: .github/workflows/ci.yml
Final Thoughts
While new tools like uv and poethepoet offer impressive speed and simplicity, Nox remains an unparalleled orchestration layer in 2025 for teams needing:
- Python version agility
- Lockfile-aware sessions
- Matrix-based CI pipelines
In Terminus, combining Nox with uv, GitHub Actions, and Docker gives us a maintainable and fully automated pipeline — fast, reproducible, and clean.
Codebase
Includes:
noxfile.py-
Dockerfileanddocker-compose.yml - Full GitHub Actions workflow (
ci.yml) - Example
.env,src/, andtests/(TODO)