# 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](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)). 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 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](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. 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](../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:** 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](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. 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: 5. 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. 6. 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. 7. 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. 8. 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: ```sh 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](https://github.com/LupoLab/solprop/issues/74)). ```sh 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: ```sh 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](seps/archive/sep-0029-compiled-backend-distribution.md).