From b2e63684d3770ba703d51dda4d68eddaebbcbc02 Mon Sep 17 00:00:00 2001 From: zackees Date: Sat, 27 Jun 2026 18:11:43 -0700 Subject: [PATCH] ci: cross-compile aarch64-apple-darwin from Linux (drop macos-latest lane) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `macos-latest` runner for the `aarch64-apple-darwin` release lane with the existing `ubuntu-latest` runner already shared by every other target. The Linux runner uses soldr + cargo-zigbuild + soldr's managed Apple SDK to produce real Mach-O arm64 binaries — closing the last cross-platform-runner dependency in the release matrix. ## What landed * `.github/workflows/release-auto.yml` — `aarch64-apple-darwin` lane swaps `runner: macos-latest` for `runner: ubuntu-latest` plus the new `mac_cross_linux: true` flag. * `.github/workflows/template_native_build.yml` — adds the `mac_cross_linux: true` boolean input + the matching build / PyO3 / staging / strip branches. When set: - `soldr prepare --target aarch64-apple-darwin` fetches the Apple SDK ahead of cargo so the SDKROOT export is debuggable independently - `soldr cargo zigbuild --release --target aarch64-apple-darwin` builds `fbuild-cli` + `fbuild-daemon` - `PYO3_NO_PYTHON=1 soldr cargo zigbuild ...` builds the `fbuild-python` PyO3 extension with the same target-dir layout the macOS native lane produced (no rename needed in stage step) - strip step is a no-op (Linux's binutils `strip` would silently corrupt a Mach-O; the binaries ship unstripped at ~25 MB each) * `ci/docker-mac-arm64-cross/` — a minimal `ubuntu:24.04`-based docker image + `build.sh` that reproduces the GHA flow locally and asserts via `file(1)` that all three output artifacts are `Mach-O 64-bit ... arm64`. The "NO CHEATING" gate that locked the workflow design. ## How it was validated Local run inside the docker image, against the current tip of FastLED/fbuild#main: ``` staging/fbuild: Mach-O 64-bit arm64 executable staging/fbuild-daemon: Mach-O 64-bit arm64 executable staging/_native.abi3.so: Mach-O 64-bit arm64 dynamically linked shared library ``` Sizes: 23 MB / 25 MB / 14 MB — comparable to the macOS-native lane. ## Known soft warnings (non-blocking) * `rust-objcopy` fails to load `libLLVM.so` during the strip-debuginfo post-link step on aarch64-apple-darwin — cargo's `profile.release.strip` knob calls rust-objcopy which expects libLLVM next to the rustc binary. Cargo treats this as a warning and the binaries are produced correctly. soldr#934's `llvm-tools-preview` work covers this when the catalogue ingest cycle wires it up. * `soldr prepare` warns about a failed internal `rustup target add` — cosmetic, the explicit `soldr rustup target add` in build.sh already added the target. Tracked separately as a soldr bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release-auto.yml | 9 ++- .github/workflows/template_native_build.yml | 47 ++++++++++- ci/docker-mac-arm64-cross/Dockerfile | 86 +++++++++++++++++++++ ci/docker-mac-arm64-cross/README.md | 38 +++++++++ ci/docker-mac-arm64-cross/build.sh | 74 ++++++++++++++++++ 5 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 ci/docker-mac-arm64-cross/Dockerfile create mode 100644 ci/docker-mac-arm64-cross/README.md create mode 100755 ci/docker-mac-arm64-cross/build.sh diff --git a/.github/workflows/release-auto.yml b/.github/workflows/release-auto.yml index 1a748c61..2263705e 100644 --- a/.github/workflows/release-auto.yml +++ b/.github/workflows/release-auto.yml @@ -163,11 +163,17 @@ jobs: linux_cross: true macos_cross: false + # FastLED/fbuild#XXX (TBD): aarch64-apple-darwin now + # cross-compiles from the linux runner via soldr + cargo-zigbuild + # + soldr's managed Apple SDK. This drops one macos-latest + # runner-minute from every release and removes the only + # mac-host dependency from the matrix. - target: aarch64-apple-darwin - runner: macos-latest + runner: ubuntu-latest binary_ext: "" linux_cross: false macos_cross: false + mac_cross_linux: true - target: x86_64-pc-windows-msvc runner: windows-latest @@ -182,6 +188,7 @@ jobs: binary_ext: ${{ matrix.binary_ext }} linux_cross: ${{ matrix.linux_cross }} macos_cross: ${{ matrix.macos_cross }} + mac_cross_linux: ${{ matrix.mac_cross_linux == true }} ref: ${{ needs.prepare.outputs.release_ref }} publish: diff --git a/.github/workflows/template_native_build.yml b/.github/workflows/template_native_build.yml index 3fb419a3..b331fc73 100644 --- a/.github/workflows/template_native_build.yml +++ b/.github/workflows/template_native_build.yml @@ -26,6 +26,11 @@ on: type: boolean default: false description: "macOS x86_64 cross-compilation on ARM runner" + mac_cross_linux: + required: false + type: boolean + default: false + description: "Cross-compile macOS target from Linux via soldr + cargo-zigbuild + managed Apple SDK" ref: required: false type: string @@ -136,10 +141,25 @@ jobs: echo "PYO3_CROSS_PYTHON_VERSION=3.13" >> "$GITHUB_ENV" echo "PYO3_CROSS_PYTHON_IMPLEMENTATION=CPython" >> "$GITHUB_ENV" + # Linux → mac cross — soldr fetches the Apple SDK ahead of the + # cargo invocation so the SDKROOT export is debuggable independent + # of the build itself. + - name: Prepare Apple SDK (Linux → mac cross) + if: inputs.mac_cross_linux + shell: bash + run: soldr prepare --target ${{ inputs.target }} + - name: Build release binaries shell: bash run: | - if [ "${{ runner.os }}" = "Linux" ] && [[ "${{ inputs.target }}" == *-pc-windows-msvc ]]; then + if [ "${{ inputs.mac_cross_linux }}" = "true" ]; then + # Linux → aarch64-apple-darwin: zigbuild + soldr-managed SDK. + # Identical recipe to ci/docker-mac-arm64-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 cargo xwin build --release --target ${{ inputs.target }} \ -p fbuild-cli \ -p fbuild-daemon @@ -173,7 +193,19 @@ jobs: shell: bash run: | PYTHON_TARGET_DIR="target/python-extension" - if [ "${{ runner.os }}" = "Linux" ] && [[ "${{ inputs.target }}" == *-pc-windows-msvc ]]; then + 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 PYO3_NO_PYTHON=1 cargo xwin build --release \ --target-dir "${PYTHON_TARGET_DIR}" \ --target ${{ inputs.target }} -p fbuild-python \ @@ -220,6 +252,11 @@ jobs: "${PYTHON_TARGET_DIR}/release/_native.dll"; do [ -f "$ext_src" ] && cp "$ext_src" staging/_native.pyd && break done + elif [ "${{ inputs.mac_cross_linux }}" = "true" ]; then + # Linux → mac cross: dylib lands under the apple-darwin + # target dir (no manylinux floor, unlike the Linux PyO3 lane). + ext_src="${PYTHON_TARGET_DIR}/${{ inputs.target }}/release/lib_native.dylib" + [ -f "$ext_src" ] && cp "$ext_src" staging/_native.abi3.so elif [ "${{ runner.os }}" = "Linux" ]; then TARGET="${{ inputs.target }}" ARCH="${TARGET%%-*}" @@ -248,6 +285,12 @@ jobs: llvm-strip staging/fbuild || true llvm-strip staging/fbuild-daemon || true llvm-strip staging/_native.abi3.so 2>/dev/null || true + elif [ "${{ inputs.mac_cross_linux }}" = "true" ]; then + # Linux host `strip` is binutils — it would silently corrupt + # a Mach-O. Skip stripping; ~30 MB is acceptable for release + # artifacts. A future iteration can wire `llvm-strip` from + # ziglang's bundled tools. + true elif [[ "${{ inputs.target }}" == *-pc-windows-msvc ]]; then true elif command -v strip &> /dev/null; then diff --git a/ci/docker-mac-arm64-cross/Dockerfile b/ci/docker-mac-arm64-cross/Dockerfile new file mode 100644 index 00000000..8872b73e --- /dev/null +++ b/ci/docker-mac-arm64-cross/Dockerfile @@ -0,0 +1,86 @@ +# Simulate the FastLED/fbuild GitHub Actions release runner for the +# `aarch64-apple-darwin` lane on **Linux x86_64**, with no Apple-side +# tooling and no pre-installed Rust toolchain. Soldr bootstraps +# everything else. +# +# This is the "NO CHEATING" proof: we ship a vanilla Debian base + the +# absolute minimum apt deps, and soldr brings rustup, the pinned +# toolchain, zig, the Apple SDK, and `cargo-zigbuild` — exactly the +# story soldr#997 promised for cross-compile completeness. +# +# Build: +# docker build -f ci/docker-mac-arm64-cross/Dockerfile -t fbuild-mac-arm64-cross . +# +# Run (from repo root): +# docker run --rm -v "$PWD:/src" -w /src fbuild-mac-arm64-cross \ +# ./ci/docker-mac-arm64-cross/build.sh + +# ubuntu:24.04 is exactly what `ubuntu-latest` is on GitHub Actions +# right now (the runners moved off 22.04 in 2025-Q4). Picking it +# matches the constraint "simulate the GHA runner" verbatim and +# avoids glibc-floor friction: the published `soldr` linux-gnu +# binary requires glibc 2.39, which matches 24.04. Older bases +# (debian bookworm, ubuntu 22.04) would silently downgrade us to +# either the musl binary or a soldr that 404's its own libc. +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Minimum apt set — no rust, no zig, no clang. Soldr fetches all of those. +# curl + ca-certificates → soldr's HTTP fetches +# git → cargo fetch from git deps +# xz-utils + bzip2 → tarball extraction for various release archives +# python3 + python3-pip → soldr is shipped as a pip wheel +# python3-venv → uv venvs +# build-essential → cc/ld for any build script that needs them +# pkg-config → many *-sys crates poke pkg-config at build time +# file → final verification of the Mach-O output +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl git xz-utils bzip2 zstd \ + python3 python3-pip python3-venv \ + build-essential pkg-config file \ + && rm -rf /var/lib/apt/lists/* + +# uv for python ops (CLAUDE.md mandate). +RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ + && cp /root/.local/bin/uv /usr/local/bin/ \ + && uv venv --seed /opt/soldr-venv + +# Soldr binary tarball from GitHub Releases — the published soldr wheel +# is tagged `manylinux_2_39` (glibc 2.39 floor, ubuntu-24.04+) so a +# `pip install soldr` on Debian bookworm (glibc 2.36) silently falls back +# to an unrelated squatted `soldr-0.1.0` placeholder package. The tarball +# is built against glibc 2.17 (manylinux_2_17 floor) so it runs on every +# remotely-recent distro. Issue tracked at zackees/soldr#1005 (forthcoming). +ARG SOLDR_VERSION=0.7.59 +RUN mkdir -p /opt/soldr-bin \ + && curl -fsSL \ + "https://github.com/zackees/soldr/releases/download/v${SOLDR_VERSION}/soldr-v${SOLDR_VERSION}-x86_64-unknown-linux-gnu.tar.zst" \ + | zstd -d --stdout \ + | tar -xf - -C /opt/soldr-bin \ + && ls -la /opt/soldr-bin + +RUN for bin in soldr cargo-chef crgx zccache zccache-daemon zccache-fp; do \ + [ -f "/opt/soldr-bin/$bin" ] && cp "/opt/soldr-bin/$bin" "/usr/local/bin/$bin" && chmod +x "/usr/local/bin/$bin"; \ + done + +# Pre-seed soldr's home so its first-run setup is non-interactive + +# the rustup auto-bootstrap has a writable HOME to drop ~/.cargo/ into. +ENV HOME=/root +RUN soldr --version + +# cargo-zigbuild from PyPI — same approach fbuild's existing +# template_native_build.yml takes for its linux musl + linux PyO3 cross +# steps (see #331). Ships zig in the venv. +RUN /opt/soldr-venv/bin/pip install --no-cache-dir cargo-zigbuild ziglang \ + && ln -s /opt/soldr-venv/bin/cargo-zigbuild /usr/local/bin/cargo-zigbuild + +# Soldr-controlled defaults: +# - SOLDR_APPLE_SDK_VERSION pins the Apple SDK soldr fetches at +# `cargo zigbuild --target *-apple-darwin` time. +# - SOLDR_TRUST_MODE=permissive accepts unpinned managed downloads +# (the Phase A catalogue blobs are sha-pinned by soldr itself). +ENV SOLDR_APPLE_SDK_VERSION=11.3 \ + SOLDR_TRUST_MODE=permissive \ + CARGO_TERM_COLOR=always diff --git a/ci/docker-mac-arm64-cross/README.md b/ci/docker-mac-arm64-cross/README.md new file mode 100644 index 00000000..3e17c917 --- /dev/null +++ b/ci/docker-mac-arm64-cross/README.md @@ -0,0 +1,38 @@ +# `ci/docker-mac-arm64-cross/` — Linux → `aarch64-apple-darwin` simulator + +Reproduces fbuild's `aarch64-apple-darwin` release lane on **Linux x86_64** +with **no Apple-side tooling and no pre-installed Rust toolchain**. Soldr +bootstraps rustup, the pinned 1.94.1 channel, zig, the Apple SDK, and +`cargo-zigbuild` from a vanilla `ubuntu:24.04` base. + +This is the proof-of-concept that lets fbuild's release pipeline drop +its `macos-latest` runner for the mac-arm64 lane and replace it with the +same `ubuntu-latest` lane every other target already uses. + +## Build + run + +```bash +# From the fbuild repo root: +docker build -f ci/docker-mac-arm64-cross/Dockerfile -t fbuild-mac-arm64-cross . +docker run --rm -v "$PWD:/src" -w /src fbuild-mac-arm64-cross \ + bash ci/docker-mac-arm64-cross/build.sh +``` + +`build.sh` produces three artifacts under `$PWD/staging/` and asserts via +`file(1)` that each is a `Mach-O 64-bit arm64` binary — the +`NO CHEATING` gate. If anything regressed and we accidentally produced +the host Linux binary, `file` reports `ELF 64-bit LSB pie executable, +x86-64` and the script fails loudly. + +## Why `ubuntu:24.04` (not `debian:bookworm-slim`) + +Soldr's published Linux-gnu binary requires `glibc 2.39`. Debian +bookworm-slim ships `glibc 2.36`, so `soldr --version` 404's its own +libc and a `pip install soldr` falls back to a squatted `soldr-0.1.0` +placeholder on PyPI. `ubuntu:24.04` matches what GitHub Actions' +`ubuntu-latest` resolves to today, so this is also the most-faithful +GHA-runner simulation. + +The friction is tracked at zackees/soldr — Phase B-2 should lower the +soldr Linux-gnu binary to a `manylinux_2_17` floor so any distro from +the last ~5 years can run it directly. diff --git a/ci/docker-mac-arm64-cross/build.sh b/ci/docker-mac-arm64-cross/build.sh new file mode 100755 index 00000000..c689fb15 --- /dev/null +++ b/ci/docker-mac-arm64-cross/build.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run inside the `fbuild-mac-arm64-cross` docker image (see Dockerfile). +# Cross-compiles fbuild + fbuild-daemon + the PyO3 extension to +# aarch64-apple-darwin using soldr + cargo-zigbuild + soldr's Apple SDK. +# +# Output layout (in $PWD/staging): +# fbuild ← Mach-O 64-bit executable arm64 +# fbuild-daemon ← Mach-O 64-bit executable arm64 +# _native.abi3.so ← Mach-O 64-bit dylib arm64 (PyO3 extension) + +set -euo pipefail + +TARGET="aarch64-apple-darwin" +STAGING="${STAGING:-$PWD/staging}" +mkdir -p "$STAGING" + +echo "::group::soldr version + rust toolchain" +soldr --version +# `soldr toolchain ensure` installs rustup (if missing) + the pinned +# channel from rust-toolchain.toml + the requested target stdlib + the +# components soldr's bootstrap matrix needs. +soldr toolchain ensure +soldr rustup target add "$TARGET" +echo "::endgroup::" + +echo "::group::cargo-zigbuild + soldr-managed apple SDK" +which cargo-zigbuild +cargo-zigbuild --version +# Force a pre-fetch of the Apple SDK before the real build so a +# slow / failing SDK download is debuggable separately from the cargo +# build itself. +soldr prepare --target "$TARGET" +echo "::endgroup::" + +echo "::group::Build fbuild-cli + fbuild-daemon" +soldr cargo zigbuild --release --target "$TARGET" \ + -p fbuild-cli -p fbuild-daemon +echo "::endgroup::" + +echo "::group::Build fbuild-python PyO3 extension" +PYO3_NO_PYTHON=1 soldr cargo zigbuild --release \ + --target-dir target/python-extension \ + --target "$TARGET" -p fbuild-python \ + --features extension-module +echo "::endgroup::" + +echo "::group::Stage + verify artifacts" +cp "target/$TARGET/release/fbuild" "$STAGING/fbuild" +cp "target/$TARGET/release/fbuild-daemon" "$STAGING/fbuild-daemon" + +EXT_SRC="target/python-extension/$TARGET/release/lib_native.dylib" +if [ ! -f "$EXT_SRC" ]; then + echo "ERROR: PyO3 extension not found at $EXT_SRC" >&2 + find target/python-extension -name "lib_native.*" -o -name "_native.*" >&2 || true + exit 1 +fi +cp "$EXT_SRC" "$STAGING/_native.abi3.so" + +# Verify the output is actually a Mach-O ARM64 binary — this is the +# `NO CHEATING` gate. If file(1) reports anything other than +# `Mach-O 64-bit ... arm64` for all three artifacts, the cross-compile +# silently produced the host binary instead and we must fail loudly. +for f in fbuild fbuild-daemon _native.abi3.so; do + desc="$(file "$STAGING/$f")" + echo " $desc" + if ! echo "$desc" | grep -qE "Mach-O.*(arm64|aarch64)"; then + echo "ERROR: $f is not Mach-O arm64 — got: $desc" >&2 + exit 1 + fi +done +echo "::endgroup::" + +echo "All three artifacts are valid Mach-O arm64. Staging dir: $STAGING" +ls -lh "$STAGING"