From 7dde605d950b5241c128ffd3976e4645b3544cb9 Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Tue, 7 Jul 2026 17:20:09 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20prebuilt=20CI=20deps=20=E2=80=94=20ADR-?= =?UTF-8?q?0049=20+=20CLAUDE.md=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheel matrix no longer recompiles Boost/eigenpy per run; deps are prebuilt once per toolchain (Linux = GHCR image, macOS = release tarballs, Windows = conda-forge) and consumed by CI. Document it: - ADR-0049: why an image / tarballs / conda per platform; the toolchain-key tag as the ABI assert; single-sourced versions (.github/ci-deps-versions.env) that retrigger the prebuild; why NOT actions/cache (10 GB ceiling); the empty-matrix guard; parallel matrix+assemble. - CLAUDE.md: fix the now-stale claims -- Boost.Python/eigenpy prebuilt (not per-run), the custom ghcr.io/bertiniteam/b2-manylinux-deps image (not stock manylinux), VERSION file (not pyproject) as the version source, and 0-skiptests via bundled OpenMPI + mpi4py. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 ++-- docs/adr/0049-prebuilt-ci-deps.md | 78 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0049-prebuilt-ci-deps.md diff --git a/CLAUDE.md b/CLAUDE.md index 86746e1dd..a9f457214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,15 +146,15 @@ compilers, and versions. Rules that follow: - **GMP/MPFR/MPC** -- Arbitrary-precision arithmetic (found via custom CMake modules in `cmake/`) - **Eigen 3** -- Linear algebra. **Not** pinned in cmake (`find_package(Eigen3)`, no version floor). In practice the version is coupled to the eigenpy build: the wheel CI builds **eigen 3.4.0** and then builds eigenpy against it (a dev env may use newer, e.g. `eigen=5.0.1`). Newer Eigen is welcome -- we *want* upstream improvements -- but it must be matched by an eigenpy built against the same Eigen (they share Eigen types across the binding ABI). -- **Boost** (serialization, filesystem, log, graph, regex, timer, chrono, thread, unit_test_framework, python) -- no minimum version pinned in cmake; `boost_system` is conditionally linked for Boost < 1.89 (header-only from 1.89). Boost.Python is ABI-locked to one CPython version, so CI rebuilds it per target Python. -- **eigenpy** -- Eigen/NumPy bridge for Python bindings. Built **from source** in CI at a single pinned version (`EIGENPY_VERSION` in `build_and_test.yml`, currently `3.13.0`) against the chosen Eigen -- eigenpy and bertini must use the *same* Eigen. eigenpy >= 3.13 sets the Python floor (>= 3.10). +- **Boost** (serialization, filesystem, log, graph, regex, timer, chrono, thread, unit_test_framework, python) -- no minimum version pinned in cmake; `boost_system` is conditionally linked for Boost < 1.89 (header-only from 1.89). Boost.Python is ABI-locked to one CPython version, so it is built **per target Python** -- but this now happens **once, up front, in the prebuilt CI deps** (Linux image / macOS tarballs, ADR-0049), *not* recompiled in every wheel run. +- **eigenpy** -- Eigen/NumPy bridge for Python bindings. **Prebuilt** into the CI deps (image/tarballs), not built-from-source per run, against the chosen Eigen -- eigenpy and bertini must use the *same* Eigen. The version is single-sourced in `.github/ci-deps-versions.env` (`EIGENPY_VERSION`, currently `3.13.0`); eigenpy >= 3.13 sets the Python floor (>= 3.10). - **jrl-cmakemodules** -- CMake helper macros (auto-fetched via FetchContent if not found) ## Build System Notes - The root `CMakeLists.txt` uses `jrl-cmakemodules` (fetched automatically). It currently only adds `core/` as a subdirectory; `python_bindings/` and `python/` subdirectory calls are commented out (the wheel build via scikit-build-core handles them). - `pyproject.toml` configures scikit-build-core: wheel packages from `python/bertini/`, build dir is `bld/`. -- Cross-platform: Linux uses manylinux Docker + `auditwheel`; macOS uses Homebrew; Windows uses conda + clang-cl (MSVC has template compilation issues). +- Cross-platform: Linux builds in a **custom prebuilt manylinux image** (`ghcr.io/bertiniteam/b2-manylinux-deps`, ADR-0049) + `auditwheel`; macOS uses Homebrew + **prebuilt Boost/eigenpy tarballs** (the `ci-deps` release); Windows uses conda-forge (which already ships prebuilt Boost/eigenpy) + clang-cl (MSVC has template compilation issues). - `-Werror` is disabled globally. `-pedantic` is stripped from flags. ## CI/CD @@ -165,7 +165,7 @@ compilers, and versions. Rules that follow: ### Linux wheel test coverage -Linux wheels are built inside a `manylinux_2_34` container (AlmaLinux 9, MPFR 4.1; set via `CIBW_MANYLINUX_X86_64_IMAGE`). The **full pytest suite runs on all three platforms** — on Linux it runs *inside* that container via `CIBW_TEST_COMMAND_LINUX`, and on macOS/Windows via the host-runner test jobs. +Linux wheels are built inside the **custom prebuilt deps image** (`ghcr.io/bertiniteam/b2-manylinux-deps`, an `manylinux_2_34`/AlmaLinux 9 base with Boost+eigenpy baked in; set via `CIBW_MANYLINUX_X86_64_IMAGE`; ADR-0049). The **full pytest suite runs on all three platforms** — on Linux it runs *inside* that container via `CIBW_TEST_COMMAND_LINUX`, and on macOS/Windows via the host-runner test jobs. `mpi4py` is installed in every test env (the image ships OpenMPI), so the MPI test modules **run at 1 rank rather than skip** — the suites are 0-skip on all three platforms. This was not always so: for a while Linux ran an import smoke test only, because the suite was SIGABRT/SIGSEGV-crashing — a crash *misattributed* to the older `manylinux_2_28` container's MPFR 3.1.6. The real cause is a **version-independent** bug (uninitialized `mpfr`/`mpc` numpy slots), now fixed in the bindings. Do **not** try to fix Linux test crashes by bumping MPFR or the manylinux image (that was tried and does not work) or by building MPFR from source (specifically out of bounds). See `docs/adr/0006-eigenpy-uninitialized-numpy-slot-guards.md` for the fix and `docs/adr/0003-manylinux-no-full-pytest.md` for the (now reversed) smoke-test stopgap and its history. @@ -199,4 +199,4 @@ Single-argument bindings and read-only `Vec const&` bindings are unaffected. - C++ standard: C++17. Headers use `.hpp` extension. - License: GPL v3 with additional terms (see `licenses/`, `core/ADDITIONAL_GPL_TERMS`). -- Version is tracked in `pyproject.toml` (the `version = "..."` line), read at runtime via `importlib.metadata.version("bertini2")`. +- Version single source of truth is the top-level **`VERSION`** file; `pyproject.toml` reads it *dynamically* (scikit-build-core), and `publish.yml`'s `check_version` asserts the release tag matches it. Read at runtime via `importlib.metadata.version("bertini2")`. Bumping requires a PR (develop is ruleset-guarded), but `VERSION` is in `paths-ignore` so a version-only PR runs no CI. diff --git a/docs/adr/0049-prebuilt-ci-deps.md b/docs/adr/0049-prebuilt-ci-deps.md new file mode 100644 index 000000000..c2b590ac8 --- /dev/null +++ b/docs/adr/0049-prebuilt-ci-deps.md @@ -0,0 +1,78 @@ +# ADR-0049: Prebuilt CI dependencies — image (Linux), tarballs (macOS), conda (Windows) + +**Status:** Accepted +**Date:** 2026-07-07 + +## Context + +Building the wheels recompiled **Boost (incl. Boost.Python) + eigenpy from source in every +CI run, on every (platform × CPython) cell** — the dominant cost of the ~hour-long matrix. +This is not gratuitous: `libboost_python3X` is **ABI-locked to a single CPython version**, so +one build cannot be shared across interpreters within a run; each of ~15 cells rebuilt it. + +The obvious fix — cache the built prefixes with `actions/cache` — was tried and **fails**: +the GitHub Actions cache is **10 GB per repo with LRU eviction**, and Boost+eigenpy prefixes +across the matrix overrun it and thrash (evict → rebuild → re-cache → evict). So caching is +the wrong tool; the deps must live in storage *outside* that ceiling. + +A second, load-bearing constraint: prebuilt `libboost_python` must match, **exactly**, the +runtime ABI — glibc/libstdc++ (manylinux), CPython ABI, Boost version, and the Eigen/eigenpy +it links. A mismatch is not a build error but a runtime `SIGABRT`/`SIGSEGV` (this repo has +that history — see ADR-0006). Any prebuild scheme must make a mismatch **impossible to +consume silently**. + +## Decision + +Prebuild the dependencies **once per toolchain** and have CI *consume* them, per platform: + +- **Linux → a GHCR image.** `ghcr.io/bertiniteam/b2-manylinux-deps`, built by + `.github/workflows/build-ci-image.yml`: a **matrix** builds Boost.Python + eigenpy for each + CPython in parallel (inside the manylinux container via `docker run`, + `docker/manylinux-deps/build-python-deps.sh`), and a thin **assemble** job unpacks them into + the image alongside the system layer (eigen/ccache/patchelf/**OpenMPI**). The wheel job sets + `CIBW_MANYLINUX_X86_64_IMAGE` to the pinned tag; `CIBW_BEFORE_BUILD_LINUX` just symlinks the + active Python's prefix (`/opt/deps/` → `/opt/deps/current`). Image/package storage + is a separate, effectively-unbounded quota (sidesteps the 10 GB ceiling); the runner + layer-caches the pull. + +- **macOS → GitHub Release-asset tarballs.** No container, so the analog is prebuilt + `deps-macos14-arm64--.tar.gz` on the `ci-deps` prerelease, built by + `build-macos-deps.yml`. `CIBW_BEFORE_BUILD_MACOS` downloads the matching bundle (with a + **build-from-source fallback** if it's absent), extracting to `/tmp/deps-py` so its + `hardcode-dll-paths` stay valid for delocate. Also off the 10 GB ceiling (release storage). + +- **Windows → nothing new.** conda-forge already ships Boost/boost-python/eigenpy as binaries, + and `environment-win.yml` already includes `msmpi`+`mpi4py`. The only from-source cost is + bertini itself (a ccache experiment addresses recompiles). + +**The ABI contract (the crux).** The image *tag* and the tarball *name* are the **toolchain +key**: `boost-eigen-eigenpy-mlx2_34`. The consumer pins that exact key, derived from +the versions. If a version is bumped but the prebuilt artifact for the new key doesn't exist +yet, the **pull 404s and the job fails loudly** — the implicit ABI assert. There is no path by +which a stale, wrong-ABI binary is silently consumed. + +**Single source of versions.** `.github/ci-deps-versions.env` holds `BOOST_VERSION` / +`EIGEN_VERSION` / `EIGENPY_VERSION`, read by all three workflows. It is in the **path filters** +of the image and macOS producers, so a bump there both reruns the wheel CI and **retriggers the +prebuild** — no more editing versions in four places and forgetting to rebuild the deps. + +**0 skiptests.** The image (and macOS via `brew open-mpi`) provide OpenMPI so `pip install +mpi4py` succeeds in the test venv; the MPI test modules then run at 1 rank instead of skipping, +matching Windows (which already ships mpi4py). + +## Consequences + +- Wheel CI stops recompiling Boost per run; the prebuild runs **rarely** — only when the + Dockerfile/recipe or a pinned version changes (or on manual dispatch). +- The Linux build+test env is a single, transparent, versioned artifact — the natural + foundation for a **conda-forge feedstock (with MPI variants)** and a **Homebrew tap** later. +- The image must be built **in `bertiniteam/b2`** (its token pushes to that org's GHCR) and the + package made **public** once (so fork-PR CI can pull it). +- Gotcha fixed here: `test_wheels_linux_macos` *excludes* ubuntu, so an ubuntu-only os matrix + (a non-develop PR base, or `-f os=linux`/`os=windows`) expanded to zero combinations and + failed the run with nothing to point at; it is now guarded with + `contains(os, 'macos-14')`. +- The image is intentionally larger than necessary (Boost headers duplicated per CPython for + recipe fidelity); a later pass can share the CPython-independent headers to shrink it. + +See `docker/manylinux-deps/README.md` for the operational detail.