Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/build-constraints.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The build tools of a release: the build backend that pyproject.toml names and every
# package it needs, pinned so that .github/workflows/publish.yml builds a release as
# tests/test_release_artifacts.py builds and checks it.
packaging==26.3
setuptools==84.2.1
setuptools-scm==10.3.4
vcs-versioning==2.6.0
wheel==0.48.0
163 changes: 163 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
name: Publish to PyPI

# Publishing a release on GitHub uploads CytePool to PyPI, built from the commit that
# the release's tag names, once that commit has passed every check below. The
# installation page of the documentation ("Make a release") describes the steps and
# the setup they need on PyPI and GitHub.
on:
release:
types: [published]

permissions:
contents: read

# One publication per tag at a time; a second run waits instead of racing.
concurrency:
group: publish-${{ github.event.release.tag_name }}
cancel-in-progress: false

# Every job works on github.sha, the commit that the tag named when the release was
# published.
jobs:
tests:
name: Require a passed Tests run of the release commit
runs-on: ubuntu-26.04
permissions:
actions: read
steps:
- name: Find the latest Tests run of the commit
env:
GH_TOKEN: ${{ github.token }}
run: |
# A pull request's run tests the merge of its branch into master, not this
# commit, so only runs started by a push or by hand count.
run="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/workflows/tests.yml/runs" \
-f head_sha="$GITHUB_SHA" -f per_page=100 --paginate \
--jq '.workflow_runs[] | select(.event == "push" or .event == "workflow_dispatch")
| [.run_number, .status, (.conclusion | tostring), .html_url] | @tsv' \
| sort -nr | sed -n '1p')"
if [[ -z "$run" ]]; then
echo "::error::No Tests run of ${GITHUB_SHA} was started by a push or by hand. Run Tests on the release tag, then re-run this workflow."
exit 1
fi
IFS=$'\t' read -r number status conclusion url <<< "$run"
echo "Tests run ${number} of ${GITHUB_SHA}: ${status}, ${conclusion} (${url})"
if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then
echo "::error::The latest Tests run of ${GITHUB_SHA} has not passed: ${url}"
exit 1
fi

docs:
name: Build the documentation
uses: ./.github/workflows/docs.yml
with:
ref: ${{ github.sha }}

build:
name: Build and check the release files
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require the release tag to name the commit
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
tagged="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")"
if [[ "$tagged" != "$GITHUB_SHA" ]]; then
echo "::error::The tag ${RELEASE_TAG} names ${tagged}, not ${GITHUB_SHA}, the commit it named when the release was published"
exit 1
fi
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.13"
version: "0.13.5"
- name: Build the wheel and the source distribution
env:
# setuptools-scm takes the version from the tag, without a local part, and
# stops the build if the checkout has changes.
SETUPTOOLS_SCM_OVERRIDES_FOR_CYTEPOOL: '{local_scheme = "no-local-version-strict"}'
run: |
require_clean() {
status="$(git status --porcelain=v1)"
if [[ -n "$status" ]]; then
printf '%s\n' "$status"
echo "::error::The checkout has changes $1"
exit 1
fi
}
git describe --tags --long --dirty
require_clean "before the build"
uv build --clear --build-constraints .github/build-constraints.txt
require_clean "after the build"
- name: Check the release files against the tag and the source tree
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: >-
uv run --no-project --with packaging==26.3
python tests/validate_release_artifacts.py --release-tag "$RELEASE_TAG" dist/*
- name: Store the release files
uses: actions/upload-artifact@v7
with:
name: python-package-distributions
path: dist/
if-no-files-found: error

smoke:
name: Smoke-test the wheel (${{ matrix.os }}, Python ${{ matrix.python-version }})
needs: build
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-26.04]
python-version: ["3.13", "3.14", "3.15"]
include:
- os: windows-2025
python-version: "3.13"
- os: macos-26
python-version: "3.13"
defaults:
run:
# Bash expands the wheel's name on every runner, Windows included.
shell: bash
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
with:
python-version: ${{ matrix.python-version }}
version: "0.13.5"
- name: Download the release files
uses: actions/download-artifact@v8
with:
name: python-package-distributions
path: dist/
- name: Install the wheel in a clean environment and run a small analysis
run: uv run --no-project python tests/smoke_test_wheel.py dist/*.whl

publish:
name: Upload to PyPI
needs: [tests, docs, build, smoke]
runs-on: ubuntu-26.04
environment:
name: pypi
url: https://pypi.org/p/cytepool
permissions:
# Trusted publishing: PyPI accepts this workflow's OpenID Connect token for the
# project, so no API token is stored.
id-token: write
steps:
- name: Download the release files
uses: actions/download-artifact@v8
with:
name: python-package-distributions
path: dist/
- name: Upload to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
10 changes: 10 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# The source distribution holds the package, the files that build it, the README and
# the licence files. setuptools-scm adds every file that git tracks; the tests, the
# documentation, the validation scripts and the repository's tooling are removed here.
# tests/validate_release_artifacts.py refuses any other file.
prune .github
prune docs
prune tests
prune validation
exclude .gitignore .python-version uv.lock *.md
include README.md THIRD_PARTY_NOTICES.md
54 changes: 53 additions & 1 deletion docs/source/installation.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Install CytePool from its repository with Python 3.13 or newer, set up a development environment, and build this documentation.
description: Install CytePool from its repository with Python 3.13 or newer, set up a development environment, build this documentation, and make a release.
---

(installation)=
Expand Down Expand Up @@ -139,3 +139,55 @@ data names its datasets in its metadata `datasets`
([Datasets](reference/datasets.md)); `make -C docs execute` prepares them.
`uv run --group docs python docs/coverage_report.py` lists the public names that no page
uses or explains yet.

(make-a-release)=
## Make a release

To release a version, check that the Tests run of the commit on master passed, then
draft a new release on GitHub, create its tag with that commit as the target, and
publish the release. The tag is the version, such as `0.1.0`, or `0.2.0rc1` for a
release candidate, which PyPI lists as a pre-release. Publishing the release starts
the [release workflow](https://github.com/NygenAnalytics/CytePool/blob/master/.github/workflows/publish.yml),
which builds CytePool from the commit that the tag names and uploads it to PyPI once
all of these pass:

- the latest run of the Tests workflow for that commit, started by a push or by hand
(a pull request's run tests a merge commit instead), passed;
- the documentation builds, as the Docs workflow builds it;
- the wheel and the source distribution, built from a clean checkout of the commit with
the build tools pinned in `.github/build-constraints.txt`, name the version of the
tag, declare what `pyproject.toml` declares, and hold the package and no other file
of the repository (`tests/validate_release_artifacts.py`);
- the wheel, installed in a clean environment on Linux with Python 3.13, 3.14 and 3.15
and on Windows and macOS with Python 3.13, pools a small dataset into a cube, gives
limma's tables from `limma_voom`, and loads its compiled routines from the cache in a
second run (`tests/smoke_test_wheel.py`).

When the `pypi` environment has required reviewers, one of them approves the upload
after the checks. 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. Running the failed workflow again checks the same commit, which helps only
when the failure came from outside it, such as a network error.

`uv run pytest` builds the two files and checks them as the workflow does. To run the
smoke test on a wheel you build:

```bash
uv build --wheel --clear --out-dir dist/
uv run --no-project python tests/smoke_test_wheel.py dist/cytepool-*.whl
```

### Before the first release

The owner of the repository sets up two things once:

1. On PyPI, a trusted publisher, so that the workflow uploads without a stored token:
at <https://pypi.org/manage/account/publishing/>, add a pending publisher for
GitHub with the project name `cytepool`, the owner `NygenAnalytics`, the repository
`CytePool`, the workflow `publish.yml` and the environment `pypi`. The first upload
creates the project, owned by that account.
2. On GitHub, in the repository's settings, an environment named `pypi`: add the
people who approve uploads as required reviewers, and 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. Without it, the first run creates an environment that
protects nothing.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ test = [

[project.urls]
Homepage = "https://github.com/NygenAnalytics/CytePool"
Documentation = "https://docs.nygen.io/CytePool/"
Issues = "https://github.com/NygenAnalytics/CytePool/issues"

[dependency-groups]
Expand Down
Loading
Loading