# 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](seps/archive/sep-0029-compiled-backend-distribution.md)): - **`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](../guide/performance/building-native.md)). - **`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](decisions.md)). 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](https://github.com/LupoLab/solprop/issues/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`. ```sh 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): ```sh 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: ```sh 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](../guide/performance/building-native.md#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](../introduction/installation.md#checking-the-installation): 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 `..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-`. 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: ```sh 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](https://docs.github.com/en/actions/concepts/security/github_token). 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: 6. 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. 7. 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](tooling.md#ci)), and that pull request's run is the release commit's record. 8. 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. 9. 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-`. 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](#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](https://github.com/LupoLab/solprop/issues/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: ```sh 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](https://github.com/LupoLab/solprop/issues/74), closed). ```sh 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](seps/archive/sep-0029-compiled-backend-distribution.md).