Skip to content

Publish to PyPI on a GitHub release, after checking the tagged commit - #63

Merged
parashardhapola merged 1 commit into
masterfrom
53-release-workflow
Oct 26, 2026
Merged

parashardhapola merged 1 commit into
masterfrom
53-release-workflow

Conversation

@parashardhapola

Copy link
Copy Markdown
Member

Summary

Publishing a release on GitHub now uploads CytePool to PyPI, built from the commit that the release's tag names, in the way CyteArc's release workflow does. Nothing is uploaded unless that commit's Tests run passed, the documentation builds, the wheel and the source distribution match the tag and pyproject.toml and hold only the package, and the wheel installs and reproduces limma's tables in clean environments on Linux (Python 3.13, 3.14 and 3.15), Windows and macOS (Python 3.13). The workflow runs only when a release is published, so it can be merged before the first tag, which stays with the owner.

What the owner sets up once, before the first release

  1. A trusted publisher on PyPI. The workflow then uploads with a token that GitHub issues for each run, and no PyPI API token is stored anywhere. At https://pypi.org/manage/account/publishing/, add a pending publisher for GitHub with:

    • PyPI project name: cytepool
    • Owner: NygenAnalytics
    • Repository name: CytePool
    • Workflow name: publish.yml
    • Environment name: pypi

    A pending publisher does not reserve the name, so check that cytepool is still free on PyPI. The first upload creates the project, owned by the account that added the publisher.

  2. An environment named pypi on GitHub (Settings, Environments, New environment). Add the people who approve uploads as required reviewers. Under Deployment branches and tags, choose Selected branches and tags and add a tag rule *, so that only a run for a tag can upload. If this environment does not exist, the first run creates it without any protection.

How a release is made

  1. Merge what is to be released into master and wait for the Tests run that the push starts to pass.
  2. On GitHub, draft a new release, create its tag with that commit as the target, and publish the release. The tag is the version: 0.1.0, or 0.2.0rc1 for a release candidate, which PyPI lists as a pre-release.
  3. The "Publish to PyPI" workflow runs its checks. Once they pass and a reviewer of the pypi environment approves, it uploads the wheel and the source distribution.

When a check fails, nothing is uploaded. Fix the cause on master, delete the release and its tag, and publish the release again with the fixed commit as the target. Re-running the failed workflow tests the same commit again, which helps only when the failure came from outside the commit, such as a network error. The installation page of the documentation now has these steps in a "Make a release" section.

Changes

  • .github/workflows/publish.yml: the release workflow, triggered by a published release, with five jobs:
    • tests: the latest run of tests.yml for the commit, started by a push or by hand, must have passed;
    • docs: runs docs.yml on the commit, as pages.yml does;
    • build: requires the tag to still name the commit, builds both files from a clean checkout with pinned tools and uv 0.13.5, checks them with tests/validate_release_artifacts.py, and stores dist/;
    • smoke: runs tests/smoke_test_wheel.py on Linux with Python 3.13, 3.14 and 3.15, and on Windows (windows-2025) and macOS (macos-26) with Python 3.13;
    • publish: after the four others, uploads by trusted publishing from the pypi environment.
  • .github/build-constraints.txt: pins the build backend and everything it installs: setuptools 84.2.1, setuptools-scm 10.3.4, vcs-versioning 2.6.0, wheel 0.48.0 and packaging 26.3.
  • MANIFEST.in: keeps the tests, the documentation, the validation scripts and the repository's tooling out of the source distribution.
  • tests/validate_release_artifacts.py: checks the wheel and the source distribution against the tag and the source tree, prints every problem it finds and exits with status 1.
  • tests/smoke_test_wheel.py: installs the wheel in a clean environment and runs a small analysis twice, the second time from the numba cache.
  • tests/test_release_artifacts.py: 20 tests on files built from this tree.
  • docs/source/installation.md: the "Make a release" section for maintainers.
  • pyproject.toml: a Documentation link (docs.nygen.io/CytePool) for the PyPI page.

No public function or class of the package changes.

Design notes

  • Which Tests runs count. The tests job lists the runs of tests.yml whose head SHA is the release commit and takes the latest one started by a push or by hand. A pull request's run is left out, because it tests the merge of its branch into master while GitHub files it under the branch's head SHA. Running Tests by hand on the tag gives a commit that was never pushed to master a run of its own. The latest run must have completed successfully, so a later failed or unfinished run blocks the release even after an earlier pass.
  • The version. setuptools-scm reads it from the tag with the local scheme no-local-version-strict of vcs-versioning, which drops the local part and stops the build when the checkout has changes. The build step also checks git status before and after the build, and an earlier step checks that the tag still names the commit the release was published for, as in CyteArc's workflow. One case can still differ: when two version tags name the same commit (a release candidate promoted without a new commit), git describe may report the other tag, depending on which of them are annotated. The artifact check then refuses the release, so a wrong version is never uploaded; a new commit for the final release avoids the case.
  • Pinned tools. The constraints are the set that uv resolved for this project's own builds on 22 October 2026, a little newer than CyteArc's pins. uv build builds the source distribution first and the wheel from it, so the published wheel is what a build from the published source distribution gives.
  • What the artifact check compares. Every expectation comes from the tagged checkout, never from the build itself: the version from the tag; the Python versions, the licence expression and files, the readme and its content type, and the requirements of every extra, markers included, from pyproject.toml; every module of cytepool/, byte for byte. The wheel may hold nothing else but its .dist-info files, and the source distribution nothing else but pyproject.toml, MANIFEST.in, the README, the licence files and what setuptools writes (PKG-INFO, setup.cfg, cytepool.egg-info/); cytepool/_version.py must be in both and name the version. Any other file is reported by name. The check also requires the requirements without an extra to be numpy and numba alone, the package's rule, and the wheel to be py3-none-any with Root-Is-Purelib: true.
  • The source distribution. Without MANIFEST.in, setuptools-scm puts every tracked file in it: 944 files of tests/ (mostly fixtures), docs/, validation/, .github/, uv.lock and the repository's tooling files, 7.4 MB in all. It now holds the 77 modules, pyproject.toml, MANIFEST.in, the README and the two licence files: 0.7 MB, and enough to build the wheel.
  • The smoke test. The clean environment is made with uv venv and uv pip install of the wheel alone, so numpy and numba resolve as they do for a user. The analysis runs with that interpreter in isolated mode from a scratch folder. It checks that cytepool is imported from the environment and reports the wheel's version, pools tests/data/differential_expression/cells.npz with from_cells, builds the paired design of tests/test_differential_expression.py, runs limma_voom, and compares the three contrasts of the T cells with limma's tables in limma.npz to 1e-6. The tests hold these values to 1e-10 on Linux; other platforms' math libraries may round differently, and a broken build differs by far more. The analysis runs twice with a NUMBA_CACHE_DIR of its own: the first run must compile the routines (85 of them) and load none, the second must load them from the cache and compile none. This shows on each platform that the cache of cytepool._jit works for an installed wheel; a second process then starts in under a second instead of compiling for about 30 seconds.
  • Runners and actions. ubuntu-26.04 as in the other workflows, windows-2025, and macos-26 (Apple silicon). The upload uses pypa/gh-action-pypi-publish@release/v1, as CyteArc does; it also runs twine check and attaches attestations to the files.
  • Left for the first release. The README still says "Nothing is released yet" and the installation page says to install from the repository; both should change with the release. The README is also the project description on PyPI, where its seven relative links (to validation/, docs/, LICENSE and others) will not resolve; absolute links to GitHub would.

Validation

No statistical method changes, so there is no comparison with a reference tool; the release path was checked on this machine instead. Windows and macOS run only on GitHub.

check how result
build uv build --build-constraints .github/build-constraints.txt in a clean clone of master, with the strict local scheme wheel and source distribution of version 0.1.dev54; the checkout is clean after the build
changed checkout the same build after editing a tracked file stopped by DirtyWorkingTreeError
artifact check uv run --no-project --with packaging==26.3 python tests/validate_release_artifacts.py --release-tag 0.1.dev54 dist/* both files ready to publish
artifact check, wrong tag the same with --release-tag v0.1.0 six problems: the file name, cytepool/_version.py and the Version field of each file
artifact check, no MANIFEST.in the same on a build without it every stray file named, from .github/ to validation/
smoke test uv run --no-project --python 3.13 python tests/smoke_test_wheel.py dist/cytepool-*.whl limma's tables; 85 routines compiled, then none compiled and 10 loaded from the cache; 38 s
smoke test, Python 3.14 the same the same result; 37 s
smoke test, Python 3.15 the same, with numpy, numba and llvmlite from a local folder of cached wheels, as this machine is offline for the index the same result; 34 s
smoke test refusals a second run without a writable cache; a wrong version; cytepool imported from the checkout each fails, with its message
Tests gate the job's script with a stand-in for gh passes when the latest run passed; fails when it failed or is unfinished, and when there is no run
workflow lint actionlint 1.7.12 on all four workflows no problems, apart from its list of runner labels, which predates ubuntu-26.04, the label every workflow here uses

The clean environments held numpy 2.5.4, numba 0.68.0 and llvmlite 0.50.0, with uv 0.13.5.

Tests

tests/test_release_artifacts.py has 20 tests. A module fixture builds the wheel and the source distribution once, with the workflow's command and pinned tools, into a temporary folder; with a warm uv cache this takes 2 to 4 seconds, the slowest setup of the suite. The tests compare against expectations written by hand, not taken from the validator:

  • the files built from this tree are accepted, and hold exactly the expected entries at their top level (no tests/, docs/ or validation/), with cytepool/_version.py in both;
  • for each of 14 defects, a copy of a built file must give exactly the expected problems and no others: a test file in the wheel, documentation in the source distribution, a missing module, version file or licence file, a stale version file, an edited module, a wheel that is not pure Python, an added requirement, a missing extra, a dropped marker, another licence, other Python versions, and another description;
  • a tag that names another version, a tag that is no version or has a local part, two wheels, no source distribution, a file of another kind, and a wheel for one platform are each refused with their message.

The fixture skips, saying why, when uv or git is missing or the tree is not a git checkout. One more test runs the smoke test's analysis on the source tree, so that a change that breaks it fails on its pull request rather than at a release.

Runtime of the module: 3 seconds warm; 37 seconds with a cold numba cache, nearly all of it compiling the routines of limma_voom, which the suite compiles for its own tests anyway. The whole suite: 4736 passed and 18 skipped (zstd needs Python 3.14) in 34 seconds. The new tests also pass with OPENBLAS_CORETYPE=Sandybridge NUMBA_CPU_NAME=generic and a fresh numba cache. ruff, ruff format, mypy, make -C docs api and make -C docs html pass.

@parashardhapola
parashardhapola force-pushed the 54-pca-dot-plots-and-incomplete-beta branch from 9202c3b to 558102d Compare October 26, 2026 23:05
@parashardhapola
parashardhapola changed the base branch from 54-pca-dot-plots-and-incomplete-beta to master October 26, 2026 23:10
A published release runs .github/workflows/publish.yml: it requires the
commit's Tests run to have passed, builds the documentation, builds and
checks both files with pinned tools, smoke-tests the wheel on Linux, Windows
and macOS, and uploads by trusted publishing from the pypi environment.
@parashardhapola
parashardhapola marked this pull request as ready for review October 26, 2026 23:10
@parashardhapola
parashardhapola merged commit c769c81 into master Oct 26, 2026
6 checks passed
@parashardhapola
parashardhapola deleted the 53-release-workflow branch October 26, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant