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 from native/: wheels only, one per supported platform. No source distribution is published, so that where no wheel matches, pip install solprop fails 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 from gui/, pure Python: a source distribution and one universal wheel, installed as solprop[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

ubuntu-24.04

ubuntu-24.04-arm

macos-15

windows-2025

windows-11-arm

build environment

manylinux_2_28 container, GCC 14

manylinux_2_28 container, GCC 14

Homebrew’s Clang, deployment target 14.0 (macOS Sonoma)

Clang on the MSVC ABI (MSVC STL and CRT, /MD)

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 libgomp

GCC’s libgomp

LLVM libomp, built for the target

LLVM libomp from the LLVM installation

the same

BLAS

OpenBLAS 0.3.34, OpenMP, DYNAMIC_ARCH, solprop_ prefix (built by the configure step)

the same

Accelerate

OpenBLAS 0.3.34, OpenMP, one fixed target per build (Haswell, Nehalem; no DYNAMIC_ARCH), static

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

auditwheel

auditwheel

delocate

delvewheel (mangled names)

delvewheel (mangled names)

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:

  1. python -I native/ci/smoke.py --version VERSION: the package imports and passes check_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;

  2. python -I -m solprop_native: the build record;

  3. python -I native/ci/coexistence.py: NumPy and SciPy imported before and after solprop_native, each order in a fresh process, every package’s thread pool started, and a short propagation run on the native backend, on numpy_ref and on the native backend again in the same process;

  4. uv run --no-sync poe test with SOLPROP_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.

  1. On PyPI (done), under Your account → Publishing, add a pending trusted publisher for each project, both naming the owner LupoLab, the repository solprop and the workflow wheels.yml:

    • the project solprop with the environment pypi;

    • the project solprop-native with the environment pypi-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.

  2. On GitHub (done), under Settings → Environments, give both pypi and pypi-native a required reviewer, and restrict their deployments to Selected branches and tags with the single tag rule v*. Without the reviewer, anyone with write access could publish by pushing a tag.

  3. Under Settings → Secrets and variables → Actions (done), add the repository variable PUBLISH with the value true. The publish jobs are skipped without it.

  4. On Zenodo (done), signed in with GitHub, switch on LupoLab/solprop under 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, which CITATION.cff and the README cite.

  5. The front end, solprop-gui, in this order:

    1. On PyPI, add a pending trusted publisher for the project solprop-gui, naming the owner LupoLab, the repository solprop, the workflow wheels.yml and the environment pypi-gui.

    2. On GitHub, create the environment pypi-gui with a required reviewer and the single tag rule v*, as in step 2.

    3. Only then add the repository variable PUBLISH_GUI with the value true. 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_GUI is true, each release builds the front end and checks an install of solprop[gui] from the files, and the extra resolves only in the repository’s workspace.

Then, for every release:

  1. In the rolling release PR, marked ready to freeze refreshes, check that every pending fragment has been collected and the changelog’s Unreleased section describes the release. Move it under the new version, ## [X.Y.Z] - YYYY-MM-DD, retaining an empty ## Unreleased section above it, and give CITATION.cff the same version and date-released (tests/test_release_metadata.py checks that they agree). The commit that does so is the release commit; it reaches main through this PR, after the required checks.

  2. 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 main runs none of CI’s lanes (CI), and that pull request’s run is the release commit’s record.

  3. 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 on main it otherwise runs only weekly. On a branch the publish jobs are skipped, so this is a dry run of the release.

  4. Then tag that commit, as below.

Tagging and publishing#

  1. 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. Its solprop-dist job first checks that CITATION.cff names 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 for solprop-dist, and solprop’s two for every wheel’s test job, so the run that publishes has itself tested the release commit on every wheel platform, whichever lanes CI skipped on the push to main.

  2. Publishing, through PyPI’s trusted publishing, then waits for each environment’s reviewer. publish-native uploads solprop-native’s wheels from pypi-native first; publish uploads solprop’s sdist and wheel from pypi only once every native wheel is up, since solprop requires solprop-native at the same version. With PUBLISH_GUI set, publish-gui then uploads the front end’s sdist and wheel from pypi-gui, once publish has 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.

  3. 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 install solprop==X.Y.Z from PyPI. It checks both distributions’ versions, pip check and that backend="auto" resolves to native. 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. With PUBLISH_GUI set, published-install-gui follows publish-gui on the same platforms: it installs solprop[gui]==X.Y.Z, runs pip check, checks that the front end is at the version and carries the user guide’s chapter, and that solprop-gui --version answers. Its artifacts are named published-install-gui-<platform>.

  4. 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 that CITATION.cff and the README cite already names every version.

  5. On Read the Docs, check that the vX.Y.Z version built (activate it under Versions if it did not), and that /en/stable/ serves it. Read the Docs’ stable follows the newest release tag, with no setting to change, and rebuilds when the tag is pushed: check its build of the tag succeeded, then that https://solprop.readthedocs.io/en/stable/_static/documentation_options.js gives VERSION: 'X.Y.Z'. latest keeps following main. 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/.

  6. 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.

  7. 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.