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
154 changes: 154 additions & 0 deletions .github/workflows/build-av.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
---
# This workflow is based on: https://github.com/PyAV-Org/PyAV/blob/v18.1.0/.github/workflows/tests.yml
name: Build av wheels (riscv64)

on:
workflow_dispatch:
inputs:
version:
description: 'av version to build (PyPI version, e.g. 18.1.0)'
required: true
default: '18.1.0'
pull_request:
paths:
- '.github/workflows/build-av.yml'
- 'ci/av/**'

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

permissions:
contents: read # to fetch code (actions/checkout)

env:
# `inputs.version` is empty on pull_request events; default to 18.1.0 there.
AV_VERSION: ${{ inputs.version || '18.1.0' }}
MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64

jobs:
# The wheels bundle prebuilt FFmpeg libraries from a pyav-ffmpeg release,
# including GPL x264/x265 and LGPL FFmpeg, GnuTLS, Nettle, GMP, libunistring,
# alsa-lib and LAME. Publishing them obliges us to ship their licence texts
# and to make the corresponding sources permanently available.
vendor_sources:
name: Collect vendored FFmpeg sources
runs-on: ubuntu-latest

steps:
- name: Checkout python-wheels
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Checkout PyAV v${{ env.AV_VERSION }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: PyAV-Org/PyAV
ref: v${{ env.AV_VERSION }}
persist-credentials: false
path: pyav

- name: Download the vendored sources and extract their licences
run: |
python3 ci/av/collect-vendor-sources.py \
--pyav-dir pyav --sources-dir sources --licenses-dir licenses
tar -cf gpl-sources.tar -C sources .

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: av-${{ env.AV_VERSION }}-gpl-sources
path: gpl-sources.tar
if-no-files-found: error

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: av-${{ env.AV_VERSION }}-vendor-licenses
path: licenses/
if-no-files-found: error

build_wheels:
name: Build av ${{ inputs.version || '18.1.0' }} manylinux_riscv64
needs: [vendor_sources]
runs-on: ubuntu-24.04-riscv
timeout-minutes: 300

steps:
- name: Checkout PyAV v${{ env.AV_VERSION }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: PyAV-Org/PyAV
ref: v${{ env.AV_VERSION }}
persist-credentials: false

- name: Fetch the vendored libraries' licence texts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: av-${{ env.AV_VERSION }}-vendor-licenses
path: vendor-licenses

# setuptools' default license-files glob picks these up from the project root.
- name: Stage the licence texts for packaging
run: cp vendor-licenses/LICENSE.* .

- name: Build wheels
uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0
with:
output-dir: wheelhouse/
env:
CIBW_ARCHS: riscv64
CIBW_BUILD: "cp311* cp314t*"
CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }}
CIBW_BEFORE_BUILD: python scripts/fetch-vendor.py --config-file scripts/ffmpeg-latest.json /tmp/vendor
CIBW_ENVIRONMENT_LINUX: >-
LD_LIBRARY_PATH=/tmp/vendor/lib:$LD_LIBRARY_PATH
PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig
PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/
CIBW_TEST_REQUIRES: pytest numpy
CIBW_TEST_COMMAND: mv {project}/av {project}/av.disabled && python -m pytest {package}/tests && mv {project}/av.disabled {project}/av

- name: Check the wheels ship the compiled extensions and vendored licences
run: |
python3 - wheelhouse/*.whl <<'EOF'
import pathlib, sys, zipfile
expected = {p.name for p in pathlib.Path("vendor-licenses").iterdir()}
for whl in sys.argv[1:]:
names = zipfile.ZipFile(whl).namelist()
sos = [n for n in names if n.startswith("av/") and n.endswith(".so")]
libs = [n for n in names if n.startswith("av.libs/")]
shipped = {n.rsplit("/", 1)[1] for n in names if ".dist-info/licenses/" in n} - {""}
assert sos, f"no av/*.so in {whl}"
assert any("libavcodec" in n for n in libs), f"no vendored FFmpeg in {whl}"
assert expected <= shipped, f"{whl} is missing {sorted(expected - shipped)}"
print(f"{whl}: {len(sos)} extension modules, {len(libs)} bundled libraries, "
f"{len(shipped)} licence files")
EOF

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

publish:
name: Publish av ${{ inputs.version || '18.1.0' }} to GitLab
needs: [build_wheels, vendor_sources]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Publish wheels and open docs PR
uses: riseproject-dev/python-wheels/actions/publish-wheels@main
with:
artifact-pattern: av-${{ env.AV_VERSION }}-manylinux_riscv64
gitlab-username: ${{ vars.GITLAB_DEPLOY_USER }}
gitlab-token: ${{ secrets.GITLAB_DEPLOY_TOKEN }}
gitlab-project-id: ${{ vars.GITLAB_PROJECT_ID }}
gh-token: ${{ secrets.GITHUB_TOKEN }}
gpl-sources-artifact: av-${{ env.AV_VERSION }}-gpl-sources
gpl-sources-release-tag: av-v${{ env.AV_VERSION }}
gpl-sources-description: FFmpeg, x264, x265
119 changes: 119 additions & 0 deletions ci/av/collect-vendor-sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
"""Collect the sources and licence texts of the FFmpeg stack PyAV vendors.

PyAV's wheels bundle prebuilt shared libraries fetched from a pyav-ffmpeg
release. Several of them are GPL (x264, x265) or LGPL (FFmpeg, GnuTLS,
Nettle, GMP, libunistring, alsa-lib, LAME), so redistributing the wheels
carries a source-distribution obligation. pyav-ffmpeg pins every dependency
by URL and SHA-256 in scripts/pkg.py, which is what this reads.
"""

import argparse
import hashlib
import json
import re
import subprocess
import sys
import tarfile
from pathlib import Path

LICENCE_RE = re.compile(r"^(COPYING|COPYRIGHT|LICEN[CS]E|NOTICE)", re.IGNORECASE)


def load_packages(pkg_py: str):
namespace: dict = {}
exec(compile(pkg_py, "pkg.py", "exec"), namespace)
# Linux riscv64 enables gnutls, alsa and libvpl; CUDA/AMF/nasm are x86-only.
packages = (
namespace["gnutls_group"]
+ namespace["codec_group"]
+ [
namespace["alsa_package"],
namespace["libvpl_package"],
namespace["ffmpeg_package"],
]
)
return sorted(packages, key=lambda p: p.name)


def download(package, dest_dir: Path) -> Path:
name = package.source_filename or package.source_url.rsplit("/", 1)[-1]
# A few upstreams name their tarball after the tag alone ("v2.16.0.tar.gz").
if package.name.replace("-", "").lower() not in name.replace("-", "").lower():
name = f"{package.name}-{name}"
path = dest_dir / name
subprocess.run(
["curl", "--location", "--fail", "--silent", "--show-error",
"--output", str(path), package.source_url],
check=True,
)
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if digest != package.sha256:
raise SystemExit(
f"{package.name}: sha256 mismatch for {package.source_url}\n"
f" expected {package.sha256}\n got {digest}"
)
print(f"{package.name}: {name} ({path.stat().st_size} bytes, sha256 ok)")
return path


def extract_licences(package, tarball: Path, dest_dir: Path) -> None:
chunks = []
with tarfile.open(tarball) as tar:
for member in tar.getmembers():
parts = Path(member.name).parts
# Top level of the archive, plus one nested directory (x265 keeps
# its sources under source/, gnutls its licences under doc/).
if not member.isfile() or len(parts) > 3:
continue
if not LICENCE_RE.match(parts[-1]):
continue
handle = tar.extractfile(member)
if handle is None:
continue
text = handle.read().decode("utf-8", "replace")
chunks.append(f"===== {'/'.join(parts[1:])} =====\n\n{text}")
if not chunks:
raise SystemExit(f"{package.name}: no licence file found in {tarball.name}")
header = (
f"Licence texts for {package.name}, bundled in this wheel as a prebuilt\n"
f"shared library. Source: {package.source_url}\n\n"
)
(dest_dir / f"LICENSE.{package.name}").write_text(header + "\n\n".join(chunks))
print(f"{package.name}: {len(chunks)} licence file(s)")


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--pyav-dir", type=Path, required=True)
parser.add_argument("--sources-dir", type=Path, required=True)
parser.add_argument("--licenses-dir", type=Path, required=True)
args = parser.parse_args()

config = json.loads((args.pyav_dir / "scripts" / "ffmpeg-latest.json").read_text())
tag = config["url"].split("/download/")[1].split("/")[0]
print(f"pyav-ffmpeg release: {tag}")

args.sources_dir.mkdir(parents=True, exist_ok=True)
args.licenses_dir.mkdir(parents=True, exist_ok=True)

# pyav-ffmpeg carries the build recipe and the patches it applies to FFmpeg,
# GMP, LAME and libvpx, so it is part of the corresponding source.
recipe = args.sources_dir / f"pyav-ffmpeg-{tag}.tar.gz"
subprocess.run(
["curl", "--location", "--fail", "--silent", "--show-error", "--output", str(recipe),
f"https://github.com/PyAV-Org/pyav-ffmpeg/archive/refs/tags/{tag}.tar.gz"],
check=True,
)
with tarfile.open(recipe) as tar:
member = next(m for m in tar.getmembers() if m.name.endswith("/scripts/pkg.py"))
pkg_py = tar.extractfile(member).read().decode()

for package in load_packages(pkg_py):
tarball = download(package, args.sources_dir)
extract_licences(package, tarball, args.licenses_dir)


if __name__ == "__main__":
sys.exit(main())
Loading