Releasing and the native wheels#
solprop is released as three distributions from one repository, at one version taken from
the v* git tag; no other tag affects it
(SEP 29):
solprop, pure Python: a source distribution and one universal wheel.solprop-native, the compiled backend built fromnative/: wheels only, one per supported platform. No source distribution is published, so that where no wheel matches,pip install solpropfails to resolve instead of starting a C++ build; source builds are made from the repository (Building the native backend).solprop-gui, the desktop front end built fromgui/, pure Python: a source distribution and one universal wheel, installed assolprop[gui]and published only once its publishing is set up (pre-flight step 5).
Versions are 0.x for now: a minor release may break the API, the output schema or the resume key, and every break is listed under “Changed (breaking)” in the changelog (ADR-058 in decisions). The first release was 0.1.0.
The wheels are built, repaired, qualified and published by
.github/workflows/wheels.yml. cibuildwheel’s configuration is the
[tool.cibuildwheel] table of native/pyproject.toml; the libraries each wheel bundles
are built from pinned, checksummed sources by the scripts in native/ci/, each of which
also runs by hand.
The wheels#
Linux x86-64 |
Linux aarch64 |
macOS arm64 |
Windows x64 |
Windows Arm |
|
|---|---|---|---|---|---|
CI runner |
|
|
|
|
|
build environment |
|
|
Homebrew’s Clang, deployment target 14.0 (macOS Sonoma) |
Clang on the MSVC ABI (MSVC STL and CRT, |
the same |
CPU baseline |
x86-64-v3 and x86-64-v2 |
Neoverse N1 |
Apple M1 |
x86-64-v3 and x86-64-v2 |
Armv8-A, the compiler’s default |
host execution space |
Kokkos OpenMP |
Kokkos OpenMP |
Kokkos OpenMP |
Kokkos OpenMP |
Kokkos OpenMP |
OpenMP runtime |
GCC’s |
GCC’s |
LLVM |
LLVM |
the same |
BLAS |
OpenBLAS 0.3.34, OpenMP, |
the same |
Accelerate |
OpenBLAS 0.3.34, OpenMP, one fixed target per build (Haswell, Nehalem; no |
OpenBLAS 0.3.34, OpenMP, Neoverse N1 target, static |
FFTW 3.3.11 |
shared, SSE2 and AVX codelets |
shared, cycle counter, NEON codelets |
shared, cycle counter, NEON codelets |
static, SSE2 and AVX codelets |
static, cycle counter, NEON codelets |
vector maths (ADR-059) |
SLEEF 3.9.0, static, AVX2 in the v3 build and SSE2 in the v2 |
the system libm |
the system libm |
SLEEF 3.9.0, static, AVX2 in the v3 build and SSE2 in the v2 |
the system libm |
repair |
|
|
|
|
|
An x86-64 wheel holds two extensions, _core at x86-64-v3 and _core_v2 at x86-64-v2, and
the package loads the best one the processor runs (solprop_native/_cpu.py). The setting
SOLPROP_NATIVE_CPU_VARIANT=x86-64-v2 asks for the lower one. native/ci/build_variant.py
builds the second before the wheel and native/ci/merge_variant.py adds it before the
repair, which then treats both alike. On Linux both link one OpenBLAS.
Every bundled library runs on the same OpenMP runtime as Kokkos, so a process holds one
thread pool for the backend. The wheel carries the licence text of every library compiled
into it or copied in by the repair (LICENSE-*.txt beside the extension). One wheel, built
on CPython 3.14 against the stable ABI (cp314-abi3), serves 3.14 and later; abi3audit
and a load on 3.15 must pass before it is published. Free-threaded CPython is not a target.
The same workflow’s python-3-15-tests job runs the default lane on Linux on 3.15, with h5py
built from source. It’s a qualification lane and publishing doesn’t wait for it, because 3.15
is not yet supported (#319).
Building a wheel locally#
Every wheel is the distribution profile (SOLPROP_NATIVE_PROFILE=DISTRIBUTION), never
a build for the build machine’s own architecture.
macOS arm64. cibuildwheel needs python.org’s framework Python, which it installs only
on CI. native/ci/build-wheel-macos.sh runs the same steps with uv: it builds libomp
and FFTW for the deployment target (native/ci/build-deps.sh), builds the wheel and
repairs it with delocate.
brew install llvm cmake ninja
native/ci/build-wheel-macos.sh /tmp/wheels # the repaired wheel lands in /tmp/wheels
Keep the script and [tool.cibuildwheel.macos] in step: they set the same deployment
target, CMake arguments and delocate check.
Linux (x86-64 or aarch64, on a machine of that architecture with Docker):
uvx cibuildwheel --platform linux --output-dir wheelhouse native
Windows x64 or on Arm, from a Visual Studio developer prompt (that architecture’s MSVC tools and SDK in the environment), with LLVM, CMake and Ninja installed:
uvx cibuildwheel --platform windows --output-dir wheelhouse native
To build one platform’s dependencies by hand: native/ci/build-deps.sh PREFIX (Linux,
macOS) or native/ci/build-deps-windows.ps1 -Prefix PREFIX.
Qualifying a wheel#
The workflow’s test jobs install each repaired wheel beside solprop from the same checkout and run, in order:
python -I native/ci/smoke.py --version VERSION: the package imports and passescheck_build(), is the distribution build at the release’s version, loads the FFT, BLAS and OpenMP libraries from inside the wheel, and carries their licences; on Arm, both precisions of its FFTW carry the NEON codelets;python -I -m solprop_native: the build record;python -I native/ci/coexistence.py: NumPy and SciPy imported before and aftersolprop_native, each order in a fresh process, every package’s thread pool started, and a short propagation run on the native backend, onnumpy_refand on the native backend again in the same process;uv run --no-sync poe testwithSOLPROP_TEST_BACKEND=native: solprop’s default lane on the native backend. It runs a second time on Linux and on Windows x64 with the x86-64-v2 build forced.
On Linux x86-64, the cpu-floor job runs the wheel under Intel’s Software Development
Emulator: the smoke test on a Haswell, which loads the x86-64-v3 build; a Nehalem (no AVX)
and a Sandy Bridge (AVX without AVX2, FMA or BMI), which load the x86-64-v2 build, with
backend="auto" resolving to native and no warning; and a refusal before any native code
runs on a Penryn (no SSE4.2). It runs when the repository variables SDE_URL and
SDE_SHA256 name an SDE release, and publishing waits for it then, as it always waits for
minimum-systems. The other platforms’ checks (Windows through
IsProcessorFeaturePresent, Linux aarch64 through /proc/cpuinfo’s Features) are
unit-tested with stand-in processors in native/tests/test_cpu_baseline.py; no CI job
runs them on a processor below the baseline.
macOS and pyFFTW. solprop no longer uses pyFFTW, but a script or another package may.
pyFFTW’s macOS wheel carries its own libomp, which it initialises
when it is imported. LLVM’s OpenMP runtime refuses, or crashes on, a second initialised
copy, so the native package raises at import when pyFFTW is already loaded
(OpenMP runtimes), with a message
that says what to do. The test jobs’ second coexistence check installs pyFFTW and Numba
as outside packages and runs native/ci/coexistence.py among them, so that guard meets a
real pyFFTW on every platform but Windows on Arm, which has no pyFFTW wheel.
solprop’s distributions. The solprop-dist job builds solprop’s sdist and wheel and
installs each from the file in a fresh environment, outside the checkout, beside this run’s
Linux x86-64 wheel. In each it runs the check of the
installation page: solprop
imports at the distributions’ version, which on a release tag must be the tag’s, and
backend="auto" resolves to native.
solprop’s built metadata pins solprop-native at solprop’s own version (hatch_build.py), so
two distributions built at different versions fail here, before anything is published.
Making a release#
The rolling release PR#
Implementation PRs add independent Markdown fragments under changelog.d/, named
<short-slug>.<category>.md. A PR number can be the slug; no separate issue is needed.
The categories are added, changed, breaking, deprecated, removed, fixed and
security; breaking becomes Changed (breaking). Write one entry without a heading
or leading bullet, including migration instructions for a break. Markdown links are
relative to the repository-root changelog. Existing Unreleased entries remain in place;
released sections are copied unchanged.
After each push to main, .github/workflows/release-notes.yml creates or refreshes
one draft PR, Prepare the next release notes, on
chore/release-after-<last-version>. The latest released version comes from
CITATION.cff; the next version is chosen by the maintainer. The draft groups every
pending fragment into CHANGELOG.md and removes the collected fragments on its branch.
Only this PR normally changes the shared changelog. Edit pending fragments in ordinary
PRs, not the generated draft. A manual refresh is available with:
gh workflow run release-notes.yml --ref main
The updater verifies that its previous branch tree is exactly the generated result for
its recorded base before writing another commit. It uses the current main tree for
all other files and keeps the previous head as a parent, so updates are ordinary
fast-forward pushes. It never force-pushes, approves, merges, tags or publishes. Manual
branch edits stop the workflow without overwriting them. A closed PR or one marked
ready pauses updates. If a draft was closed by mistake, reopen it to resume.
One-time setup (done): under GitHub Settings → Actions → General → Workflow permissions,
enable Allow GitHub Actions to create and approve pull requests. Keep the default
token permissions read-only; only this job requests contents: write and
pull-requests: write. The workflow only runs from trusted main. No personal token
or additional dependency is needed. The required checks and branch rules stay in force.
A push made with GITHUB_TOKEN starts no workflow, so CI does not run on the updater’s
commits at all, and the draft shows no checks: they first run on a person’s push to the
branch, which at the freeze is the release commit (step 6). See
GitHub’s token documentation.
When ready to release, mark the PR ready before editing it. This freezes automatic
refreshes. If more changes arrive, bring main into the release worktree and run
uv run poe changelog-collect before updating the release heading. Resolve any conflicts
as usual; the command appends only fragments still present in that worktree. Follow the
checklist below in this PR and wait for its required checks. Once the release commit
reaches main, the next pending fragment starts a new draft for the next cycle.
Pre-flight checklist#
A release tag builds and tests everything but publishes nothing until a maintainer with admin rights on the repository and an account on PyPI has set up publishing. Steps 1 to 4 were done once, before 0.1.0, for solprop and solprop-native; step 5 is done once, before the first release that publishes the front end.
On PyPI (done), under Your account → Publishing, add a pending trusted publisher for each project, both naming the owner
LupoLab, the repositorysolpropand the workflowwheels.yml:the project
solpropwith the environmentpypi;the project
solprop-nativewith the environmentpypi-native.
PyPI ties a pending publisher to one project name per workflow and environment, hence an environment for each distribution. The first upload through a pending publisher creates its project.
On GitHub (done), under Settings → Environments, give both
pypiandpypi-nativea required reviewer, and restrict their deployments to Selected branches and tags with the single tag rulev*. Without the reviewer, anyone with write access could publish by pushing a tag.Under Settings → Secrets and variables → Actions (done), add the repository variable
PUBLISHwith the valuetrue. The publish jobs are skipped without it.On Zenodo (done), signed in with GitHub, switch on
LupoLab/solpropunder GitHub (the organisation must grant Zenodo’s application access). Zenodo archives each GitHub release, not a tag, with the metadata of.zenodo.json, and gives it a DOI, plus one concept DOI for every version, whichCITATION.cffand the README cite.The front end,
solprop-gui, in this order:On PyPI, add a pending trusted publisher for the project
solprop-gui, naming the ownerLupoLab, the repositorysolprop, the workflowwheels.ymland the environmentpypi-gui.On GitHub, create the environment
pypi-guiwith a required reviewer and the single tag rulev*, as in step 2.Only then add the repository variable
PUBLISH_GUIwith the valuetrue. A job that names an environment which doesn’t exist makes GitHub create it with no reviewer and no tag rule, so with the variable set first the next tag would publish the front end unreviewed.
Until
PUBLISH_GUIistrue, each release builds the front end and checks an install ofsolprop[gui]from the files, and the extra resolves only in the repository’s workspace.
Then, for every release:
In the rolling release PR, marked ready to freeze refreshes, check that every pending fragment has been collected and the changelog’s
Unreleasedsection describes the release. Move it under the new version,## [X.Y.Z] - YYYY-MM-DD, retaining an empty## Unreleasedsection above it, and giveCITATION.cffthe sameversionanddate-released(tests/test_release_metadata.pychecks that they agree). The commit that does so is the release commit; it reachesmainthrough this PR, after the required checks.Check that CI passed on the release commit. When it merged a pull request whose head has the same tree and passed CI, the push to
mainruns none of CI’s lanes (CI), and that pull request’s run is the release commit’s record.With the release commit at the head of
main, run the wheels workflow on it,gh workflow run wheels.yml --ref main, and wait for it to pass. It is the only workflow that builds and tests the Linux aarch64 wheel, and onmainit otherwise runs only weekly. On a branch the publish jobs are skipped, so this is a dry run of the release.Then tag that commit, as below.
Tagging and publishing#
Tag the release commit,
git tag -a vX.Y.Z COMMIT, and push that tag on its own,git push origin vX.Y.Z: GitHub starts no workflow for a push of more than three tags at once. The tag starts the wheels workflow again, on the tagged commit (CI does not run on tags): it builds and tests every wheel, and builds solprop’s and the front end’s distributions and checks an install of each. Itssolprop-distjob first checks thatCITATION.cffnames the tag’s version, so a tag on a commit that still cites the last release fails before anything is published. Every publish job waits forsolprop-dist, and solprop’s two for every wheel’stestjob, so the run that publishes has itself tested the release commit on every wheel platform, whichever lanes CI skipped on the push tomain.Publishing, through PyPI’s trusted publishing, then waits for each environment’s reviewer.
publish-nativeuploads solprop-native’s wheels frompypi-nativefirst;publishuploads solprop’s sdist and wheel frompypionly once every native wheel is up, since solprop requires solprop-native at the same version. WithPUBLISH_GUIset,publish-guithen uploads the front end’s sdist and wheel frompypi-gui, oncepublishhas succeeded, since solprop-gui requires solprop at the same version; it waits for that environment’s reviewer too. If an upload fails partway, re-run the failed jobs: they upload the same files, and PyPI accepts a file identical to one it holds. It refuses different files under a name it has seen, so a release that needs a change takes a new version.Wait for
published-install, which starts after both publish jobs succeed. On Linux x86-64, Linux aarch64, macOS arm64, Windows x64 and Windows on Arm it creates a fresh CPython 3.14 environment outside the checkout and uses pip to resolve and installsolprop==X.Y.Zfrom PyPI. It checks both distributions’ versions,pip checkand thatbackend="auto"resolves tonative. It uses no local distribution, dependency lock or cached environment. Its per-platform artifacts retain the install report, logs, resolved versions and platform information, including on failure; each job also writes a summary. Check these before publishing the GitHub release. A failed check does not undo the PyPI upload: record the failure in an issue and repair it in a new version if the published files need changing. If PyPI has not yet made the new files available, rerun the failed install jobs once they appear; the check does not retry a failed resolution or hide its first result. WithPUBLISH_GUIset,published-install-guifollowspublish-guion the same platforms: it installssolprop[gui]==X.Y.Z, runspip check, checks that the front end is at the version and carries the user guide’s chapter, and thatsolprop-gui --versionanswers. Its artifacts are namedpublished-install-gui-<platform>.Publish a GitHub release from the tag, with the version’s changelog section as its notes:
gh release create vX.Y.Z --verify-tag --title "solprop X.Y.Z" --notes-file NOTES.md. Zenodo archives it and gives it a DOI; the concept DOI thatCITATION.cffand the README cite already names every version.On Read the Docs, check that the
vX.Y.Zversion built (activate it under Versions if it did not), and that/en/stable/serves it. Read the Docs’stablefollows the newest release tag, with no setting to change, and rebuilds when the tag is pushed: check its build of the tag succeeded, then thathttps://solprop.readthedocs.io/en/stable/_static/documentation_options.jsgivesVERSION: 'X.Y.Z'.latestkeeps followingmain. The user-facing documentation links (the READMEs, the package metadata, the front end’s Help and solprop’s error messages) point at/en/stable/, so they move with it; links meant for contributors point at/en/latest/.Link the run of the published-install check on the release’s tracking issue, green on every platform: the run that followed the publish jobs, or one dispatched as in Rechecking a published release.
Yank an earlier release on PyPI if a platform without wheels would backtrack to it. An installer that cannot satisfy the new release on such a platform tries older ones, and 0.1.0, which did not require the native backend, installs there with no error (#463). A yanked version still installs when it is asked for exactly (
solprop==0.1.0).
Rechecking a published release#
The Published install workflow can also be dispatched by hand with a required
version, written as X.Y.Z or vX.Y.Z. It runs the same five-platform check without
building or publishing anything:
gh workflow run published-install.yml -f version=X.Y.Z
Its optional readme_example=true input additionally runs the first Python block of
that release’s tagged README as a script, using Matplotlib’s noninteractive Agg
backend. The source and output join the evidence artifact. This full-length example
is a manual release qualification, requested explicitly; the automatic post-publish
check only installs and checks the backend. It does not propagate a pulse. The manual
example was added for 0.1.0’s outstanding platform checks
(issue 74, closed).
gh workflow run published-install.yml -f version=X.Y.Z -f readme_example=true
gui=true installs solprop[gui] instead and checks the front end as well.
The Wheels workflow runs the same check, as its qualify-published job, when it is
dispatched with published_version (and optionally readme_example): it then builds and
publishes nothing.
The actions in the workflows are pinned by commit; when updating one, take the commit of its release tag and keep the version in the trailing comment.
The release recipe on this page was set up and qualified by SEP 29.