Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions .github/workflows/build-fbuild.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
---
# fbuild has no sdist: release-auto.yml builds the fbuild/fbuild-daemon
# binaries and the PyO3 _native extension per target (template_native_build.yml),
# then ci/publish.py assembles the wheels. This mirrors that pipeline for
# riscv64, built natively in the manylinux image instead of cargo-zigbuild.
name: Build fbuild wheels (riscv64)

on:
workflow_dispatch:
inputs:
version:
description: 'Version glob to (re)build; empty builds every version of docs/packages/fbuild.yaml not released yet'
required: false
default: ''
pull_request:
branches: [main]
paths:
- '.github/workflows/build-fbuild.yml'
- 'docs/packages/fbuild.yaml'
push:
branches: [main]
paths:
- '.github/workflows/build-fbuild.yml'
- 'docs/packages/fbuild.yaml'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions:
contents: read

env:
MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64

jobs:
setup:
uses: $/.github/workflows/_setup.yml
with:
package: fbuild
version: ${{ inputs.version }}

build_wheel:
needs: [setup]
if: needs.setup.outputs.versions != '[]'
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
name: Build fbuild ${{ matrix.version }} manylinux_riscv64
runs-on: ubuntu-24.04-riscv
timeout-minutes: 360
env:
FBUILD_VERSION: ${{ matrix.version }}

steps:
- name: Checkout fbuild v${{ env.FBUILD_VERSION }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: FastLED/fbuild
ref: v${{ env.FBUILD_VERSION }}
persist-credentials: false

- name: Checkout python-wheels
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: python-wheels
persist-credentials: false

- name: Patch fbuild source
run: git apply python-wheels/patches/fbuild/${{ env.FBUILD_VERSION }}/00*.patch

- name: Write in-container build script
run: |
cat > "$RUNNER_TEMP/build.sh" <<'SCRIPT'
set -euxo pipefail
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none
source "$HOME/.cargo/env"
rustup toolchain install

# zccache-protocol (pulled in transitively) compiles its one .proto
# file with prost_build; protoc-bin-vendored ships no riscv64
# binary, so patches/fbuild/2.5.21/0004-...patch vendors a shim
# that falls back to a protoc on $PATH -- install one.
dnf -y install protobuf-compiler protobuf-devel

cargo build --release -p fbuild-cli -p fbuild-daemon
PYO3_NO_PYTHON=1 cargo build --release \
--target-dir target/python-extension \
-p fbuild-python \
--features extension-module

staging=dist/linux-riscv64
mkdir -p "$staging"
cp target/release/fbuild target/release/fbuild-daemon "$staging/"
cp target/python-extension/release/lib_native.so "$staging/_native.abi3.so"
strip "$staging/fbuild" "$staging/fbuild-daemon" "$staging/_native.abi3.so"

/opt/python/cp312-cp312/bin/python - <<'PY'
import ci.publish as publish

publish.build_wheel(*publish.read_project_meta(), "linux-riscv64", ["manylinux_2_39_riscv64"])
PY
auditwheel show dist/wheels/*.whl
SCRIPT

- name: Build wheel
run: |
docker run --rm \
-v "$(pwd)":/workspace \
-v "$RUNNER_TEMP/build.sh":/build.sh \
-w /workspace \
"${MANYLINUX_RISCV64_IMAGE}" \
bash /build.sh

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fbuild-${{ env.FBUILD_VERSION }}-manylinux_riscv64
path: dist/wheels/*.whl
if-no-files-found: error

test_wheel:
name: Test fbuild ${{ matrix.version }} on Python ${{ matrix.python-version }}
needs: [setup, build_wheel]
if: needs.setup.outputs.versions != '[]'
runs-on: ubuntu-24.04-riscv
timeout-minutes: 30
env:
FBUILD_VERSION: ${{ matrix.version }}
UV_PYTHON_PREFERENCE: only-managed
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
# cp310-abi3 is not installable on free-threaded builds, so no 3.14t.
python-version: ['3.12', '3.13', '3.14']

steps:
- name: Download wheel
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: fbuild-${{ env.FBUILD_VERSION }}-manylinux_riscv64

- name: Install Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: ${{ matrix.python-version }}
activate-environment: true
enable-cache: false

- name: Install wheel
run: uv pip install --reinstall --no-index --find-links . fbuild

- name: Test wheel
run: |
set -euo pipefail
version=$(fbuild --version)
echo "$version"
[ "$version" = "fbuild ${FBUILD_VERSION}" ]
fbuild-daemon --help >/dev/null
python - <<'PY'
import importlib.metadata
import os
import tempfile

import fbuild

assert fbuild.__version__ == os.environ["FBUILD_VERSION"], fbuild.__version__
with tempfile.TemporaryDirectory() as project:
assert fbuild.find_firmware(project, "uno") is None

dist_info = {p.name for p in importlib.metadata.files("fbuild") if ".dist-info/" in str(p)}
assert "LICENSE" in dist_info, dist_info
assert importlib.metadata.metadata("fbuild")["License-Expression"] == "AGPL-3.0-only"
PY

publish:
name: Publish fbuild ${{ matrix.version }}
needs: [setup, build_wheel, test_wheel]
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
secrets:
app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }}
with:
artifact-pattern: fbuild-${{ matrix.version }}-manylinux_riscv64
8 changes: 8 additions & 0 deletions docs/packages/fbuild.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package-name: fbuild
source-code: https://github.com/FastLED/fbuild
license: AGPL-3.0-only
versions:
- version: 2.5.21
comment: We patch ci/publish.py, which assembles the wheel, to ship the project's LICENSE and
declare its licence in the wheel metadata; upstream's own wheels carry neither.
patched: true
69 changes: 69 additions & 0 deletions patches/fbuild/2.5.21/0001-publish-ship-LICENSE-in-wheels.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
Upstream-Status: To upstream [upstream's wheels ship no licence file or licence metadata on any platform]

ci/publish.py hand-assembles every fbuild wheel, but writes only METADATA,
WHEEL and RECORD into the dist-info: the AGPL-3.0-only LICENSE from the
repository root never reaches the wheel, and METADATA carries no licence
field, although pyproject.toml declares `license = "AGPL-3.0-only"`.
Ship the LICENSE as `<dist-info>/licenses/LICENSE` and declare it with the
PEP 639 `License-Expression`/`License-File` fields (Metadata-Version 2.4),
reading the expression from `[project] license` in pyproject.toml.
diff --git a/ci/publish.py b/ci/publish.py
index e785a9d..6b47738 100644
--- a/ci/publish.py
+++ b/ci/publish.py
@@ -74,6 +74,9 @@ PLATFORMS: dict[str, list[str]] = {
# Extension filenames produced by build.yml
EXTENSION_NAMES = {"_native.abi3.so", "_native.pyd"}

+# Shipped in every wheel as `<dist-info>/licenses/<name>` (PEP 639).
+LICENSE_FILES = ("LICENSE",)
+

# ---------------------------------------------------------------------------
# Helpers
@@ -103,6 +106,13 @@ def read_project_meta() -> tuple[str, str, str, str, str]:
)


+def read_project_license() -> str | None:
+ """Return the SPDX `[project] license` expression from pyproject.toml, if any."""
+ with open(ROOT / "pyproject.toml", "rb") as f:
+ license_expression = tomllib.load(f)["project"].get("license")
+ return license_expression if isinstance(license_expression, str) else None
+
+
def record_hash(data: bytes) -> str:
digest = hashlib.sha256(data).digest()
return "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
@@ -151,13 +161,20 @@ def build_wheel(
tag_prefix = "py3-none"
wheel_filename = f"{name_norm}-{version}-py3-none-{tag_plat}.whl"

+ license_expression = read_project_license()
+ license_files = [ROOT / n for n in LICENSE_FILES if (ROOT / n).is_file()]
+
metadata = (
- f"Metadata-Version: 2.1\n"
+ f"Metadata-Version: 2.4\n"
f"Name: {name}\n"
f"Version: {version}\n"
f"Summary: {summary}\n"
f"Requires-Python: {requires_python}\n"
)
+ if license_expression:
+ metadata += f"License-Expression: {license_expression}\n"
+ for license_file in license_files:
+ metadata += f"License-File: {license_file.name}\n"
if readme:
# Per PEP 566: blank line separates headers from the description body,
# and Description-Content-Type declares the rendering format PyPI uses.
@@ -227,6 +244,9 @@ def build_wheel(
)

# dist-info
+ for license_file in license_files:
+ add_file(whl, f"{dist_info}/licenses/{license_file.name}", license_file.read_bytes())
+
meta_bytes = metadata.encode()
add_file(whl, f"{dist_info}/METADATA", meta_bytes)

Loading
Loading