Skip to content
Merged
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
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -199,4 +199,4 @@ Single-argument bindings and read-only `Vec<T> 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.
78 changes: 78 additions & 0 deletions docs/adr/0049-prebuilt-ci-deps.md
Original file line number Diff line number Diff line change
@@ -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/<cpXY-cpXY>` → `/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-<cpXY>-<key>.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<B>-eigen<E>-eigenpy<EP>-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.