Skip to content

feat: cross compile - #1434

Draft
xangcastle wants to merge 130 commits into
mainfrom
xangcastle/cross-sdist-macos
Draft

xangcastle wants to merge 130 commits into
mainfrom
xangcastle/cross-sdist-macos

Conversation

@xangcastle

Copy link
Copy Markdown
Member

Changes are visible to end-users: yes/no

  • Searched for relevant documentation and updated as needed: yes/no
  • Breaking change (forces users to change their own code or config): yes/no
  • Suggested release notes appear below: yes/no

Test plan

  • Covered by existing test cases
  • New test cases added
  • Manual testing; please provide instructions so we can reproduce:

xangcastle and others added 30 commits August 13, 2026 16:52
(cherry picked from commit 4869b33)
pycross-setuptools' native_wheels matrix duplicates
e2e/crossbuild/zstandard (same package, and crossbuild goes further:
QEMU execution plus a byte-for-byte diff against the official wheel).
Its marginal value — the cross path under e2e/cases' own CC toolchain
and shared-hub setup — lands as its own small PR after this one.
Recoverable from commit 2273078.
geohash was the workspace's first case and kept its pyproject/uv.lock
at the root under a bare pypi_crossbuild hub while every later case
adopted the one-dir-one-hub pattern. Move the pair into geohash/,
rename the hub to pypi_crossbuild_geohash, and align the project name
and dep_group with the crossbuild_<pkg> convention.
Port of rules_pycross's e2e suite (build_setuptools, patches_and_hooks,
build_pure_python and the distutils probe), relocated into the
crossbuild workspace.

(originally commit 4869b33, merged to
main squashed in #1413 / 05504ac; re-homed here with the content of
the original port)
Brings xangcastle/pep517-rust-toolchain's current state onto the spike:
rust_toolchain as a label down to sdist_build, inferred setuptools-rust
detection, RUST_SYSROOT derived and passed to rustc explicitly, the cargo
cross env keyed off the wired toolchain, and the pycross-rust-rs case on
rules_rs 0.0.110. The helper's own setuptools-rust sniffing and its tests
go: Starlark decides, the helper obeys.
…line

Cargo fetched its dependencies inside the build action, with whatever
network the sandbox allowed: not hermetic, not reproducible beyond the lock
the sdist happened to ship, and dead under remote execution. The
C-family backends never had this problem: their build deps come from the uv
lock and their toolchains are action inputs.

sdist_configure now reads the sdist's Cargo.lock (anywhere in the tree,
shallowest first: setuptools-rust keeps its crate under src/) and reports the
crates it pins. For a Rust build, sdist_build downloads each crates.io crate
through Bazel's downloader with the lock's checksum into a cargo vendor
directory, and the generated pep517_native_whl passes it to the helper, which
writes the source replacement into the sandbox CARGO_HOME and sets
CARGO_NET_OFFLINE. Locks pinning git or path sources are rejected outright.

Sdists without a Cargo.lock (tiktoken) build online with a warning, or take
one from uv.override_package(cargo_lock = ...): vendored the same way and
placed next to the crate's Cargo.toml before the build.

e2e/crossbuild now runs every sdist build with sandbox network blocked; all
five Rust cases (maturin on rules_rust and rules_rs, setuptools-rust, with
and without a shipped lock) pass offline.
…t-macos

# Conflicts:
#	e2e/cases/uv-deps-650/crossbuild/snapshots/app_amd64_layers_listing.yaml
#	e2e/cases/uv-deps-650/crossbuild/snapshots/app_arm64_layers_listing.yaml
Rust sdists (maturin, setuptools-rust) no longer need a `rust_toolchain`
attribute nor a rules_rust/rules_rs dependency: the `rust_tools` module
extension downloads rustc + cargo + rust-std for the host and rust-std
for every cross target from static.rust-lang.org, pinned by sha256, and
registers one `rust_pep517_toolchain` per host x target pair.

- uv/private/rust: versions (pins), repository rules, toolchain rule,
  extension; toolchain_type wired into a new `pep517_rust_whl` rule
  (factory over pep517_native_whl) that exports CARGO/RUSTC/sysroot/
  target std roots to the build helper.
- sdist_build emits `pep517_rust_whl` for Rust builds; setuptools-rust
  is only inferred under a setuptools backend (numpy ships meson's .rs
  test cases and must stay a meson build).
- build_helper merges host sysroot + target rust-std the way rustup
  lays them out, for both native and cross cargo builds.
- rust_layer.bzl, the `rust_toolchain` tag attr and the rules_rs e2e
  case are removed; e2e/crossbuild builds every Rust case offline.
The root and e2e/cases suites already run on Linux for every PR, and the
crossbuild cases are exercised by the darwin cross-build pipeline. The job
tripled macOS minutes without covering anything new.
…t-macos

# Conflicts:
#	.github/workflows/ci-workflows.yaml
#	e2e/README.md
#	e2e/crossbuild/MODULE.bazel
#	e2e/crossbuild/pycross-setuptools/BUILD.bazel
numpy's sdist vendors meson's test suite, .rs files included, so the
configure tool inferred setuptools-rust for it and sdist_build treated a
meson build as Rust: it demanded a rust_toolchain the project never needed.
setuptools-rust only ever rides on a setuptools backend (or a bare setup.py).
… with

Without a toolchain, cargo or maturin failed deep inside the build action
with no hint of the cause. sdist_build already knows at repository time that
the sdist builds Rust; it now fails there, naming uv.project(rust_toolchain)
and the reason (maturin backend, or setuptools-rust requirement). Hand-wired
uv.override_package(toolchains = [...]) entries are trusted as the escape
hatch. Docs say why the attribute exists at all: rules_py cannot resolve a
Rust toolchain itself without making rules_rust a dependency of every
consumer.
The inferred setuptools-rust requirement is a guess from stray .rs files:
zstandard ships an optional rust-ext it never builds by default, and the
fail-early check refused to generate its repository. A guess still wires
the project's toolchain when there is one, but never demands one; the build
proceeds unwired, as before, and fails only if the backend needs rustc.
… toolchain

State the design plainly: fetching rustc and cargo is the Rust rulesets' job
and rules_py depends on neither, so the project points uv.project at the
toolchain it registers. Give the rules_rust label and the rules_rs wiring the
e2e uses, and note that only declared Rust builds demand a toolchain.
pycross-rust, pycross-rust-rs and pycross-tiktoken get the pycross_wheel_export
from #1507, so CI installs and runs their cross-built wheels on native amd64
and arm64 runners like every other crossbuild case. The rules_rs case's test
file takes the bundle's name so the runner finds it.
…tically

`uv.project(rust_toolchain = "@rules_rust//rust/toolchain:current_rust_toolchain")`
declares the project's Rust toolchain once. sdist_build already knows each
sdist's build backend and declared build requirements from the configure
tool; when the backend is maturin, or setuptools-rust is among the
requirements, the generated pep517_native_whl gets the toolchain plus a
local rust_host_sysroot layer in `toolchains`. pep517_native_whl derives
CARGO/RUSTC/RULES_PY_RUST_HOST_SYSROOT from their make-variables, so no
per-package uv.override_package is needed for Rust.

rust_host_sysroot moves from e2e/crossbuild/tools into rules_py: it
re-resolves the toolchain in the exec configuration, because under a cross
transition current_rust_toolchain returns the target-targeting toolchain,
whose sysroot lacks the exec-platform std that build scripts and
proc-macros need. It reads platform_common.ToolchainInfo only, so rules_py
takes no dependency on rules_rust.

Requirement names are normalized from their PEP 508 spelling (extras,
specifiers and markers dropped): the configure tool reports build
requirements as declared, e.g. `setuptools-rust>=1.7`.

Inferred setuptools-rust counts too: the configure tool adds it for sdists
that ship .rs files without declaring it, and injects it into the build
venv, so the build needs the toolchain just the same. The inference is
limited to setuptools backends (or a bare setup.py); numpy vendors meson's
test suite, .rs files included, and stays a meson build.

The helper no longer re-derives "is this a Rust build" from pyproject.toml,
which would miss inferred builds: CARGO wired by the rule is the signal for
the cargo cross environment. rust_toolchain travels as a label, not a
string, so a relative or apparent-repo spelling resolves where the user
wrote it.

Tests: unittests for the detection and for the BUILD wiring it emits; an
analysis test with a fake toolchain proves rust_host_sysroot exports the
exec-configured sysroot; pycross-rust is the end-to-end consumer, now with
no override at all.
…ot explicitly

New e2e/crossbuild case pycross-rust-rs builds pydantic-core with rules_rs
toolchains: a native import and linux/amd64 + linux/arm64 cross builds
through uv.project(rust_toolchain = ...), mirroring pycross-rust. rules_rs
declares its toolchains with rules_rust's rust_toolchain rule inside the
patched rules_rust repository it provisions, so the same
current_rust_toolchain label and providers apply, and both rulesets coexist
in one workspace because each carries its own toolchain type. The case pins
that the wiring is a provider contract, not a dependency on one ruleset.

It also exposed a layout assumption: the helper ran rustc bare, which infers
its sysroot from its own location, and rules_rs fetches rustc and rust-std
into separate repositories (E0463: can't find crate for `std`). The rule
now derives RUST_SYSROOT, the sysroot rules_rust's toolchain assembles, and
the helper passes it: natively through a rustc wrapper (--sysroot), under
cross as the target side of the merged sysroot instead of dirname(rustc).

Tests: unit tests for the native cargo env and for the merge with a
rules_rs-style layout; an analysis test with a fake toolchain exporting
CARGO/RUSTC/RUST_SYSROOT asserts the derived env keys and that the
make-variable names do not leak.
…line

Cargo fetched its dependencies inside the build action, with whatever
network the sandbox allowed: not hermetic, not reproducible beyond the lock
the sdist happened to ship, and dead under remote execution. The C-family
backends never had this problem: their build deps come from the uv lock and
their toolchains are action inputs.

sdist_configure now reads the sdist's Cargo.lock (anywhere in the tree,
shallowest first: setuptools-rust keeps its crate under src/) and reports the
crates it pins. For a Rust build, sdist_build downloads each crates.io crate
through Bazel's downloader with the lock's checksum into a cargo vendor
directory, and the generated pep517_native_whl passes it to the helper, which
writes the source replacement into the sandbox CARGO_HOME and sets
CARGO_NET_OFFLINE. Locks pinning git or path sources are rejected outright.

Sdists without a Cargo.lock build online with a warning, or take one from
uv.override_package(cargo_lock = ...): vendored the same way and placed next
to the crate's Cargo.toml before the build. pycross-tiktoken (setuptools-rust,
no shipped lock) is that consumer.

e2e/crossbuild now runs every sdist build with sandbox network blocked; the
whole suite passes offline, Rust cases included.
Without a toolchain, cargo or maturin fail deep inside the build action
with no hint of the cause. sdist_build now fails while the repository is
generated, naming the attribute to set, when the sdist declares a Rust
build: a maturin backend, or setuptools-rust among its declared build
requirements. Explicit uv.override_package(toolchains = [...]) entries are
trusted as the by-hand escape hatch.

Inferred setuptools-rust (stray .rs files) is only a guess and never
demands a toolchain: zstandard ships an optional rust-ext it does not
build by default. A guess still wires the project's toolchain when there
is one; without one the build proceeds unwired, as before, and fails only
if the backend really needs rustc.
pycross-rust, pycross-rust-rs and pycross-tiktoken get the pycross_wheel_export
from #1507, so CI installs and runs their cross-built wheels on native amd64
and arm64 runners like every other crossbuild case. The rules_rs case's test
file takes the bundle's name so the runner finds it. The crossbuild README
lists the Rust cases alongside the other backends.
… toolchain

State the design plainly: fetching rustc and cargo is the Rust rulesets' job
and rules_py depends on neither, so the project points uv.project at the
toolchain it registers. Give the rules_rust label and the rules_rs wiring the
e2e uses, and note that only declared Rust builds demand a toolchain.
…n uv.project()

`uv.project()` goes back to hub, lock and pyproject. The toolchains the
module's sdists build with move to their own tag on the same extension,
resolved in _parse_projects next to override_package and annotate_packages
and producing the same sdist_build repositories:

    uv.package_toolchains(rust_toolchain = "@rules_rust//rust/toolchain:current_rust_toolchain")

Without `lock` the declaration covers every uv.project() of the module, one
line per MODULE.bazel instead of one per lock. With `lock` it applies to that
project only and wins over the module-wide one, which is how a workspace
builds one project on rules_rust and another on rules_rs. Two declarations
with the same scope, a lock without a uv.project(), and a tag naming no
toolchain fail with their own messages.

The attribute stays `rust_toolchain` rather than a generic list because
sdist_build has to know which toolchain is the Rust one to wire it only into
Rust builds with the exec-sysroot layer; other project-level toolchains get
their own attribute when needed. `cargo_lock` remains per package on
override_package: it is data for the offline crate vendoring, not a
toolchain.

e2e/crossbuild exercises the three scopes: pycross-rust declares the
module-wide rules_rust toolchain, pycross-rust-rs overrides it for its lock
with the rules_rs one, pycross-tiktoken inherits.
maturin 1.8+ fetches rustc through puccinialin when it finds no cargo, so a
Rust sdist with no toolchain wired, or one the setuptools-rust inference
left unwired, would build with a toolchain downloaded inside the action.
Every sdist build now carries MATURIN_NO_INSTALL_RUST=1; a package env entry
still wins.
…dists

The rustc wrapper only injected --sysroot, so a Rust wheel carried the
sandbox and execroot paths of the host that built it (debug info, panic
locations) and LLVM's per-host module ids in symbol names: the wheel action
itself hits the remote cache, but its bytes differ per host, so every
downstream action (whl_install, venvs) misses. The wrapper now remaps the
sandbox root and the execroot away and compiles target crates as a single
codegen unit; build scripts and proc-macros, which never reach the wheel,
keep cargo's codegen. Native builds treat every crate as target.

Crates with C or C++ sources (ring, zstd-sys) build them through cc-rs,
which looks up CC_<triple>, CXX_<triple>, AR_<triple> and RANLIB_<triple>
before falling back to the PATH; only CARGO_TARGET_<TRIPLE>_LINKER was set,
so those objects came from whatever compiler the runner had. Cross builds
now export the wired C toolchain under both spellings, with a ranlib
wrapper over `ar s` for toolchains that ship none. The linker stays the C
driver: linking with c++ would add an implicit libstdc++ dependency to
every Rust extension, not only the ones with C++ sources.

Tests run the generated wrapper around an argv-echoing rustc and check the
cc-rs variables end to end through the cross env.
The CycloneDX SBOM maturin writes into the wheel records every crate as
path+file:///<sandbox>/..., and the sandbox id differs per action, so two
builds of one sdist never produced the same wheel even with the rustc
wrapper remapping paths. maturin 1.15 has no flag or variable for it, only
the [tool.maturin.sbom] table, appended to the extracted pyproject.toml
unless the sdist configures it itself.
…toolchains

Two review asks on uv.package_toolchains(). A declaration can now name a
package, like uv.override_package(): `name` alone covers that package in
every project of the module, `name` with `lock` covers it in one project.
And the tag carries `toolchains`, the generic list uv.override_package()
forwards per package, so a JDK or Ant reach every source build of a module
or project with one declaration.

Each attribute resolves from the most specific declaration that sets it:
package in lock, package everywhere, lock, module. `rust_toolchain` stays
its own attribute because sdist_build wires it only into maturin and
setuptools-rust builds with the exec-sysroot layer, while `toolchains` is
forwarded as-is. A `name` that matches no source build fails, like an
uv.override_package() that matches nothing; two declarations with the same
scope fail.

pycross-jdk moves its JDK and Ant from the per-package override to a
lock-scoped declaration.
Cargo's `-C metadata=` hash, which rustc mangles into every symbol and
uses as the crate's stable id, mixes in `rustc -vV` (host triple included)
and the paths of host-compiled build scripts, so a wheel's `.so` differed
by build host even with paths remapped. The rustc wrapper now gives target
crates a hash derived from host-independent identity: the toolchain's
`rustc --version` string, `CARGO_PKG_NAME` and `CARGO_PKG_VERSION` from the
environment cargo hands rustc, the crate name, its types, cfgs, target and
the codegen options its profile sets. Two versions of one package hash
apart, and so do the two compilations a native build makes of one crate
under differing `build-override` and normal profiles. Path-carrying
options (`linker`, `link-arg`) are excluded; `-C extra-filename` stays
cargo's so artifact names never collide. Host crates keep cargo's hash:
they never reach the wheel.

The wrapper is exercised around an argv-echoing fake rustc: same hash from
two sandboxes, different across versions, features and toolchains, host
crates untouched, both `-C metadata=` spellings.
crossbuild/test.sh becomes the same aggregator cases/test.sh is: it runs
every `<case>/test.sh`, so a case that needs a real `bazel run`, the host's
identity or network the action sandbox denies owns its own script instead
of growing the workspace-level one. The macOS amd64 cross build of
pycross-geohash moves to pycross-geohash/test.sh.
Every sdist_build repository of a Rust sdist now carries a
`cargo_lock_generator` target. `bazel run` extracts the sdist, runs
`cargo generate-lockfile` on its shallowest manifest with the project's
own `rust_toolchain` (cargo and rustc from the toolchain's runfiles) and
copies the lock into the workspace: `<name>-<version>.Cargo.lock` at the
root, the first argument, or in place when `uv.override_package(cargo_lock
= ...)` already declares one. It prints the override to add. The warning
for an sdist that ships no Cargo.lock names the target.

The generated script is checked in an analysis test around a fake
toolchain exposing cargo and rustc as Files; the BUILD rendering and the
lock path derivation have unit tests; pycross-tiktoken/test.sh runs the
real target against crates.io and checks the result is a lock for that
crate. Regenerating tiktoken's declared lock reproduces the checked-in
file byte for byte.

docs: the generator, the symbol-hash normalization, and why rules_py
writes no PYO3_CONFIG_FILE (maturin writes its own in a cross build;
setuptools-rust derives the target from PYO3_CROSS_PYTHON_VERSION).
The Rust toolchain gets its own tag, `uv.rust_toolchain(toolchain = ...,
lock = ...)`, scoped to the module or to one project's lock; a lock-scoped
declaration wins for its project. Detection makes the wide scope precise:
sdist_build wires the toolchain only into builds whose backend is maturin
or setuptools-rust, so nothing else sees it.

Toolchains a single package needs beyond Rust (a JDK, Ant) stay where they
were before bc44642, on `uv.override_package(toolchains = ...)`. Their
files become action inputs of that package's build alone, instead of every
sdist built from source in the scope. The `name`-scoped variant and the
"matches no source build" check go away with the generic list; pycross-jdk
returns to its per-package override.

`cargo_lock` remains an override_package attribute: a lock pins one sdist
version's crates, a build input like pre_build_patches, not a toolchain.
…-sdist-macos

Brings #1526's final state onto the spike: uv.rust_toolchain(toolchain, lock)
replaces the spike's uv.project(rust_toolchain = ...), generic toolchains stay
per package on uv.override_package, offline crate vendoring with the
:cargo_lock generator, the reproducible rustc wrapper (remapped paths, one
codegen unit, host-independent -C metadata), cc-rs environment, maturin SBOM
off, and the per-case test.sh aggregator.

The spike's own helper work is replayed on top: pkg-config's python3 pointed
at the interpreter the wheel is built for, meson-python's build dir kept so
meson-log.txt survives a failed build, and static libstdc++ for GNU C++
links. pycross-bcrypt and pycross-rpds_py move to the module-wide
uv.rust_toolchain() declared in pycross-rust.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants