| title | Development Guide |
|---|---|
| layout | default |
| nav_order | 6 |
Structurally, the python-wheels repository has four goals:
- Provide a simple interface for users to install Python wheels from.
- Create GitHub Actions workflows for building binary Python wheels that closely match upstream projects' existing CI/CD, but which build and test only for riscv64.
- Add supplemental workflows and tooling to track upstream releases, automate version upgrades, and simplify deprecation once upstream projects incorporate riscv64 builds, allowing developers to focus on broader package support.
- Serve as a reference to upstream maintainers, indicating that their projects can easily add support for riscv64.
python-wheels workflows should closely match those for the upstream project to
ensure that our build process for riscv64 wheels is consistent and can be
submitted to the upstream maintainers as evidence of feasibility. Unless
otherwise noted, this guide will reference the existing build-numpy.yml
workflow for example code.
The general process:
- Review the upstream project's build and test workflows (they may be called
python.yml,wheel.yml,build.yml,release.yml, or something else entirely), identifying the section(s) which build for Linux with glibc and musl. - Create a copy of the upstream build workflow in the
python-wheelsrepo at.github/workflows/build-<package>.yml, where<package>matches the project name (e.g.build-numpy.ymlfor NumPy`). - Create
docs/packages/<package>.yamldeclaring the version to build (see Declaring Versions). - Remove any workflow logic not related to the Linux glibc and musl (if present) build processes, support for other architectures and operating systems (e.g. Windows, Mac OS). This includes the sdist build (unless it is consumed by a build or test step).
- Repeat steps #2 and #4 for the corresponding test workflow, if it is separate from the upstream build file.
From this point, some customizations are required to enable builds targeting riscv64.
docs/packages/<package>.yaml is the source of truth for what a workflow
builds. A brand-new package starts with:
package-name: <package>
source-code: <repository URL>
license: <SPDX identifier>
versions:
- version: <version>version is the version the built wheel will carry (its METADATA version,
not necessarily the git tag: the workflow derives the tag from it). An entry
with no tag: and no files: is pending: it has not been released yet.
_setup.yml reads the YAML at the start of every run and hands the list of
pending versions to the build jobs as a matrix, so:
- a
pull_requestrun builds every pending version and dry-runs the publish; - a
pushtomainbuilds every pending version and publishes it; - a
workflow_dispatchwith aversionglob (e.g.2.5.*) rebuilds every matching version, released or not, and republishes it frommain; - a run with nothing pending (typically the push that lands
tag:/files:) skips every job and stays green.
Publishing fills in tag:/files: under that entry through the documentation
PR (ci_scripts/update_doc.py), which retires it from the pending set. The
wheel's version must match a declared entry byte for byte, or the publish
fails: a setuptools_scm dev version (1.0.1.dev0+g...) where 1.0.0 was
declared is a bug in the build, not something to document.
To upgrade an existing package, append a - version: <new version> line to
its YAML and open a PR; no workflow change is needed unless the new version
needs one. The nightly check_versions.py --declare run opens one draft PR
per package from package/<package>, so each upgrade is built, reviewed and merged on its
own. Publication uses the same package-specific branch, allowing a nightly
version declaration and its release metadata to share one PR without mixing
unrelated packages.
We make use of the official RISE RISC-V
Runners for any jobs which should run
on a riscv64 platform, particularly build and test jobs. The python-wheels
repository is already configured to access them. The runs-on directives in any
new workflows should be changed like so:
jobs:
build:
runs-on: ubuntu-24.04-riscv
RISE builds each release of a package for the Python versions upstream built
that release for, rather than for a fixed list. Each version entry of
docs/packages/<pkg>.yaml records them:
versions:
- version: 1.2.3
files:
- python: 3.10:manylinux
- python: 3.11:manylinux
- python: 3.14:manylinux
- python: 3.14t:manylinuxci_scripts/check_versions.py fills this in from the wheels upstream
published on PyPI for that version:
- a
cpXY-cpXYorcpXY-cpXYtLinux wheel adds3.X:PLATFORMor3.Xt:PLATFORM(free-threaded); - a
cpXY-abi3Linux wheel adds3.X-abi3:PLATFORM, which records its ABI floor. The matrix selects the oldest available non-free-threaded interpreter at or above that floor to build the one wheel; - with no version-specific wheels at all (sdist-only or pure-Python
releases),
requires_pythondecides, and no free-threaded build is made.
The nightly job writes it for every version it declares. For a version added by hand (a new port, or a manual upgrade), run:
uv run --with-requirements ci_scripts/requirements.txt python3 ci_scripts/check_versions.py --python-targets <pkg>
which adds target slots under files to every version. Every slot has a python target; publishing fills filename, sha256, and optional
requires-python into each slot. An empty slot means the whole version remains
pending and must be rebuilt as one complete release. The accepted forms are 3.X:PLATFORM, 3.Xt:PLATFORM, and 3.X-abi3:PLATFORM, where PLATFORM is manylinux or musllinux. Values
must be quoted: unquoted, YAML reads 3.10 as the number 3.1.
The setup job of every build workflow outputs a matrix with one entry
per listed version that cibuildwheel can build for riscv64. Each entry has a
cibuildwheel build-interpreter python tag such as cp314t, its
python-version form such as 3.14t, wheel-tag, and platform. An abi3
entry also has abi3-floor. For example, 3.8-abi3:manylinux with the default exclusions produces
python: cp312, python-version: 3.12, wheel-tag: cp38-abi3,
abi3-floor: 3.8, and platform: manylinux
(RISCV64_PYTHONS in ci_scripts/pending_versions.py: currently 3.9 to 3.15
and 3.14t/3.15t, but not 3.13t). Package metadata records every target upstream
publishes, including targets this repository cannot currently build. Matrix
generation omits unsupported targets such as experimental 3.13t without
removing them from files. RISCV64_PYTHONS is a technical build limit,
updated when cibuildwheel is bumped, not a metadata policy.
To build more or fewer interpreter/platform targets than upstream for one
version, edit its files[].python targets. To drop interpreters for every version of a package, use
the python-exclude input of setup, which takes shell-style globs on
cibuildwheel tags. It defaults to "cp39", so Python 3.10 and newer targets
are built when upstream publishes them. Setting it replaces the default:
python-exclude: "" also builds Python 3.9, and
python-exclude: "cp39 cp31[01] cp315*" also drops 3.15, for example when a
dependency has no riscv64 wheel for it.
Explain any override in a comment next to it. Most workflows still carry a
hard-coded python: [...] list from before this scheme (the former default
was ['3.12:manylinux', '3.13:manylinux', '3.14:manylinux', '3.14t:manylinux']); each is converted when it is next
updated to build a new release.
The official actions/setup-python Action does not yet support riscv64 builds,
so workflows using it will fall back to using the host version (if one
is present matching the major.minor numbering used by the workflow, e.g.
3.12). A simple alternative is to replace any usage of actions/setup-python
in the upstream workflow with astral-sh/setup-uv like so:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
name: Install Python
with:
python-version: '3.12'
activate-environment: true
enable-cache: false
Note the python-version 'activate-environment', and 'enable-cache' options.
The first two allow us to select the environment Python and have it pre-enabled
(matching actions/setup-python behaviour for our purposes). The enable-cache
option is disabled for now, as it has caused failures in previous build
attempts.
We use the actions/checkout action to checkout the upstream repository at the
desired tag:
- name: Checkout numpy v${{ env.NUMPY_VERSION }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: numpy/numpy
ref: v${{ env.NUMPY_VERSION }}
submodules: true
persist-credentials: false
This effectively overwrites the default project layout for the workflow, which
would otherwise be a copy of python-wheels. It allows our workflows to operate
as if they are part of the upstream project without having to include them in a
fork. More importantly, it is critical for uncomplicated usage of tools like
cibuildwheel, which assumes that the root directory is the project to be built
when invoked.
The python-wheels repository contains reusable workflows and patch files to
apply for certain projects. Every build-<package>.yml workflow starts with a
setup job that calls _setup.yml to turn the package YAML into the version
matrix, and every other job iterates over that matrix:
on:
workflow_dispatch:
inputs:
version:
description: 'Version glob to (re)build; empty builds every version of docs/packages/numpy.yaml not released yet'
required: false
default: ''
pull_request:
branches: [main]
paths:
- '.github/workflows/build-numpy.yml'
- 'docs/packages/numpy.yaml'
push:
branches: [main]
paths:
- '.github/workflows/build-numpy.yml'
- 'docs/packages/numpy.yaml'
jobs:
setup:
uses: $/.github/workflows/_setup.yml
with:
package: numpy
version: ${{ inputs.version }}
build_wheels:
needs: [setup]
if: needs.setup.outputs.versions != '[]'
name: Build numpy ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.setup.outputs.matrix) }}
env:
NUMPY_VERSION: ${{ matrix.version }}
...
The if: guard is required: GitHub Actions rejects a job whose matrix vector is
empty, and versions is [] whenever nothing is pending. _setup.yml
also outputs matrix: one target row per interpreter/platform in the version's
files[].python targets (see Target Python Versions) that
cibuildwheel can build for riscv64. Use this full matrix only when the job command or
artifact varies by Python/platform. Interpreter-independent jobs, including py3-none
wheels, one fixed ABI3 wheel, native dependencies built once, GPL collection, and publish,
use the version-only needs.setup.outputs.versions matrix. Otherwise GitHub repeats the
same work for every target row. The python-exclude input drops interpreters for a package.
Job outputs are not
per matrix leg, so a job must not hand its version to another through
outputs:; use matrix.version (or an artifact named after it) instead.
Every build-<package>.yml workflow ends with a call to _publish-wheel.yml,
which performs the following steps:
- Downloads the built wheel(s) from the previous job
- Creates an immutable GitHub Release containing the wheels
- Opens a PR against
docs/packages/<name>.yamlrecording the release under the version's entry (viaci_scripts/update_doc.py), whichci_scripts/generate_packages_doc.pylater renders into the published Markdown page. With it in place, thebuild-numpy.ymlscript'spublishjob looks like this:
publish:
name: Publish numpy ${{ matrix.version }}
needs: [setup, build_wheels]
if: needs.setup.outputs.versions != '[]'
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
permissions:
contents: write
pull-requests: write
uses: $/.github/workflows/_publish-wheel.yml
with:
artifact-pattern: numpy-${{ matrix.version }}-*-manylinux_riscv64
permissions needs contents: write and pull-requests: write here (not just
contents: read) since the docs step pushes a branch and opens a PR with the
default GITHUB_TOKEN.
Other workflows follow the same process, modifying artifact-pattern to match
their own artifact naming scheme and otherwise reusing _publish-wheel.yml like
the example.
Open a new draft PR with the workflow and the package YAML. The pull_request
trigger runs the workflow, which builds every version the YAML declares as
pending; to build additional versions, add more - version: lines. To force a
rebuild of an already released version, dispatch the workflow on the PR branch
with a version glob:
gh workflow run build-numpy.yml --ref <branch> -f version='2.5.*'
(A brand-new workflow can only be dispatched once its first pull_request run
has registered it with GitHub.) Achieving a passing (green) build may require
several attempts including rework and possible patches, depending on the nature
of the failure.
While building for both glibc and musl (in cibuildwheel terms, manylinux and
musllinux) is desirable, some of the projects we target do not build for
musllinux (or they do, but run into various issues on riscv64 specifically), and
so dependent packages cannot rely on musl versions of the packages either. If
the musl builds fail without an obvious solution, strip those jobs from the
workflow and retry, while opening an issue to track the musl incompatibility.
Some workflows may fail consistently when building or testing a module, despite following the guidelines above. When this occurs, consider whether the failure meets one of the following criteria:
- The failure exercises a narrow part of the module's functionality, or relies on external resources (e.g. large downloads over the network)
- The failure is due to reliance on some other software unavailable on riscv64
- The failure is a consequence of an artificial test limitation, e.g. a maximum timeout
- The project's build scripts use host tooling which isn't available on the
runners or in the riscv64 manylinux images (e.g.
aptvsdnf)
Additionally, some packages may not fully incorporate all of the LICENSE files found in their repositories or as required by their dependencies. See the Licensing section for more info on this topic. In these cases, it may be justified to add one or more patch files to remove these cases from the workflow. In this scenario, follow these steps:
- Any such patches should be placed in a
patches/<package_name>/<version_tag>path insidepython-wheels. - Each patch should include an
Upstream-Statustag. See the Upstream Status Tags section for details on valid types. - An extra step should be added to the build/test workflows before execution to
use
git applyto make necessary modifications to the project source. - The change should be documented for the package, so that users are aware of modifications made.
Note: Patching should be performed and reviewed on a case-by-case basis - as much functionality as possible should be tested by our system to ensure a smooth user experience when consuming wheels from RISE's package index.
A pull request run builds and tests the wheels, then calls _publish-wheel.yml
in dry-run mode. Review the publish job and confirm that it selected exactly the
expected wheels and derived the expected normalized package name and version.
Pull request runs never create releases or documentation pull requests.
Merging the pull request pushes the pending version to main, which triggers
the package workflow again through its push trigger. The successful main
run:
- creates a draft GitHub Release targeting the workflow's commit;
- names the release
<normalized-package>-v<version>and gives its tag a UTC timestamp suffix,<normalized-package>-v<version>-<yyyymmddhhmmss>; - uploads all selected wheels and any required
gpl-sources.tarasset; - publishes the release, at which point repository-level immutable releases freeze its tag and assets; and
- opens or updates the package-specific
package/<package>pull request, titled<package>: update packages, filling in the version's entry with the release tag, wheel filenames, SHA-256 hashes, andRequires-Pythonmetadata.
Every main run creates a new release; existing releases are never reused or
modified. Its description links back to the workflow run that produced it.
Publishing fails if immutable releases are disabled, or if the wheel's version
is not declared in the package YAML.
Merging the package pull request pushes the now-released entry to main and
triggers the workflow once more; with nothing left pending, that run skips every
guarded job. Until then the version is still pending on main, so any other
push touching the workflow or its YAML rebuilds and re-releases it.
The generated /simple/ package index reads only docs/packages/*.yaml. The
new release does not become discoverable through pip until the generated
documentation pull request has been reviewed, merged, and deployed. Review that
pull request promptly to complete publication.
The wheels built by the python-wheels project use a variety of open-source
licenses. Since RISE is the distributor of riscv64 wheels through its package
index, we must ensure that the wheels adhere to each project's
licensing requirements. More specifically, check:
- The built wheel contains one or more
LICENSEfiles corresponding to those contained in the upstream project source. - If the wheel ships any statically- or dynamically-linked libraries from other projects, the licensing requirements for those projects are also correctly addressed.
If either point is not met, we should follow the Patching a Project process for patching our build, and submit an issue and/or PR upstream to help them comply with license requirements as well.
Some builds statically or dynamically link against GPL-licensed components
that aren't part of the upstream project itself, but come from our build
environment - most commonly the toolchain baked into the manylinux_riscv64
container (e.g. gcc). As the distributor of the resulting wheel, we need to
make the corresponding source available permanently, not just for as long as
a CI job's artifacts happen to be retained.
Use the collect-gpl-sources action in a job alongside
build_wheels to pull the same container image and bundle the source RPM(s)
for one or more installed packages into a gpl-sources.tar artifact:
gpl_sources:
name: Collect GPL sources (gcc) for <package> ${{ inputs.version }}
runs-on: ubuntu-24.04-riscv
steps:
- name: Collect gcc source RPM from manylinux_riscv64
uses: riseproject-dev/python-wheels/actions/collect-gpl-sources@main
with:
image: ${{ env.MANYLINUX_RISCV64_IMAGE }}
packages: gcc
output: gpl-sources.tar
- uses: actions/upload-artifact@v7
with:
name: <package>-${{ inputs.version }}-gpl-sources
path: gpl-sources.tar
if-no-files-found: error
Pin MANYLINUX_RISCV64_IMAGE as a workflow-level env and pass it to both
CIBW_MANYLINUX_RISCV64_IMAGE in the build job and this job, so the sources
collected actually correspond to the toolchain that produced the wheels (see
build-numpy.yml for a complete example).
Then add gpl_sources to the publish job's needs:, and pass the artifact
through to _publish-wheel.yml:
gpl-sources-artifact: <package>-${{ inputs.version }}-gpl-sources
gpl-sources-description: gcc
_publish-wheel.yml publishes the tar as a permanent asset on the newly created
immutable GitHub Release and records it in the new version's release metadata —
no manual documentation edit is needed.
Modules which are cross-compiled from Rust to Python typically use maturin. This greatly simplifies building binary wheels for riscv64, but there is a pitfall here to watch out for - many projects use a matrix definition looking like:
matrix:
platform:
- runner: ubuntu-22.04
target: x86_64
- runner: ubuntu-22.04
target: x86
- runner: ubuntu-22.04
target: aarch64
- runner: ubuntu-22.04
target: armv7
- runner: ubuntu-22.04
target: ppc64le
For riscv64 and some other architectures, the rustc toolchain target name does
not follow this simple pattern (i.e. the arch part of the triple is not exact):
tgamblin@alchemist ~/workspace/baylibre/rise/python-wheels (tgamblin/dev-guide)$ rustup target list | grep riscv64
riscv64a23-unknown-linux-gnu
riscv64gc-unknown-linux-gnu
riscv64gc-unknown-linux-musl
riscv64gc-unknown-none-elf
riscv64imac-unknown-none-elf
Simply adding a new line with target: riscv64 will lead to build failures. The
recommended approach here is to make the matrix more explicit, then add riscv64,
so that each entry looks like:
- runner: ubuntu-24.04-riscv
target: riscv64gc-unknown-linux-gnu
arch: riscv64
Note that doing so typically requires a tweak to an Upload wheels step or
similar, so that it uses the arch field:
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels-linux-${{ matrix.platform.arch }}
path: dist
Some packages may require GCC 14 or later to compile for riscv64. If your build requires GCC 14, ensure that you are either using a cibuildwheel container approach, or (if the project doesn't use cibuildwheel) have an appropriate workaround in place, since the RISC-V runners currently ship GCC 13 by default.
We use a pattern established by the Yocto Project for indicating the purpose and status of custom patches which we carry for various projects. Each tag should be specified in the patch file's commit message like so:
Upstream-Status: <type> [reason and/or link]
There are five valid Upstream-Status types for the python-wheels repository:
Issue: An issue has been opened on the upstream project to indicate a bug was found during build/test. The issue link should be included.Submitted: A change was submitted to fix an issue upstream, but we are carrying the patch ourselves until it's merged and released in a future version. Include a link to the upstream PR and/or merged commit.To upstream: The patch needs to be submitted upstream, but submission is blocked. Include an explanation of why it can't be submitted upstream yet.Inappropriate: The patch includes changes which are necessary for riscv64 builds and/or our infrastructure, but are not relevant upstream. Include a short description of why.Backport: The patch includes a fix which is already merged in an upstream release version, but not in the version we're trying to build. Include a link and short description of the problem.
Including an Upstream-Status tag is a baseline requirement for custom patches,
and it is automatically searched for by the ci_scripts/check_patch.py script
when submitting a PR. However, any additional detail which can be provided in
each patch's commit message is helpful for maintenance.