diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf98df98..5040cd3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: # it cross-builds that target from Linux too. # aarch64-apple-darwin cross-compiles from the Linux runner via - # soldr + cargo-zigbuild + soldr's managed Apple SDK, matching the + # soldr's blessed cross path + its managed Apple SDK, matching the # release matrix. No macos-latest runner is used anywhere in this # workflow. - target: aarch64-apple-darwin diff --git a/.github/workflows/ci-workflow-drift.yml b/.github/workflows/ci-workflow-drift.yml index f2473452..b4907b01 100644 --- a/.github/workflows/ci-workflow-drift.yml +++ b/.github/workflows/ci-workflow-drift.yml @@ -42,3 +42,11 @@ jobs: run: uv run --with pyyaml --no-project python ci/check_workflow_concurrency.py - name: Unit-test the concurrency guard run: uv run --with pyyaml --no-project python -m unittest ci.test_workflow_concurrency + # soldr's blessed cross path is the only sanctioned one; cargo-zigbuild, + # ziglang, zig cc and cargo-xwin are retired. This gate keeps them from + # coming back via a copy-pasted snippet or a half-remembered recipe. + # See agents/docs/cross-compilation.md. + - name: Verify the retired cross backends stay purged + run: uv run --no-project python ci/check_no_legacy_cross.py + - name: Unit-test the cross-backend guard + run: uv run --no-project python -m unittest ci.test_no_legacy_cross diff --git a/.github/workflows/release-auto.yml b/.github/workflows/release-auto.yml index 02c1226e..15787746 100644 --- a/.github/workflows/release-auto.yml +++ b/.github/workflows/release-auto.yml @@ -185,7 +185,7 @@ jobs: # every fbuild workflow pins the binary version explicitly. # # aarch64-apple-darwin cross-compiles from the Linux - # runner via soldr + cargo-zigbuild + soldr's managed + # runner via soldr's blessed cross path + its managed # Apple SDK — no mac-host dependency in the matrix. - target: aarch64-apple-darwin runner: ubuntu-latest @@ -197,7 +197,7 @@ jobs: # x86_64-apple-darwin shares the mac_cross_linux template # branch — soldr's apple_sdk fetcher auto-picks # `darwin-x86_64` (thin-x86_64) when the target triple is - # `x86_64-apple-darwin`, and `cargo zigbuild --target + # `x86_64-apple-darwin`, and `soldr build --target # x86_64-apple-darwin` resolves the SDKROOT + frameworks # identically to the arm64 lane. Closes Lane 3 of soldr#1006. - target: x86_64-apple-darwin diff --git a/.github/workflows/template_native_build.yml b/.github/workflows/template_native_build.yml index 7fc49e3c..994b54b1 100644 --- a/.github/workflows/template_native_build.yml +++ b/.github/workflows/template_native_build.yml @@ -30,7 +30,7 @@ on: required: false type: boolean default: false - description: "Cross-compile macOS target from Linux via soldr + cargo-zigbuild + managed Apple SDK" + description: "Cross-compile a macOS target from Linux via soldr's blessed path + managed Apple SDK" ref: required: false type: string @@ -116,7 +116,12 @@ jobs: # available through the v2 catalogue. uses: zackees/setup-soldr@v0 with: - version: 0.9.6 + # 0.9.12 is the first release carrying the pkg-config sysroot fix + # (zackees/soldr#3081): soldr exported PKG_CONFIG_SYSROOT_DIR, and + # pkg-config prefixed it onto every absolute -L, so the managed + # syslib path came out as sysroot+absolute and the musl lanes died + # with `ld: cannot find -lbz2`. Do not pin below this. + version: 0.9.12 cache: true build-cache: true target-cache: true @@ -192,69 +197,19 @@ jobs: sudo apt-get -o Acquire::http::Timeout=30 -o Acquire::Retries=3 update sudo apt-get -o Acquire::http::Timeout=30 -o Acquire::Retries=3 install -y musl-tools pkg-config - # Windows MSVC release artifacts can be cross-built from Linux with - # cargo-xwin, but native Windows runners use the standard cargo path. - - name: Install cargo-xwin - if: runner.os == 'Linux' && contains(inputs.target, 'pc-windows-msvc') - uses: taiki-e/install-action@v2 - with: - tool: cargo-xwin - - - name: Install xwin system dependencies - if: runner.os == 'Linux' && contains(inputs.target, 'pc-windows-msvc') - timeout-minutes: 10 - run: | - sudo apt-get -o Acquire::http::Timeout=30 -o Acquire::Retries=3 update - # llvm provides llvm-lib + llvm-rc which cc-rs invokes when - # compiling C build scripts (e.g. blake3's blake3.c) under - # cargo-xwin. Without it the win-msvc lane fails at - # `error: failed to find tool "llvm-lib"`. - sudo apt-get -o Acquire::http::Timeout=30 -o Acquire::Retries=3 install -y --no-install-recommends clang lld llvm zip - # `clang-cl` is a driver-mode symlink, not a separate apt - # package. On Ubuntu the `clang` binary supports MSVC - # compatibility mode (`clang --driver-mode=cl`) but the - # `clang` package doesn't always create the `clang-cl` - # convenience symlink. The `CC_=clang-cl` env vars - # at the top of this template require that binary to exist - # on PATH — without it, ring + cc-rs fail with - # `failed to find tool "clang-cl": No such file or - # directory` during `cargo xwin build`. Create the symlink - # ourselves so the env vars resolve. soldr >= v0.8.0 ships - # `soldr-clang-shim` in its release archive and `soldr build` - # installs it on PATH (zackees/soldr#1032); the symlink stays - # as a safety belt for any direct `cargo xwin` invocation. - if ! command -v clang-cl >/dev/null 2>&1; then - sudo ln -sf "$(command -v clang)" /usr/local/bin/clang-cl - fi - - # cargo-zigbuild — used for two reasons on Linux: - # 1. musl cross-compilation (linux_cross targets) - # 2. Building the PyO3 extension with a pinned glibc symbol version - # so manylinux_2_17 wheels actually run on glibc 2.17. The native - # ubuntu-latest linker would otherwise pull in glibc 2.39 symbols. + # soldr's blessed cross path, step 1 of 2. `soldr prepare` installs the + # target stdlib and materializes the compiler, linker and SDK/sysroot, + # then exports the target-scoped environment that `soldr build` + # consumes. Run for EVERY target, native included: it is a no-op where + # nothing needs fetching, and running it unconditionally means one code + # path instead of a per-target branch. # - # PINNED. This install was unpinned and 0.23.4 broke the two - # apple-darwin lanes of the 2.5.22 release: rustc emits - # `-Wl,-exported_symbols_list -Wl,` for a cdylib, 0.23.4 - # reorders those linker args so zig reads the *next* flag as the - # list path, and the link dies with - # error: unable to read exported symbols list '-dead_strip': FileNotFound - # error: could not compile `zccache-watcher` (lib) - # 0.23.1 + ziglang 0.16.0 is the exact pair that built the 2.5.21 - # release. Bump deliberately, with a release build to prove it. - - name: Install cross-compilation tools - if: runner.os == 'Linux' && !contains(inputs.target, 'pc-windows-msvc') - timeout-minutes: 10 - run: pip install --timeout 60 cargo-zigbuild==0.23.1 ziglang==0.16.0 - - # Linux → mac cross — soldr fetches the Apple SDK ahead of the - # cargo invocation so the SDKROOT export is debuggable independent - # of the build itself. + # Kept as its own step so a slow or failing SDK/sysroot download is + # diagnosable separately from the compile. # - # SOLDR_TOOLCHAIN_ORIGIN is job-scoped above so this prepare step and - # both following build steps resolve the exact same catalogue. - - name: Prepare Apple SDK (Linux → mac cross) - if: inputs.mac_cross_linux + # SOLDR_TOOLCHAIN_ORIGIN is job-scoped above so this step and both + # build steps resolve the exact same catalogue. + - name: Prepare target toolchain timeout-minutes: 15 shell: bash run: soldr prepare --target ${{ inputs.target }} @@ -263,164 +218,59 @@ jobs: timeout-minutes: 45 shell: bash run: | - if [ "${{ inputs.mac_cross_linux }}" = "true" ]; then - # Linux → aarch64-apple-darwin: zigbuild + soldr-managed SDK. - # Identical recipe to ci/docker-mac-cross/build.sh, which - # validates this same path against a minimal ubuntu:24.04 image. - soldr cargo zigbuild --release --target ${{ inputs.target }} \ - -p fbuild-cli \ - -p fbuild-daemon - elif [ "${{ runner.os }}" = "Linux" ] && [[ "${{ inputs.target }}" == *-pc-windows-msvc ]]; then - # Blessed Linux→Windows MSVC path (soldr#1012 PR 6), - # used by the aarch64-pc-windows-msvc lane. `soldr build` - # installs the soldr-clang-shim ahead of system clang on - # PATH so ring's hardcoded `c.compiler("clang")` - # (build.rs:563) routes to clang-cl for - # aarch64-windows-msvc. For x64 the shim is harmless - # passthrough. cargo-xwin is still installed (handles - # SDK download); soldr build delegates to cargo-xwin via - # internal env vars when the catalogue xwin-cache row is - # absent for the target arch. Requires soldr >= v0.8.0 - # (first release whose archive ships the shim). - # Prepare explicitly so the managed xwin cache is available for - # the x64 CRT compatibility path below. - soldr prepare --target ${{ inputs.target }} - if [ "${{ inputs.target }}" = "x86_64-pc-windows-msvc" ]; then - # Keep the blessed compiler and derive the cache's actual CRT, - # UM, and UCRT library directories from their canonical files. - # The catalogue's directory labels differ between cache releases. - xwin_root="$(find "$SOLDR_CACHE_DIR/sdk/${{ inputs.target }}/xwin" -mindepth 2 -maxdepth 2 -type d -name xwin -print -quit)" - [ -n "$xwin_root" ] || { echo "managed xwin cache missing" >&2; exit 1; } - msvcrt="$(find "$xwin_root/crt/lib/x86_64" -type f -iname msvcrt.lib -print -quit)" - oldnames="$(find "$xwin_root/crt/lib/x86_64" -type f -iname oldnames.lib -print -quit)" - kernel32="$(find "$xwin_root/sdk/lib/um/x86_64" -type f -iname kernel32.lib -print -quit)" - ucrt="$(find "$xwin_root/sdk/lib/ucrt/x86_64" -type f -iname ucrt.lib -print -quit)" - [ -n "$msvcrt" ] && [ -n "$oldnames" ] && [ -n "$kernel32" ] && [ -n "$ucrt" ] || { echo "managed xwin libraries missing" >&2; exit 1; } - for library in "$msvcrt" "$oldnames"; do - directory="$(dirname "$library")" - base="$(basename "$library")" - lower="${base,,}" - upper="$(basename "$base" .lib | tr '[:lower:]' '[:upper:]').lib" - [ "$base" = "$lower" ] || ln -sf "$base" "$directory/$lower" - [ "$base" = "$upper" ] || ln -sf "$base" "$directory/$upper" - done - export RUSTFLAGS="-D warnings -C link-arg=/LIBPATH:$(dirname "$msvcrt") -C link-arg=/LIBPATH:$(dirname "$kernel32") -C link-arg=/LIBPATH:$(dirname "$ucrt")" - else - # Cargo's global RUSTFLAGS takes precedence over soldr's - # target-specific xwin linker flags. - unset RUSTFLAGS - fi - soldr --no-cache build --release --target ${{ inputs.target }} \ - -p fbuild-cli \ - -p fbuild-daemon - elif [[ "${{ inputs.target }}" == *-unknown-linux-musl ]]; then - # Direct `cargo zigbuild` (pip-installed wrapper) bypasses - # soldr's bin cache, which has been serving a corrupted - # cargo-zigbuild binary (`Syntax error: ")" unexpected` at - # line 10) and blocking PyPI publishes. See #331. - # mimalloc-pprof's vendored diagnostic banner expands - # __DATE__/__TIME__. Zig promotes that one warning to an error; - # demote only that diagnostic for this C dependency build. - cargo zigbuild --release --target ${{ inputs.target }} \ - -p fbuild-cli \ - -p fbuild-daemon - else - # Default: `soldr cargo build`. On the native - # windows-latest runner this picks up MSVC's - # link.exe / cl.exe automatically. - soldr cargo build --release --target ${{ inputs.target }} \ - -p fbuild-cli \ - -p fbuild-daemon - fi + # One invocation for every target, native and cross alike. + # `soldr build` is soldr's blessed-default surface and consumes + # exactly what `soldr prepare` set up above. + # + # This replaced four hand-rolled branches: `soldr cargo zigbuild` + # for the apple-darwin lanes, bare `cargo zigbuild` for musl, + # and for x86_64-pc-windows-msvc a cargo-xwin install plus ~40 + # lines of shell that hunted through the xwin cache and created + # lower/upper-case symlinks for msvcrt.lib and oldnames.lib so a + # case-sensitive filesystem would satisfy MSVC's import + # libraries. soldr owns the xwin cache now; none of that is ours + # to carry. See agents/docs/cross-compilation.md. + soldr build --release --target ${{ inputs.target }} \ + -p fbuild-cli \ + -p fbuild-daemon - # PyO3 extension — built for ALL targets, including cross-compiled. - # Notes: - # - PYO3_NO_PYTHON=1 + abi3-py310 feature lets pyo3-build-config skip - # the host interpreter lookup on cross builds. - # - All Linux builds (native AND cross) use cargo-zigbuild against - # -unknown-linux-gnu.2.17 — manylinux wheels are glibc-based - # and must work on glibc >= 2.17 (the manylinux_2_17 floor). Native - # ubuntu-latest's system linker would otherwise pull in glibc 2.39 - # symbols, breaking the wheel on every distro older than ubuntu-24.04. - # The CLI binaries stay on musl for static-link portability. - # - macOS cross: cargo rustc with -undefined dynamic_lookup works the - # same as native; just add --target. + # PyO3 extension — built for ALL targets, including cross-compiled, + # through the same blessed path as the binaries. + # + # manylinux wheels are glibc-based and must run on glibc >= 2.17. soldr's + # catalogue sysroot holds that floor on its own: the extension built via + # `soldr build --target x86_64-unknown-linux-gnu` tops out at GLIBC_2.16, + # measured 2026-09-03 with + # objdump -T lib_native.so | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail + # That is *below* the floor, and below the 2.17 the retired + # `cargo zigbuild --target ...-gnu.2.17` lane produced. + # + # Do NOT pass zigbuild's `.2.17` target suffix to soldr. soldr has no + # such target, logs the miss as a warning, falls back to the bare host + # toolchain and still exits 0 — which yields a GLIBC_2.39 .so wearing a + # manylinux_2_17 tag. The plain triple is correct and is what holds the + # floor. See agents/docs/cross-compilation.md. + # + # PYO3_NO_PYTHON=1 + the abi3-py310 feature let pyo3-build-config skip + # the host-interpreter lookup on cross builds. - name: Build Python extension timeout-minutes: 30 shell: bash run: | PYTHON_TARGET_DIR="target/python-extension" - if [ "${{ inputs.mac_cross_linux }}" = "true" ]; then - # Linux → aarch64-apple-darwin PyO3 extension. zigbuild + - # soldr-managed SDK + PYO3_NO_PYTHON=1 (PyO3's abi3-py310 - # feature lets us skip host interpreter discovery on cross - # builds). Lands at - # target/python-extension/aarch64-apple-darwin/release/lib_native.dylib - # — same path the macOS native lane produced before, so the - # stage step is unchanged. - PYO3_NO_PYTHON=1 soldr cargo zigbuild --release \ - --target-dir "${PYTHON_TARGET_DIR}" \ - --target ${{ inputs.target }} -p fbuild-python \ - --features extension-module - elif [ "${{ runner.os }}" = "Linux" ] && [[ "${{ inputs.target }}" == *-pc-windows-msvc ]]; then - # Blessed Linux→Windows MSVC path for the PyO3 ext - # (soldr#1012 PR 6, aarch64-pc-windows-msvc lane). - # `soldr build` installs the soldr-clang-shim so - # ring + cc-rs route through clang-cl correctly even - # for aarch64 PyO3 dylib compilation. - soldr prepare --target ${{ inputs.target }} - if [ "${{ inputs.target }}" = "x86_64-pc-windows-msvc" ]; then - xwin_root="$(find "$SOLDR_CACHE_DIR/sdk/${{ inputs.target }}/xwin" -mindepth 2 -maxdepth 2 -type d -name xwin -print -quit)" - [ -n "$xwin_root" ] || { echo "managed xwin cache missing" >&2; exit 1; } - msvcrt="$(find "$xwin_root/crt/lib/x86_64" -type f -iname msvcrt.lib -print -quit)" - oldnames="$(find "$xwin_root/crt/lib/x86_64" -type f -iname oldnames.lib -print -quit)" - kernel32="$(find "$xwin_root/sdk/lib/um/x86_64" -type f -iname kernel32.lib -print -quit)" - ucrt="$(find "$xwin_root/sdk/lib/ucrt/x86_64" -type f -iname ucrt.lib -print -quit)" - [ -n "$msvcrt" ] && [ -n "$oldnames" ] && [ -n "$kernel32" ] && [ -n "$ucrt" ] || { echo "managed xwin libraries missing" >&2; exit 1; } - for library in "$msvcrt" "$oldnames"; do - directory="$(dirname "$library")" - base="$(basename "$library")" - lower="${base,,}" - upper="$(basename "$base" .lib | tr '[:lower:]' '[:upper:]').lib" - [ "$base" = "$lower" ] || ln -sf "$base" "$directory/$lower" - [ "$base" = "$upper" ] || ln -sf "$base" "$directory/$upper" - done - export RUSTFLAGS="-D warnings -C link-arg=/LIBPATH:$(dirname "$msvcrt") -C link-arg=/LIBPATH:$(dirname "$kernel32") -C link-arg=/LIBPATH:$(dirname "$ucrt")" - else - unset RUSTFLAGS - fi - PYO3_NO_PYTHON=1 soldr --no-cache build --release \ - --target-dir "${PYTHON_TARGET_DIR}" \ - --target ${{ inputs.target }} -p fbuild-python \ - --features extension-module - elif [ "${{ runner.os }}" = "Linux" ]; then - TARGET="${{ inputs.target }}" - ARCH="${TARGET%%-*}" + PYO3_TARGET="${{ inputs.target }}" + if [ "${{ runner.os }}" = "Linux" ] \ + && [[ "${{ inputs.target }}" == *-unknown-linux-musl ]]; then + # The CLI binaries stay on musl for static-link portability, but a + # manylinux wheel is glibc-based -- build the extension for gnu. + ARCH="${PYO3_TARGET%%-*}" PYO3_TARGET="${ARCH}-unknown-linux-gnu" - rustup target add "$PYO3_TARGET" - # Direct `cargo zigbuild` (pip-installed wrapper) — see #331. - PYO3_NO_PYTHON=1 cargo zigbuild --release \ - --target-dir "${PYTHON_TARGET_DIR}" \ - --target "${PYO3_TARGET}.2.17" -p fbuild-python \ - --features extension-module - elif [ "${{ inputs.macos_cross }}" = "true" ]; then - PYO3_NO_PYTHON=1 soldr cargo build --release \ - --target-dir "${PYTHON_TARGET_DIR}" \ - --target ${{ inputs.target }} -p fbuild-python \ - --features extension-module - elif [ "${{ runner.os }}" = "macOS" ]; then - soldr cargo build --release --target-dir "${PYTHON_TARGET_DIR}" \ - --target ${{ inputs.target }} \ - -p fbuild-python \ - --features extension-module - else - # native windows-latest runner: cargo build picks up the - # MSVC toolchain auto-installed on the runner image. - soldr cargo build --release --target-dir "${PYTHON_TARGET_DIR}" \ - --target ${{ inputs.target }} \ - -p fbuild-python \ - --features extension-module + soldr prepare --target "$PYO3_TARGET" fi + PYO3_NO_PYTHON=1 soldr build --release \ + --target-dir "${PYTHON_TARGET_DIR}" \ + --target "$PYO3_TARGET" -p fbuild-python \ + --features extension-module - name: Stage artifacts shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index d0434d32..2026c0f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ When operating in this repo on a task that isn't covered by the architectural ov | "What DTR/RTS state do I open this CDC port at?" | [`docs/usb-cdc-control-line-matrix.md`](docs/usb-cdc-control-line-matrix.md) | | "How do I run the serial detection code against a real ESP32?" | [`agents/docs/serial-testing.md`](agents/docs/serial-testing.md) (FastLED/fbuild#899 — Docker/WSL real-device harness) | | "Where does this path/cache/build dir live, and why won't my cache key hit?" | [`agents/docs/path-conventions.md`](agents/docs/path-conventions.md) | +| "How do I cross-compile / why did a release lane break?" | [`agents/docs/cross-compilation.md`](agents/docs/cross-compilation.md) | | "Which crate owns this code?" | [`crates/CLAUDE.md`](crates/CLAUDE.md) | | "Which architecture doc maps to my crate?" | [`docs/CLAUDE.md`](docs/CLAUDE.md) | | "Is this serial port the right device?" | `fbuild serial probe list` (FastLED/fbuild#686) | @@ -61,9 +62,14 @@ The four rules an agent must internalize before doing anything else (all listed - MSRV: 1.95.0 | Edition: 2021 | Toolchain: 1.95.0 pinned in `rust-toolchain.toml` (clippy + rustfmt) - CI hosts: Linux, Windows. All warnings denied (`RUSTFLAGS="-D warnings"`). There are **no macOS runners** — macOS is a build *target*, not a test host: every apple-darwin - binary is cross-built from Linux (soldr + `cargo-zigbuild` + managed Apple SDK) by the - Linux-hosted native workflows, `release-auto.yml` and `build.yml`. Do not add a - `macos-latest` lane back. + binary is cross-built from Linux by the Linux-hosted native workflows, + `release-auto.yml` and `build.yml`. Do not add a `macos-latest` lane back. +- **Cross-compilation goes through soldr, and only soldr** — `soldr prepare --target X` + then `soldr build --target X`. The zig- and xwin-based wrappers are retired and + enforced-out by `ci/check_no_legacy_cross.py` plus the `tool_guard.py` hook. Never pass zigbuild's `.2.17` glibc suffix to soldr: it has no + such target, warns, falls back to the host toolchain and still exits 0, shipping a + GLIBC_2.39 wheel tagged `manylinux_2_17`. soldr's own sysroot links at 2.16. + See [`agents/docs/cross-compilation.md`](agents/docs/cross-compilation.md). - Every directory with files must have a README.md (enforced by hook) ## Commands diff --git a/Cargo.toml b/Cargo.toml index be6c263a..6073fca2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,22 @@ exclude = [ [workspace.metadata.dylint] libraries = [{ path = "dylints/*" }] +# Release targets, in the shape `soldr prepare --target all` expands. soldr's +# blessed cross path builds every one of these from a Linux runner; the repo +# no longer carries cargo-zigbuild or cargo-xwin for any of them. +# See agents/docs/cross-compilation.md. +[workspace.metadata.soldr] +targets = [ + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", +] + [workspace.package] version = "2.5.22" edition = "2021" diff --git a/agents/docs/README.md b/agents/docs/README.md index 46cbfbe1..dfba1b37 100644 --- a/agents/docs/README.md +++ b/agents/docs/README.md @@ -12,6 +12,11 @@ routing table sends an agent here by task. factory functions that pick them, and why a mis-spelled/absolute path silently defeats a cache key. **Read before touching any cache dir, build dir, or cache-key/signature code** (FastLED/fbuild#952). +- [`cross-compilation.md`](cross-compilation.md) — soldr's blessed + cross path (`soldr prepare` + `soldr build`), the banned wrappers and + the lint that enforces it, and the **manylinux glibc-floor trap**: a + wheel can build green, be tagged `manylinux_2_17`, and still require + glibc 2.39. **Read before touching any release lane.** - [`deploy-architecture.md`](deploy-architecture.md) — the `Deployer` trait, `post_deploy_recovery`, board-family dispatch model. diff --git a/agents/docs/cross-compilation.md b/agents/docs/cross-compilation.md new file mode 100644 index 00000000..4a5c7072 --- /dev/null +++ b/agents/docs/cross-compilation.md @@ -0,0 +1,138 @@ +# Cross-compilation + +How fbuild's release binaries and wheels get built for platforms the +runner is not. **Read this before touching +[`.github/workflows/template_native_build.yml`](../../.github/workflows/template_native_build.yml) +or any release lane.** + +## The blessed path + +soldr owns cross-compilation. Two commands, identical shape for every +supported triple: + +```bash +soldr prepare --target # stdlib + compiler/linker + SDK/sysroot + env +soldr build --release --target -p +``` + +`soldr prepare --help` states the contract: it "installs the Rust +standard library, selects and materializes the blessed compiler/linker +plus SDK or sysroot, and exports the target-scoped environment. Legacy +backend wrappers are diagnostic-only overrides and are never selected by +this command." + +**`cargo-zigbuild`, `ziglang`, `zig cc`/`zig c++`, and `cargo-xwin` are +banned**, enforced by `ci/check_no_legacy_cross.py` (unit tests in +`ci/test_no_legacy_cross.py`). The gate scans runnable lines only — +comments may name them, which is how the history below stays readable. + +soldr's supported targets, as reported by its own error text when asked +for something else: + +``` +x86_64-pc-windows-msvc, x86_64-pc-windows-gnu, aarch64-pc-windows-msvc, +x86_64-apple-darwin, aarch64-apple-darwin, +x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, +x86_64-unknown-linux-musl, aarch64-unknown-linux-musl +``` + +## The trap: never hand soldr a zigbuild target suffix + +**This is the mistake not to repeat.** It cost hours and nearly cost a +lane of the migration. + +manylinux wheels are glibc-based: a `manylinux_2_17` wheel promises it +runs on glibc >= 2.17. Nothing in the build or the packaging checks that +promise -- `ci/publish.py` assigns the tag from a filename map, not by +measuring the binary. So a `.so` linked against a newer glibc gets tagged +`manylinux_2_17`, uploads fine, installs fine, and fails at **import** +on any distro older than the build host. + +**soldr holds the floor by itself.** Its catalogue sysroot for +`x86_64-unknown-linux-gnu` produces an extension topping out at +**GLIBC_2.16** -- below the 2.17 floor, and below what the retired +`cargo zigbuild --target ...-gnu.2.17` lane produced: + +| Built with | Max GLIBC symbol | +|---|---| +| `cargo zigbuild --target x86_64-unknown-linux-gnu.2.17` (retired) | 2.17 | +| `soldr build --target x86_64-unknown-linux-gnu` | **2.16** | +| `soldr build --target x86_64-unknown-linux-gnu.2.17` | **2.39** | + +Read that third row again. **Do not port zigbuild's `..` +suffix onto a soldr target.** soldr has no such target. It logs the miss +as a *warning*, falls back to the bare host toolchain, and still exits 0: + +``` +soldr build: catalogue zstd sysroot unavailable for x86_64-unknown-linux-gnu.2.17: + unsupported platform: no zstd sysroot recipe for target x86_64-unknown-linux-gnu.2.17; + supported: [... "x86_64-unknown-linux-gnu" ...] +error: error loading target specification: + could not find specification for target "x86_64-unknown-linux-gnu.2.17" +... exit code 0 +``` + +The output lands in `target/.../x86_64-unknown-linux-gnu/release/` with +the suffix normalized away, so the path looks right too. The only signal +that anything went wrong is the glibc floor of the artifact. + +Carrying a habit from the old backend into the new one produced a +*worse* result than either doing nothing or doing it right. When +migrating a toolchain, re-derive the invocation from the new tool's own +docs; do not translate the old flags. + +## Always verify the artifact, never the exit code + +Both failure modes in this doc produced **exit 0**. Check the binary: + +```bash +# glibc floor of a Linux .so — anything above 2.17 is a broken wheel +objdump -T .so | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -4 + +# Mach-O arch (no `file` on some hosts): cffaedfe = Mach-O 64 LE, +# cputype 0x100000c = arm64, 0x1000007 = x86_64 +python3 -c "d=open('','rb').read(8); print(d[:4].hex(), hex(int.from_bytes(d[4:8],'little')))" +``` + +The published wheel is downloadable, so the shipped floor is always +checkable after the fact: + +```bash +curl -sL -o w.whl && python3 -c "import zipfile;zipfile.ZipFile('w.whl').extractall('x')" +objdump -T x/fbuild/_native.abi3.so | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail +``` + +## Pin every toolchain that can float + +On 2026-09-03 the 2.5.22 release failed both apple-darwin lanes: + +``` +error: unable to read exported symbols list '-dead_strip': FileNotFound +error: could not compile `zccache-watcher` (lib) due to 1 previous error +``` + +Cause: the template ran `pip install cargo-zigbuild` **unpinned**. The +last good release built on 0.23.1; that day pip served 0.23.4, which +reorders the `-Wl,-exported_symbols_list` / `-Wl,` pair rustc +emits for a cdylib so zig reads the following flag as the list path. +Nothing in fbuild had changed. + +The rule: **a release lane may not install a floating version of +anything.** soldr's binary version is pinned explicitly in the workflow; +any remaining pip install is pinned too. When a pin moves, prove it with +a release build before merging. + +## Why the version pin matters more than it looks + +`zackees/setup-soldr@v0` is a floating major tag; the `version:` input +pins only the soldr *binary*, not the action. If a cross lane breaks +with no corresponding fbuild change, compare the action SHA and the +installed tool versions against the last good run before touching +fbuild's own code: + +```bash +gh run view --job --repo FastLED/fbuild --log | grep -E "Download action repository|Successfully installed" +``` + +That diff is what identified the cargo-zigbuild regression above in +minutes. diff --git a/ci/check_no_legacy_cross.py b/ci/check_no_legacy_cross.py new file mode 100644 index 00000000..6c9a3583 --- /dev/null +++ b/ci/check_no_legacy_cross.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Forbid the retired cross-compilation backends: zig, cargo-zigbuild, xwin. + +soldr's blessed cross path (`soldr prepare --target X` + `soldr build --target +X`) selects and materializes the compiler, linker, SDK and sysroot for every +supported triple. `soldr prepare --help` states the contract directly: "Legacy +backend wrappers are diagnostic-only overrides and are never selected by this +command." + +Before that existed, this repo reached for the wrappers directly, and each one +cost us: + + * `cargo-zigbuild` was pip-installed UNPINNED. It floated 0.23.1 -> 0.23.4 + mid-release and broke both apple-darwin lanes of 2.5.22 with + `unable to read exported symbols list '-dead_strip': FileNotFound`, + because 0.23.4 reorders the linker args rustc emits for a cdylib. + * `cargo-xwin` needed ~40 lines of CRT-casing symlink repair in the + workflow to make a case-sensitive filesystem match MSVC's import + libraries. + * soldr's bin cache served a corrupted `cargo-zigbuild` binary + (`Syntax error: ")" unexpected`), which forced a `--no-cache` bypass. + +None of that is our problem to carry any more. This gate keeps it from coming +back by accident -- a copy-pasted workflow snippet or an agent reaching for +the recipe it remembers. + +Run with no arguments: + + uv run --no-project python ci/check_no_legacy_cross.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# Directories worth scanning. Everything else is either generated, vendored, +# or not a place a build recipe can hide. +SCAN_DIRS = (".github", "ci", "crates", "docs", "agents", "dylints") +SCAN_ROOT_FILES = ("CLAUDE.md", "README.md", "Cargo.toml", "pyproject.toml") + +SCAN_SUFFIXES = {".yml", ".yaml", ".sh", ".py", ".rs", ".toml", ".md", ".bash"} +SCAN_NAMES = {"Dockerfile"} + +SKIP_DIR_PARTS = {".git", "target", "node_modules", "__pycache__", ".venv"} + +# Each pattern is (regex, human-readable rule). +PATTERNS: list[tuple[re.Pattern[str], str]] = [ + ( + re.compile(r"\bcargo[- ]zigbuild\b"), + "cargo-zigbuild -- use `soldr build --target `", + ), + ( + re.compile(r"\bziglang\b"), + "ziglang -- soldr materializes its own toolchain", + ), + ( + # `\b` cannot terminate `c++` -- `+` is not a word character, so a + # trailing `\b` would need a word char after it and `zig c++ -o` would + # slip through. Assert "not continuing an identifier" instead. + re.compile(r"\bzig\s+(?:cc|c\+\+|build-exe)(?![\w-])"), + "zig as a C compiler -- use `soldr cc` / `soldr c++`", + ), + ( + re.compile(r"\bcargo[- ]xwin\b"), + "cargo-xwin -- `soldr build --target *-pc-windows-msvc` owns the xwin cache", + ), +] + +# Files that are ALLOWED to name the retired backends, and why. A path here +# still may not contain an *invocation*; see INVOCATION below. +ALLOWLIST: dict[str, str] = { + "ci/check_no_legacy_cross.py": "this gate names what it forbids", + "ci/test_no_legacy_cross.py": "unit tests for this gate", + "agents/docs/cross-compilation.md": ( + "the doc that explains why these are banned and shows the measured " + "glibc floors; it must be able to name them" + ), + "ci/bench-results/REPORT.md": "frozen benchmark record of a past run", +} + +# A mention inside a comment is history; a mention in a runnable line is a +# relapse. Comment prefixes per file type we scan. +COMMENT_PREFIXES = ("#", "//", "--", "*", "", + ]: + with self.subTest(line=line): + self.assertTrue(is_comment(line), f"not treated as comment: {line}") + + def test_runnable_lines_are_not_comments(self) -> None: + for line in [ + " run: cargo zigbuild --release", + " soldr build --release", + ]: + with self.subTest(line=line): + self.assertFalse(is_comment(line)) + + +if __name__ == "__main__": + unittest.main() + + +class PragmaTests(unittest.TestCase): + def test_pragma_on_the_line_above_exempts_it(self) -> None: + from ci.check_no_legacy_cross import is_exempt + + lines = [ + " # lint-allow: legacy-cross: manylinux glibc floor, soldr cannot hold it", + " run: pip install cargo-zigbuild==0.23.1", + ] + self.assertTrue(is_exempt(lines, 1)) + + def test_unmarked_line_is_not_exempt(self) -> None: + from ci.check_no_legacy_cross import is_exempt + + lines = [" # ordinary comment", " run: pip install cargo-zigbuild"] + self.assertFalse(is_exempt(lines, 1)) diff --git a/crates/fbuild-python/tests/pyo3_policy.rs b/crates/fbuild-python/tests/pyo3_policy.rs index 79d671a3..f2977848 100644 --- a/crates/fbuild-python/tests/pyo3_policy.rs +++ b/crates/fbuild-python/tests/pyo3_policy.rs @@ -156,42 +156,42 @@ fn pyo3_029_policy_stays_target_python_independent() { ); } + // Every cross build must suppress the host-interpreter lookup, and the + // blessed path is the only entry point left. The template collapsed four + // per-target branches into these two commands. for command in [ - "PYO3_NO_PYTHON=1 soldr cargo zigbuild --release \\", - "PYO3_NO_PYTHON=1 soldr --no-cache build --release \\", - "PYO3_NO_PYTHON=1 cargo zigbuild --release \\", - "PYO3_NO_PYTHON=1 soldr cargo build --release \\", + "PYO3_NO_PYTHON=1 soldr build --release \\", + "soldr build --release --target ${{ inputs.target }} \\", ] { assert!( workflow.contains(command), - "cross-build branch lost host-interpreter suppression: {command}" + "cross-build lost the blessed soldr entry point: {command}" ); } - // The Windows MSVC branches route through `soldr --no-cache build` - // (the xwin CRT-casing fixes made the cache bypass part of the - // blessed invocation); the policy is the soldr entry point plus - // host-interpreter suppression, not the exact cache flags. - for command in [ - "soldr --no-cache build --release --target ${{ inputs.target }} \\", - "PYO3_NO_PYTHON=1 soldr --no-cache build --release \\", - ] { + // The retired backends are gone with no exceptions. soldr's catalogue + // sysroot holds the manylinux glibc floor by itself -- the extension it + // produces for x86_64-unknown-linux-gnu tops out at GLIBC_2.16, below the + // 2.17 floor and below what the old zigbuild lane produced. There is no + // remaining reason for any of these to appear. + // + // Never reintroduce zigbuild's `.2.17` target suffix here either: soldr + // has no such target, warns, silently falls back to the host toolchain + // and exits 0, yielding a GLIBC_2.39 .so tagged manylinux_2_17. + // See agents/docs/cross-compilation.md. + // lint-allow: legacy-cross: this list is what the test forbids + for retired in ["zigbuild", "ziglang", "cargo-xwin", "cargo xwin"] { + let hits: Vec<&str> = workflow + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .filter(|line| line.contains(retired)) + .collect(); assert!( - workflow.contains(command), - "Windows MSVC cross-build lost the blessed soldr entry point: {command}" + hits.is_empty(), + "retired cross backend {retired} came back: {hits:?}" ); } - assert!( - !workflow.lines().any(|line| { - line.split_whitespace() - .collect::>() - .windows(3) - .any(|tokens| tokens == ["cargo", "xwin", "build"]) - }), - "Windows MSVC commands must go through soldr build, not cargo-xwin directly" - ); - let release_workflow = fs::read_to_string(root.join(".github/workflows/release-auto.yml")).unwrap(); for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] {