Setup a new repo
Complete checklist for migrating a package repo to the cava-devops CI/CD system.
1. pyproject.toml
Switch to hatch-vcs for dynamic versioning. Remove the static version = "X.Y.Z" line.
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
raw-options = { tag_regex = '^v?(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$' }
[project]
dynamic = ["version"]
# remove: version = "X.Y.Z"
[tool.uv]
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
The v? in tag_regex accepts both v1.2.3 and bare 1.2.3 tags, preserving compatibility with repos that have existing bare tags.
uv_build backend
If the repo currently uses uv_build as its build backend, change both requires and build-backend to the hatchling values above.
2. Add workflow files
ci.yml
For repos without a Postgres service:
name: CI
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
jobs:
label-gate:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
build-test:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main
For repos with a Postgres service:
jobs:
label-gate:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
build-test:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres.yml@main
with:
postgres-db: <db-name> # repo-specific
# postgres-image: pgvector/pgvector:pg16 # pgvector repos only
For repos with two test suites (e.g. SQLite + Postgres):
jobs:
label-gate:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
build-test-sqlite:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main
build-test-postgres:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres.yml@main
with:
postgres-db: test_db
merge.yml
name: Release Update
on:
pull_request:
types: [closed]
branches: [main]
jobs:
draft:
if: github.event.pull_request.merged == true
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/release-drafter.yml@main
secrets: inherit
publish.yml
name: Publish
on:
push:
tags: ['v*']
jobs:
build:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/publish.yml@main
publish:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
environment:
name: pypi
url: https://pypi.org/p/<package-name>
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist/ }
- uses: pypa/gh-action-pypi-publish@release/v1
Success
Replace <package-name> with the PyPI slug.
docs.yml
name: Deploy Docs
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
jobs:
deploy:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/deploy-docs.yml@main
All MkDocs plugins must be declared in the dev extra of pyproject.toml.
The reusable workflow installs them via uv sync --extra dev and deploys with
uv run mkdocs gh-deploy --force. See deploy-docs.yml
for details.
3. Add release-drafter config
Copy templates/release-drafter.yml from this repo to .github/release-drafter.yml in the package repo.
4. Dependency ranges on sibling packages (if applicable)
Skip this step if the repo has no dependency on another CAVA package (e.g. oa-configurator, which is a base/leaf package with no siblings of its own).
If this repo depends on one or more CAVA sibling packages, prefer a semver range over an exact pin once the sibling has itself migrated to this centralised CI/CD (its releases are label-gated, so MAJOR only happens on a breaking-labelled PR):
CI installs with plain uv sync, which prefers what's already committed in uv.lock and only re-resolves if the lock no longer satisfies pyproject.toml. See Keeping uv.lock in sync below for how the committed lock stays consistent with pyproject.toml. Enable Dependabot security updates in the repo's settings (Advanced Security) for CVE coverage; no dependabot.yml file is needed for that.
5. Keeping uv.lock in sync
The shared test workflows install with plain uv sync. It prefers whatever is already committed in uv.lock, so a dependency that is still valid never gets bumped just because something newer shipped upstream. If the lock no longer satisfies pyproject.toml, it silently re-resolves in the CI runner and carries on, so a stale committed lock goes unnoticed. uv sync --locked fails instead, but it also fails on any staleness, and --frozen skips validation entirely. The committed uv.lock therefore has to be refreshed when pyproject.toml changes, without relying on someone remembering to run uv lock after every dependency edit.
Install the canonical pre-commit hook:
mkdir -p .githooks
cp <path-to-cava-devops>/templates/githooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
git config core.hooksPath is a one-time, per-clone setting (not committed), so each contributor needs to run it once after cloning.
What it does: when pyproject.toml is part of a commit, the hook diffs it to find exactly which dependency line(s) changed, then runs uv lock --upgrade-package <name> for just those, not a blanket uv lock. This touches only the package(s) you actually edited (plus whatever that package's new version transitively requires), never an unrelated dependency that merely has a newer release available. A changed line that is not a known package name is ignored by uv.
This runs locally, before the commit is created, so uv.lock is already correct when the PR is opened.
6. Delete old files
Remove these files if present in the repo:
| File | What it was |
|---|---|
release.yml |
Monolithic release workflow (semantic-release or similar) |
lint-pr.yml |
PR title lint check (amannn/action-semantic-pull-request) |
.releaserc.json |
semantic-release configuration |
python-publish.yml |
Previous PyPI publish workflow |
tests.yml |
Previous standalone test workflow |
7. GitHub configuration
Merge settings
In Settings > General > Pull Requests:
| Setting | Value |
|---|---|
| Allow merge commits | disabled |
| Allow squash merging | enabled |
| Default commit message | Pull request title |
| Allow rebase merging | disabled |
"Pull request title" sets the squash commit message to the PR title only, which is all the changelog reads. The other options pre-fill with commit messages or the PR description and produce noisy commit messages.
Branch protection ruleset on main
| Setting | Value |
|---|---|
| Target | main |
| Require pull request | 1 approval |
| Required status checks | See below |
| Require linear history | enabled |
| Block force pushes | enabled |
| Allow bypass | Repository admin (for initial setup only) |
Required status checks by CI variant:
| ci.yml variant | Required status checks |
|---|---|
Standard (build-test.yml) |
label-gate / check, build-test / test |
Postgres (build-test-postgres.yml) |
label-gate / check, build-test / test |
| Two suites (sqlite + postgres) | label-gate / check, build-test-sqlite / test, build-test-postgres / test |
The check name prefix is the calling job name in ci.yml.
GitHub environment
Create an environment named pypi in the repo settings (Settings > Environments). No protection rules are required unless you want manual approval before every release.
PyPI trusted publisher
Add a pending trusted publisher on pypi.org:
| Field | Value |
|---|---|
| Owner | AustralianCancerDataNetwork |
| Repository name | exact GitHub repo name (e.g. my-repo) |
| Workflow filename | publish.yml |
| Environment name | pypi |
If the repo already has a trusted publisher from an old workflow (e.g. python-publish.yml), add a new one for publish.yml rather than editing the existing entry. The old entry becomes inert when the old workflow is deleted.
Labels
# From the cava-devops repo root:
scripts/bootstrap-labels.sh AustralianCancerDataNetwork/<repo-name>
This creates five labels: breaking, feature, fix, dependencies (bump labels) and chore (excluded from changelog, no version bump).
8. First release after cutover
Merge all migration changes via a PR labelled chore. merge.yml triggers and release-drafter creates a draft. What to do next depends on whether the repo has existing tags.
The draft body will be empty (chore is excluded from the changelog) and the suggested version will be the next patch after the last existing tag (e.g. v1.2.4 if the last tag was v1.2.3).
Leave the draft unpublished. The next real feature or fix PR will update the same draft with a proper changelog entry. Publish when you are ready to ship that change.
The draft will suggest an arbitrary version since there is no base tag to work from.
Before publishing, confirm the package name is available on PyPI and the trusted publisher entry is in place.
Go to the Releases page, open the draft, and change the "Tag version" field to the correct initial version (e.g. v0.1.0). Confirm the target branch is main. Click Publish release to create the tag, which triggers publish.yml and uploads the first release to PyPI.