Releasing and the native wheels#

solprop is released as two 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).

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 is 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. No CI job exercises that guard on a real pyFFTW any longer.

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: 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. GitHub may require a maintainer to approve CI runs created by GITHUB_TOKEN; use Approve workflows to run on the PR before merging. 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. Once, before the first tag:

  1. On PyPI, 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;

    • the project solprop-gui with the environment pypi-gui, once the front end is to be published (below).

    PyPI ties a pending publisher to one project name per workflow and environment, hence the two environments. The first upload through a pending publisher creates its project.

  2. On GitHub, 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, add the repository variable PUBLISH with the value true. The publish jobs are skipped without it. The front end, solprop-gui, is published only when PUBLISH_GUI is true as well, after solprop itself, whose version it requires; until then each release builds it and checks an install of solprop[gui] from the files, and the extra resolves only in the repository’s workspace.

  4. On Zenodo, 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.

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 distributions and checks an install of each. Both publish jobs wait for every wheel’s test job and for solprop-dist, 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. 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.

  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; on the first release, add the concept DOI, which names every version, to CITATION.cff and the README.

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 four-platform check without building or publishing anything:

gh workflow run published-install.yml -f version=0.1.0

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 is available to complete the first release’s outstanding platform checks (issue 74).

gh workflow run published-install.yml -f version=0.1.0 -f readme_example=true

Before the new workflow is on main, GitHub cannot dispatch it by name. The existing Wheels workflow also accepts published_version: with that input it skips the wheel builds and their dependent checks and runs only the published-install check. The publishing jobs remain dependent on the skipped builds, so this mode cannot publish. It has a separate concurrency group and does not cancel a wheel build on the same branch. Select the branch whose workflow is being qualified:

gh workflow run wheels.yml --ref fix/published-install-checks \
  -f published_version=0.1.0 -f readme_example=true

Leaving published_version empty keeps the ordinary Wheels workflow unchanged.

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.