Skip to content

Commit c109a50

Browse files
authored
ci: Added release workflow (#460)
Signed-off-by: Sricharan Reddy Varra <sricharan.varra@biohub.org>
1 parent 7fb3bc1 commit c109a50

7 files changed

Lines changed: 230 additions & 76 deletions

File tree

.github/scripts/route-tag.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
# Route a release tag to its workspace package and version.
3+
#
4+
# Usage: route-tag.sh <tag>
5+
# Prints `package=<name>` and `version=<version>` (GitHub Actions output format)
6+
# to stdout; a human-readable line goes to stderr. Exits 1 with an ::error::
7+
# annotation on an unrecognized tag.
8+
#
9+
# Each arm is anchored on `v[0-9]*`, and the bare-v umbrella arm stays LAST, so a
10+
# `viscy-…` tag or a subpackage tag missing its `v` (e.g. viscy-data-0.5.0) can't
11+
# fall through to the umbrella with a garbage version. Adding a package = add one
12+
# arm. The prefixes mirror each package's `pattern-prefix` in its pyproject.toml.
13+
set -euo pipefail
14+
15+
tag="${1:?usage: route-tag.sh <tag>}"
16+
17+
case "$tag" in
18+
# libraries (packages/)
19+
viscy-data-v[0-9]*) package=viscy-data; version=${tag#viscy-data-v} ;;
20+
viscy-models-v[0-9]*) package=viscy-models; version=${tag#viscy-models-v} ;;
21+
viscy-transforms-v[0-9]*) package=viscy-transforms; version=${tag#viscy-transforms-v} ;;
22+
viscy-utils-v[0-9]*) package=viscy-utils; version=${tag#viscy-utils-v} ;;
23+
# applications (applications/)
24+
airtable-utils-v[0-9]*) package=airtable-utils; version=${tag#airtable-utils-v} ;;
25+
cytoland-v[0-9]*) package=cytoland; version=${tag#cytoland-v} ;;
26+
dynacell-v[0-9]*) package=dynacell; version=${tag#dynacell-v} ;;
27+
dynaclr-v[0-9]*) package=dynaclr; version=${tag#dynaclr-v} ;;
28+
viscy-qc-v[0-9]*) package=viscy-qc; version=${tag#viscy-qc-v} ;;
29+
# umbrella (must stay last)
30+
v[0-9]*) package=viscy; version=${tag#v} ;;
31+
*) echo "::error::Unrecognized release tag '$tag' (expected '<pkg>-vX.Y.Z' or 'vX.Y.Z')" >&2; exit 1 ;;
32+
esac
33+
34+
echo "Routed $tag -> package=$package version=$version" >&2
35+
printf 'package=%s\nversion=%s\n' "$package" "$version"

.github/workflows/release.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Release
2+
3+
# Publishes one VisCy package to PyPI when a maintainer publishes a GitHub Release.
4+
# The tag on the Release selects the package. Each package is versioned independently
5+
# The prefix isolates its tag history, so versions need not match across packages:
6+
# v2.1.0 -> viscy (umbrella)
7+
# viscy-data-v0.5.3 -> viscy-data (library)
8+
# viscy-models-v1.4.0 -> viscy-models
9+
# viscy-transforms-v0.9.1 -> viscy-transforms
10+
# viscy-utils-v0.2.7 -> viscy-utils
11+
# airtable-utils-v0.1.0 -> airtable-utils (application)
12+
# cytoland-v0.3.0 -> cytoland
13+
# dynacell-v0.2.0 -> dynacell
14+
# dynaclr-v0.1.0 -> dynaclr
15+
# viscy-qc-v0.1.0 -> viscy-qc
16+
# Setup (PyPI trusted publishers + GitHub environments): see .github/RELEASE_SETUP.md
17+
# Design rationale: see .github/RELEASE_CI_PLAN.md
18+
19+
on:
20+
release:
21+
types: [published]
22+
23+
permissions: {}
24+
25+
concurrency:
26+
group: release-${{ github.event.release.tag_name }}
27+
cancel-in-progress: false
28+
29+
jobs:
30+
# Route the Release tag -> {package, version}. Separate job because `environment.name`
31+
# on the publish job is resolved at the job level and can only read `needs.*` outputs.
32+
parse:
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 5
35+
outputs:
36+
package: ${{ steps.route.outputs.package }}
37+
version: ${{ steps.route.outputs.version }}
38+
steps:
39+
- name: Check out (for the routing script)
40+
uses: actions/checkout@v6
41+
- name: Route tag to package
42+
id: route
43+
env:
44+
TAG: ${{ github.event.release.tag_name }} # untrusted input via env, never inline in run:
45+
run: bash .github/scripts/route-tag.sh "$TAG" >> "$GITHUB_OUTPUT"
46+
47+
release:
48+
needs: parse
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 15
51+
environment:
52+
name: pypi-${{ needs.parse.outputs.package }} # per-package OIDC isolation (see RELEASE_SETUP.md)
53+
url: https://pypi.org/project/${{ needs.parse.outputs.package }}/${{ needs.parse.outputs.version }}/
54+
permissions:
55+
id-token: write # OIDC trusted publishing
56+
contents: write # attach built dists to the GitHub Release (post-publish)
57+
env:
58+
PACKAGE: ${{ needs.parse.outputs.package }}
59+
VERSION: ${{ needs.parse.outputs.version }}
60+
steps:
61+
- name: Check out the release tag
62+
uses: actions/checkout@v6
63+
with:
64+
ref: ${{ github.event.release.tag_name }}
65+
fetch-depth: 0 # full history …
66+
fetch-tags: true # … and tags so uv-dynamic-versioning can resolve the version
67+
68+
- name: Install uv
69+
uses: astral-sh/setup-uv@v8
70+
71+
# Build BOTH from source. Plain `uv build` chains sdist -> wheel-from-sdist, and the
72+
# unpacked sdist has no .git, so uv-dynamic-versioning would fall back to 0.0.0 for the
73+
# wheel. `--sdist --wheel` builds each directly from the checkout. `--no-sources` drops
74+
# workspace path rewrites so deps resolve as normal PyPI packages (matches `pypa/build`).
75+
- name: Build sdist and wheel
76+
run: uv build --package "$PACKAGE" --sdist --wheel --no-sources --out-dir dist
77+
78+
- name: Check distribution metadata
79+
run: uvx twine check dist/*
80+
81+
# Cross-check the built version against the tag. The sdist is always named
82+
# `<name>-<version>.tar.gz`, so an exact suffix match catches both the
83+
# uv-dynamic-versioning 0.0.0 fallback and any tag/build drift — with no
84+
# substring false positives (e.g. version 10.0.0 vs the 0.0.0 fallback).
85+
- name: Verify built version matches the tag
86+
run: |
87+
set -euo pipefail
88+
ls -1 dist/
89+
if ! compgen -G "dist/*-${VERSION}.tar.gz" >/dev/null; then
90+
echo "::error::no sdist matches tag version '${VERSION}' — version derivation likely failed"
91+
exit 1
92+
fi
93+
94+
- name: Publish to PyPI
95+
uses: pypa/gh-action-pypi-publish@release/v1
96+
# attestations: true is the default on >=1.11 -> PEP 740 provenance, no extra config
97+
98+
- name: Attach distributions to the GitHub Release
99+
env:
100+
TAG: ${{ github.event.release.tag_name }}
101+
GH_TOKEN: ${{ github.token }}
102+
run: gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY"

applications/qc/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ build-backend = "hatchling.build"
33
requires = [ "hatchling", "uv-dynamic-versioning" ]
44

55
[project]
6-
name = "qc"
6+
name = "viscy-qc"
77
description = "Quality control metrics for OME-Zarr microscopy datasets"
88
readme = "README.md"
99
keywords = [ "microscopy", "quality control", "zarr" ]
@@ -54,5 +54,5 @@ waveorder = { git = "https://github.com/mehta-lab/waveorder.git", branch = "main
5454
[tool.uv-dynamic-versioning]
5555
vcs = "git"
5656
style = "pep440"
57-
pattern-prefix = "qc-"
57+
pattern-prefix = "viscy-qc-"
5858
fallback-version = "0.0.0"

docs/contributing.md

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -146,38 +146,67 @@ Authoring notes:
146146

147147
## Release a package
148148

149-
Versions come from git tags via
150-
[`uv-dynamic-versioning`](https://github.com/ninoseki/uv-dynamic-versioning); the
151-
tag **prefix** picks the package.
149+
Publishing a GitHub Release ships that package to PyPI — no tokens, no manual upload,
150+
no version bump in code. Both the workspace libraries and the applications release this
151+
way. Versions come from the tag via
152+
[`uv-dynamic-versioning`](https://github.com/ninoseki/uv-dynamic-versioning); the tag
153+
**prefix** picks the package, and each package versions independently.
152154

153155
| Package | Tag prefix | Example |
154156
|---|---|---|
157+
| `viscy` (umbrella) | none | `v0.6.0` |
155158
| `viscy-data` | `viscy-data-` | `viscy-data-v0.2.1` |
156159
| `viscy-models` | `viscy-models-` | `viscy-models-v0.4.0` |
157160
| `viscy-transforms` | `viscy-transforms-` | `viscy-transforms-v0.1.3` |
158161
| `viscy-utils` | `viscy-utils-` | `viscy-utils-v0.3.0` |
159-
| `viscy` (umbrella) | none | `v0.6.0` |
162+
| `airtable-utils` | `airtable-utils-` | `airtable-utils-v0.1.0` |
163+
| `cytoland` | `cytoland-` | `cytoland-v0.3.0` |
164+
| `dynacell` | `dynacell-` | `dynacell-v0.2.0` |
165+
| `dynaclr` | `dynaclr-` | `dynaclr-v0.1.0` |
166+
| `viscy-qc` | `viscy-qc-` | `viscy-qc-v0.1.0` |
160167

161-
```sh
162-
git checkout main && git pull && git fetch --tags
163-
git tag viscy-data-v0.2.1 # (1)!
164-
uv build --package viscy-data --out-dir dist/ # (2)!
165-
ls dist/ # viscy_data-0.2.1-...whl (3)!
166-
git push origin viscy-data-v0.2.1 # (4)!
167-
```
168+
Create the Release on the tag:
169+
170+
=== ":material-console: gh CLI"
171+
172+
```sh
173+
# standard release
174+
gh release create viscy-data-v0.2.1 --generate-notes --title "viscy-data v0.2.1"
175+
176+
# pre-release (rc / alpha / beta)
177+
gh release create viscy-data-v0.2.1rc1 --prerelease --generate-notes
178+
```
179+
180+
=== ":material-web: Web"
181+
182+
1. **Releases → Draft a new release.**
183+
2. Choose or create the tag, e.g. `viscy-data-v0.2.1`.
184+
3. Tick **Set as a pre-release** for an `rc` / `a` / `b` tag.
185+
4. **Publish release.**
186+
187+
Spell versions the PEP 440 way: `v0.2.1`, `v0.2.1rc1`. A
188+
misspelled or unprefixed tag fails the run before anything builds.
189+
190+
!!! note "What CI does"
191+
192+
Publishing the Release runs `.github/workflows/release.yml`: parse the tag, build
193+
the sdist and wheel, verify the version matches the tag, publish to PyPI over OIDC
194+
(PEP 740 attestations, no stored secrets), and attach the artifacts to the Release.
195+
196+
!!! note "How publishing is authorized"
168197

169-
1. One tag per package you ship.
170-
2. Version is derived from the tag.
171-
3. Verify the wheel name matches the tag before pushing.
172-
4. Nothing is public until you push the tag.
198+
Each package publishes through PyPI
199+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC): GitHub mints
200+
a short-lived credential per run, scoped to `release.yml` and that package's
201+
environment (`pypi-viscy`, `pypi-viscy-data`, …).
173202

174203
!!! note "Docs deploy automatically"
175204

176205
CI publishes the site with [`mike`](https://github.com/squidfunk/mike): merging
177206
to `main` updates the `dev` build; a `vX.Y.Z` umbrella tag publishes a release
178207
and moves `stable`.
179208

180-
## Before you open a PR
209+
## Before you open a PR check that:
181210

182211
- [ ] Tests cover the change and `uv run pytest` passes
183212
- [ ] `uvx prek run --all-files` is clean

docs/packages/index.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ The table below is regenerated at build time from the installed distributions
1818

1919
| Package | Version | Install |
2020
|---------|---------|---------|
21-
| [`viscy-data`](viscy-data.md) | `0.0.0.post233.dev0+0f63d97b` | `pip install viscy-data` |
22-
| [`viscy-models`](viscy-models.md) | `0.0.0.post233.dev0+0f63d97b` | `pip install viscy-models` |
23-
| [`viscy-transforms`](viscy-transforms.md) | `0.0.0.post233.dev0+0f63d97b` | `pip install viscy-transforms` |
24-
| [`viscy-utils`](viscy-utils.md) | `0.0.0.post233.dev0+0f63d97b` | `pip install viscy-utils` |
21+
| [`viscy-data`](viscy-data.md) | `0.0.0.post209.dev0+4edfaf8b` | `pip install viscy-data` |
22+
| [`viscy-models`](viscy-models.md) | `0.0.0.post209.dev0+4edfaf8b` | `pip install viscy-models` |
23+
| [`viscy-transforms`](viscy-transforms.md) | `0.0.0.post209.dev0+4edfaf8b` | `pip install viscy-transforms` |
24+
| [`viscy-utils`](viscy-utils.md) | `0.0.0.post209.dev0+4edfaf8b` | `pip install viscy-utils` |
2525

2626
<!-- versions:end -->
2727

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ viscy-utils = { workspace = true }
6161
dynaclr = { workspace = true }
6262
cytoland = { workspace = true }
6363
airtable-utils = { workspace = true }
64-
qc = { workspace = true }
64+
viscy-qc = { workspace = true }
6565
dynacell = { workspace = true }
6666
# cubic 0.7.0a9 (GPU-resident Cellpose-SAM eval API + FSC/FRC NaN fix) is not on
6767
# PyPI yet; pin the exact commit (reports 0.7.0a9, satisfies the ==0.7.0a9 pin).

0 commit comments

Comments
 (0)