diff --git a/.github/workflows/build-igraph.yml b/.github/workflows/build-igraph.yml new file mode 100644 index 000000000..f4272c71f --- /dev/null +++ b/.github/workflows/build-igraph.yml @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# Based on the manylinux/musllinux jobs of +# https://github.com/igraph/python-igraph/blob/1.0.0/.github/workflows/build.yml +name: Build igraph wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'igraph version to build (git tag, e.g. 1.0.0)' + required: true + default: '1.0.0' + pull_request: + paths: + - '.github/workflows/build-igraph.yml' + - 'patches/igraph/**' + +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || '1.0.0' }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + IGRAPH_VERSION: ${{ inputs.version || '1.0.0' }} + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + MUSLLINUX_RISCV64_IMAGE: quay.io/pypa/musllinux_1_2_riscv64 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + + build_wheels: + needs: [setup] + name: Build igraph ${{ inputs.version || '1.0.0' }} cp39-abi3-${{ matrix.libc }}_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + libc: [manylinux, musllinux] + + steps: + - name: Checkout python-igraph ${{ env.IGRAPH_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: igraph/python-igraph + ref: ${{ env.IGRAPH_VERSION }} + submodules: true + # fetch-depth: 0 also governs the vendor/source/igraph submodule clone + # depth (gotcha 268): a shallow submodule has no tags for the C core's + # CMake git_describe() version detection, and it has no IGRAPH_VERSION + # file either since it isn't a release tarball. + fetch-depth: 0 + persist-credentials: false + + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Patch python-igraph source + run: git apply python-wheels/patches/igraph/${{ env.IGRAPH_VERSION }}/*.patch + + - uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + output-dir: wheelhouse/ + env: + # setup.py's bdist_wheel_abi3 always tags the wheel cp39-abi3 (gotcha 34), + # so build on that floor and let cibuildwheel reuse+retest the same wheel + # on cp312-cp314 (gotcha 96). No free-threaded wheel exists upstream either + # (their own CIBW_SKIP: cp314t-*, since Py_LIMITED_API can't target it). + CIBW_BUILD: >- + cp39-${{ matrix.libc }}_riscv64 cp312-${{ matrix.libc }}_riscv64 + cp313-${{ matrix.libc }}_riscv64 cp314-${{ matrix.libc }}_riscv64 + CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }} + CIBW_MUSLLINUX_RISCV64_IMAGE: ${{ env.MUSLLINUX_RISCV64_IMAGE }} + CIBW_BEFORE_BUILD_LINUX: | + if command -v apk > /dev/null; then + apk add flex bison libxml2-dev zlib-dev cairo-dev + elif command -v yum > /dev/null; then + yum install -y flex bison libxml2-devel zlib-devel cairo-devel + fi + pip install -U cmake pip setuptools wheel + python setup.py build_c_core + CIBW_ENVIRONMENT: PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + CIBW_ENVIRONMENT_PASS_LINUX: PYTEST_TIMEOUT + PYTEST_TIMEOUT: 60 + # numpy/scipy/pandas/matplotlib/Pillow (upstream's `test` extra) have no + # riscv64 wheel below cp312 on our registry; `test-musl` needs none of + # them, so only the manylinux cp39 leg loses test coverage. + CIBW_TEST_SKIP: cp39-manylinux_riscv64 + # test_labels renders vertex labels and diffs against a baseline PNG + # (matplotlib image_comparison, tol=4.0); the riscv64 image's font stack + # anti-aliases just enough differently to push RMS to 6.084 (gotcha 282). + # + # testHubScore's vendored-ARPACK non-convergence on musllinux only (not + # manylinux) is upstream's own known ARPACK starting-vector flake + # (igraph/python-igraph#379, #728), not riscv64-specific -- deselect on + # musllinux only (gotcha 286). + CIBW_TEST_COMMAND: >- + cd {project} && + pip install --prefer-binary ".[${{ matrix.libc == 'musllinux' && 'test-musl' || 'test' }}]" && + python -m pytest -v tests --deselect tests/drawing/matplotlib/test_graph.py::GraphTestRunner::test_labels + ${{ matrix.libc == 'musllinux' && '--deselect tests/test_atlas.py::GraphAtlasTests::testHubScore' || '' }} + + - name: Check the wheel is abi3 and carries the compiled extension + LICENSE + run: | + python3 - wheelhouse/*.whl <<'EOF' + import sys, zipfile + for whl in sys.argv[1:]: + assert "-cp39-abi3-" in whl, whl + names = zipfile.ZipFile(whl).namelist() + assert any(n.endswith("_igraph.abi3.so") for n in names), names + licenses = {n.rsplit("/", 1)[1] for n in names if ".dist-info/licenses/" in n} - {""} + assert licenses == {"LICENSE"}, (whl, licenses) + print(whl, "ok") + EOF + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: igraph-${{ env.IGRAPH_VERSION }}-cp39-abi3-${{ matrix.libc }}_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + gpl_sources: + needs: [setup] + name: Collect GPL sources + runs-on: ubuntu-24.04-riscv + + steps: + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # plfit (vendored inside the igraph C core) links OpenMP when available, so + # auditwheel vendors the image's libgomp into igraph.libs/. + - uses: ./actions/collect-gpl-sources + with: + image: ${{ env.MANYLINUX_RISCV64_IMAGE }} + packages: gcc + output: gpl-sources.tar + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: igraph-${{ env.IGRAPH_VERSION }}-gpl-sources + path: gpl-sources.tar + if-no-files-found: error + + publish: + name: Publish igraph ${{ inputs.version || '1.0.0' }} + needs: [setup, build_wheels, gpl_sources] + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + with: + artifact-pattern: igraph-${{ inputs.version || '1.0.0' }}-*riscv64 + gpl-sources-artifact: igraph-${{ inputs.version || '1.0.0' }}-gpl-sources + gpl-sources-description: gcc diff --git a/patches/igraph/1.0.0/0001-Give-internal-PyLong-PyUnicode-helpers-non-exported.patch b/patches/igraph/1.0.0/0001-Give-internal-PyLong-PyUnicode-helpers-non-exported.patch new file mode 100644 index 000000000..3d7db691e --- /dev/null +++ b/patches/igraph/1.0.0/0001-Give-internal-PyLong-PyUnicode-helpers-non-exported.patch @@ -0,0 +1,92 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Sun, 6 Sep 2026 16:13:57 +0200 +Subject: [PATCH] Give internal PyLong/PyUnicode helpers non-exported linkage + +Upstream-Status: To upstream [not yet submitted; upstream's own CI does not run abi3audit so this leak is invisible to them] + +PyLong_AsInt_OutArg() and PyLong_to_integer_t() in convert.c are +declared with default (extern) visibility but are only ever called +from within convert.c itself; PyUnicode_CopyAsString() and +PyUnicode_IsEqualToUTF8String() in pyhelpers.c are declared in the +shared pyhelpers.h and called across several translation units, so +they need internal-to-the-library linkage rather than none at all. + +Because the extension is built with Py_LIMITED_API (setup.py sets +py_limited_api=True), abi3audit --strict flags all four as non-abi3 +symbols leaking from _igraph.abi3.so: their names collide with real +(non-limited-API) CPython C-API symbols, so a wheel claiming forward +ABI compatibility must not export them. Only PyInit__igraph needs to +be visible from the shared object. Mark the two convert.c-only +helpers static, and give the two pyhelpers.c ones hidden visibility +via __attribute__((visibility("hidden"))) since they are still +called from other translation units within the extension. + +Signed-off-by: Ludovic Henry +--- + src/_igraph/convert.c | 4 ++-- + src/_igraph/pyhelpers.c | 2 ++ + src/_igraph/pyhelpers.h | 2 ++ + 3 files changed, 6 insertions(+), 2 deletions(-) + +diff --git a/src/_igraph/convert.c b/src/_igraph/convert.c +index a36d055..795b1f7 100644 +--- a/src/_igraph/convert.c ++++ b/src/_igraph/convert.c +@@ -52,7 +52,7 @@ + * + * Returns -1 if there was an error, 0 otherwise. + */ +-int PyLong_AsInt_OutArg(PyObject* obj, int* result) { ++static int PyLong_AsInt_OutArg(PyObject* obj, int* result) { + long dummy = PyLong_AsLong(obj); + if (dummy < INT_MIN) { + PyErr_SetString(PyExc_OverflowError, "long integer too small for conversion to C int"); +@@ -962,7 +962,7 @@ int igraphmodule_PyObject_to_igraph_t(PyObject *o, igraph_t **result) { + * \param v the result is stored here + * \return 0 if everything was OK, 1 otherwise + */ +-int PyLong_to_integer_t(PyObject* obj, igraph_int_t* v) { ++static int PyLong_to_integer_t(PyObject* obj, igraph_int_t* v) { + if (IGRAPH_INTEGER_SIZE == 64) { + /* here the assumption is that sizeof(long long) == 64 bits; anyhow, this + * is the widest integer type that we can convert a PyLong to so we cannot +diff --git a/src/_igraph/pyhelpers.c b/src/_igraph/pyhelpers.c +index 6f0afaf..aab8833 100644 +--- a/src/_igraph/pyhelpers.c ++++ b/src/_igraph/pyhelpers.c +@@ -166,6 +166,7 @@ PyObject* igraphmodule_PyRange_create(Py_ssize_t start, Py_ssize_t stop, Py_ssiz + return result; + } + ++__attribute__((visibility("hidden"))) + char* PyUnicode_CopyAsString(PyObject* string) { + PyObject* bytes; + char* result; +@@ -196,6 +197,7 @@ char* PyUnicode_CopyAsString(PyObject* string) { + return result; + } + ++__attribute__((visibility("hidden"))) + int PyUnicode_IsEqualToUTF8String(PyObject* py_string, + const char* c_string) { + PyObject* c_string_conv; +diff --git a/src/_igraph/pyhelpers.h b/src/_igraph/pyhelpers.h +index 68d63d0..c40e43f 100644 +--- a/src/_igraph/pyhelpers.h ++++ b/src/_igraph/pyhelpers.h +@@ -34,12 +34,14 @@ PyObject* igraphmodule_PyList_NewFill(Py_ssize_t len, PyObject* item); + PyObject* igraphmodule_PyList_Zeroes(Py_ssize_t len); + char* igraphmodule_PyObject_ConvertToCString(PyObject* string); + PyObject* igraphmodule_PyRange_create(Py_ssize_t start, Py_ssize_t stop, Py_ssize_t step); ++__attribute__((visibility("hidden"))) + int PyUnicode_IsEqualToUTF8String(PyObject* py_string, const char* c_string); + long igraphmodule_Py_HashPointer(void *p); + + #define PyBaseString_Check(o) (PyUnicode_Check(o) || PyBytes_Check(o)) + #define PyUnicode_IsEqualToASCIIString(uni, string) \ + (PyUnicode_CompareWithASCIIString(uni, string) == 0) ++__attribute__((visibility("hidden"))) + char* PyUnicode_CopyAsString(PyObject* string); + + #define PY_IGRAPH_ASSERT_AT_BUILD_TIME(condition) \