From a56a25296efa0ce40347ca1b4a2898b70c856c4c Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Thu, 2 Jul 2026 00:02:22 +0300 Subject: [PATCH 01/17] CI/macos: native (non-Rosetta) x86_64 cross build on Apple Silicon Alternative to the Rosetta approach: build the Intel target with a NATIVE arm64 toolchain that cross-targets x86_64 (-arch x86_64), linking the x86_64 Homebrew at /usr/local. Compiles run on native arm64 clang (fast); only the output is x86_64. - ConfigureHomebrew.cmake: honor an explicit `-D HOMEBREW_PREFIX=` override (falls back to `brew --prefix`), so a native arm64 cmake can be pointed at the /usr/local x86_64 bottles. - build_source.sh: honor a caller-set NPROC to cap build parallelism. - macos/crossplatform-builds/README.md: documents the recipe, the critical gotchas (must force CMAKE_MAKE_PROGRAM to the arm64 ninja, else CMake's find_program grabs the x86_64 ninja from /usr/local and silently compiles under Rosetta; CMAKE_PREFIX_PATH=/usr/local for find_package; x86_64 python shim), and a native-vs-Rosetta comparison. Validated on an Apple M4: native arm64 clang (Code Type: ARM64) cross-built MeshLib core to x86_64; all binaries x86_64, linked against /usr/local x86_64 dylibs; 294/294 unit tests pass under Rosetta. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmake/Modules/ConfigureHomebrew.cmake | 26 ++++--- macos/crossplatform-builds/README.md | 102 ++++++++++++++++++++++++++ scripts/build_source.sh | 12 ++- 3 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 macos/crossplatform-builds/README.md diff --git a/cmake/Modules/ConfigureHomebrew.cmake b/cmake/Modules/ConfigureHomebrew.cmake index 923850d0ee70..24a6211c6af3 100644 --- a/cmake/Modules/ConfigureHomebrew.cmake +++ b/cmake/Modules/ConfigureHomebrew.cmake @@ -1,17 +1,21 @@ IF(APPLE) message("building for Apple") - execute_process( - COMMAND brew --prefix - RESULT_VARIABLE CMD_ERROR - OUTPUT_VARIABLE HOMEBREW_PREFIX - OUTPUT_STRIP_TRAILING_WHITESPACE - ) - IF(CMD_ERROR EQUAL 0 AND EXISTS "${HOMEBREW_PREFIX}") - message("Homebrew found. Prefix: ${HOMEBREW_PREFIX}") - ELSE() - message("Homebrew not found!") - message(FATAL_ERROR "${CMD_ERROR} ${HOMEBREW_PREFIX}") + # Allow an explicit Homebrew prefix override (e.g. -D HOMEBREW_PREFIX=/usr/local + # to link the x86_64 bottles when cross-building Intel on an arm64 host with a + # native toolchain). Falls back to `brew --prefix` for the common native case. + IF(NOT HOMEBREW_PREFIX) + execute_process( + COMMAND brew --prefix + RESULT_VARIABLE CMD_ERROR + OUTPUT_VARIABLE HOMEBREW_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + IF(NOT (CMD_ERROR EQUAL 0 AND EXISTS "${HOMEBREW_PREFIX}")) + message("Homebrew not found!") + message(FATAL_ERROR "${CMD_ERROR} ${HOMEBREW_PREFIX}") + ENDIF() ENDIF() + message("Homebrew prefix: ${HOMEBREW_PREFIX}") include_directories(${HOMEBREW_PREFIX}/include) link_directories(${HOMEBREW_PREFIX}/lib) diff --git a/macos/crossplatform-builds/README.md b/macos/crossplatform-builds/README.md new file mode 100644 index 000000000000..f3858ed968ec --- /dev/null +++ b/macos/crossplatform-builds/README.md @@ -0,0 +1,102 @@ +# macOS cross-platform builds — native (non-Rosetta) x86_64 on Apple Silicon + +Build the **Intel (`x86_64`)** macOS target of MeshLib on **Apple Silicon (`arm64`)** using a +**native arm64 toolchain that cross-targets x86_64** — the compiler runs natively (no Rosetta +translation of the build tools, so compiles are fast) and emits x86_64 code via `-arch x86_64`, +linking the x86_64 libraries from the `/usr/local` Homebrew. + +> This is the alternative to the "run everything under Rosetta" approach: there the whole +> toolchain (cmake/ninja/clang) runs as x86_64 under Rosetta, which is simpler but slower to +> compile. Here the toolchain stays native arm64 and only the *output* is x86_64. + +## How it works + +- **cmake / ninja / clang run natively as arm64** (fast). `CMAKE_OSX_ARCHITECTURES=x86_64` + makes AppleClang emit x86_64 objects; the resulting binaries run on Intel Macs, or on this + host under Rosetta. +- The x86_64 dependencies come from the **x86_64 Homebrew at `/usr/local`** (coexists with the + native arm64 Homebrew at `/opt/homebrew`). [`ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) + now honors an explicit `-D HOMEBREW_PREFIX=/usr/local` instead of always calling `brew --prefix`. + +## Critical gotchas (why the naive attempt silently falls back to Rosetta) + +1. **Force the arm64 ninja.** CMake's `find_program` searches `/usr/local/bin` *by default*, even + when it isn't on `PATH`, so it picks up the **x86_64** ninja from the Intel Homebrew — and an + x86_64 ninja spawns **x86_64 clang under Rosetta**, silently defeating the native build. Pass + `-D CMAKE_MAKE_PROGRAM=/opt/homebrew/bin/ninja`. (Verify with `vmmap | grep "Code Type"` + → must say `ARM64`, not `X86-64 (Translated)`.) +2. **Point find_package at `/usr/local`** with `-D CMAKE_PREFIX_PATH=/usr/local` so packages like + `Python`, OpenSSL, etc. resolve their x86_64 copies (not the arm64 `/opt/homebrew` ones). +3. **x86_64 Python vs native cmake PATH tension.** Native cmake/ninja want `/opt/homebrew` first + on `PATH`, but Python must be the x86_64 one from `/usr/local`. Resolve with a small shim dir + on `PATH` that maps `cmake`/`ninja` → `/opt/homebrew` (arm64) and `python3.10`/`python3.10-config` + → `/usr/local` (x86_64). +4. **`CMAKE_SYSTEM_PROCESSOR` stays `arm64`** (it reflects the *host*, since the cmake process is + native). This is harmless for MeshLib's own code (its SIMD is gated on target macros + `__x86_64__`/`__aarch64__`), and the heavy SIMD dependencies (OpenVDB, TBB, blosc) come from + x86_64 Homebrew *binaries* — not built from source here. Cosmetic side effect: `MR_PLATFORM` + is labelled `APPLE_arm64` for an x86_64 build. +5. Configure-time feature probes (`try_run` / `find_package(Python)` running the interpreter) + execute x86_64 test binaries, which the OS runs via Rosetta *transparently*. The bulk + compilation is native; only these brief configure probes touch Rosetta. Going fully + Rosetta-free would require a CMake toolchain file with `CMAKE_CROSSCOMPILING` + pre-seeded + `try_run` results. + +## What changed (vs upstream) + +| Change | File | +|---|---| +| Honor `-D HOMEBREW_PREFIX=` override (falls back to `brew --prefix`) | [`cmake/Modules/ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) | +| Honor a caller-set `NPROC` to cap build parallelism | [`scripts/build_source.sh`](../../scripts/build_source.sh) | + +Everything else is driven by CMake `-D` flags at invocation (below), so no other source changes +are required. + +## Recipe (Apple Silicon → native x86_64 cross build) + +Prerequisites: Rosetta 2 (only for *running* the resulting x86_64 binaries / configure probes) and +an x86_64 Homebrew bootstrapped at `/usr/local` with the `requirements/macos.txt` formulae. + +```bash +# shim: native arm64 cmake/ninja + x86_64 python on PATH +SHIM=/tmp/mlnative_bin; mkdir -p "$SHIM" +ln -sf /opt/homebrew/bin/cmake "$SHIM/cmake" +ln -sf /opt/homebrew/bin/ninja "$SHIM/ninja" +ln -sf /usr/local/bin/python3.10 "$SHIM/python3.10" +ln -sf /usr/local/bin/python3.10-config "$SHIM/python3.10-config" + +env -i HOME="$HOME" \ + PATH="$SHIM:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" \ + NPROC=5 MESHLIB_BUILD_RELEASE=ON MESHLIB_BUILD_DEBUG=OFF \ + CMAKE_C_COMPILER=/usr/bin/clang CMAKE_CXX_COMPILER=/usr/bin/clang++ \ + MR_CMAKE_OPTIONS="\ + -D CMAKE_MAKE_PROGRAM=/opt/homebrew/bin/ninja \ + -D HOMEBREW_PREFIX=/usr/local \ + -D CMAKE_PREFIX_PATH=/usr/local \ + -D CMAKE_OSX_ARCHITECTURES=x86_64 \ + -D MR_CXX_STANDARD=23 -D MR_PCH_USE_EXTRA_HEADERS=ON" \ + bash ./scripts/build_source.sh +``` + +The thirdparty-from-source libraries build the same way (native arm64 tools + the same `-D` flags); +their x86_64 output is bit-for-bit equivalent regardless of whether they were built natively or +under Rosetta. + +## Validation + +Validated on an Apple **M4** (macOS 15.7): MeshLib core built with a **native arm64 clang** +(confirmed `Code Type: ARM64` for the live compiler processes) cross-targeting x86_64; all +binaries are x86_64 and link the `/usr/local` x86_64 Homebrew dylibs; **294/294 unit tests pass** +(the x86_64 test binary runs under Rosetta). + +## Native vs Rosetta — which to use + +| | Rosetta (`…-rosetta` branch) | Native (this branch) | +|---|---|---| +| Toolchain | x86_64 under Rosetta | native arm64 | +| Compile speed | slower (translated clang) | **faster (native clang)** | +| CMake setup | just PATH + a couple env vars | more `-D` flags; ninja/find_program gotchas | +| `CMAKE_SYSTEM_PROCESSOR` | `x86_64` (SIMD paths correct for source builds) | `arm64` (cosmetic `MR_PLATFORM` mislabel) | +| Robustness | higher (fewer moving parts) | needs care (silent Rosetta fallback if ninja wrong) | + +Native wins on build speed; Rosetta wins on simplicity/robustness. Pick per priority. diff --git a/scripts/build_source.sh b/scripts/build_source.sh index d0ce87e65507..1d7748f90108 100755 --- a/scripts/build_source.sh +++ b/scripts/build_source.sh @@ -98,10 +98,14 @@ if [[ $OSTYPE == 'darwin'* ]]; then " fi -if [[ $OSTYPE == 'darwin'* ]]; then - NPROC=$(sysctl -n hw.logicalcpu) -else - NPROC=$(nproc) +# Respect a caller-provided NPROC (e.g. to cap parallelism / limit heat); +# otherwise default to all available cores. +if [ -z "${NPROC}" ]; then + if [[ $OSTYPE == 'darwin'* ]]; then + NPROC=$(sysctl -n hw.logicalcpu) + else + NPROC=$(nproc) + fi fi echo "The number of concurrent build threads NPROC=${NPROC}" From 0fe7bd85a08c17ba00048654da3e58098ced9461 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Tue, 14 Jul 2026 00:31:43 +0300 Subject: [PATCH 02/17] macos/crossplatform-builds: add native-build runner provisioning script Idempotent provisioning for the NATIVE (non-Rosetta) x86_64 cross build: 1. native arm64 cmake + ninja at /opt/homebrew (the build driver; installs only if missing, no force-upgrade) 2. Rosetta 2 (only to RUN the x86_64 output + CMake configure probes; the compilation itself is native) 3. x86_64 Homebrew at /usr/local (source of x86_64 bottles); --prewarm to install the formulae now, else CI installs them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crossplatform-builds/provision-runner.sh | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100755 macos/crossplatform-builds/provision-runner.sh diff --git a/macos/crossplatform-builds/provision-runner.sh b/macos/crossplatform-builds/provision-runner.sh new file mode 100755 index 000000000000..5d160d7603c2 --- /dev/null +++ b/macos/crossplatform-builds/provision-runner.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Provision a self-hosted arm64 macOS runner for the NATIVE (non-Rosetta) x86_64 +# cross build of MeshLib (see README.md). Idempotent -- safe to re-run. +# Run once per runner, from the repo root: +# ./macos/crossplatform-builds/provision-runner.sh [--prewarm] +# +# The native cross build uses a NATIVE arm64 toolchain (fast compiles) that +# cross-targets x86_64 and links the x86_64 Homebrew at /usr/local. +# +# Prerequisites: +# 1. native arm64 cmake + ninja (/opt/homebrew) -- the build driver (runs natively) +# 2. x86_64 Homebrew at /usr/local (+ formulae) -- the x86_64 libraries to link against +# 3. Rosetta 2 -- ONLY to run the resulting x86_64 test +# binaries and CMake configure-time +# probes; compilation itself is native. +# +# The runner must also carry the labels [self-hosted, macos, arm64, build]. +set -euo pipefail + +PREWARM=0 +[[ "${1:-}" == "--prewarm" ]] && PREWARM=1 + +if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + echo "Run on an arm64 macOS host (cross-builds x86_64)." >&2; exit 1 +fi +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +echo "==> 1/3 native arm64 toolchain (cmake + ninja @ /opt/homebrew)" +if [[ ! -x /opt/homebrew/bin/brew ]]; then + echo " ERROR: native arm64 Homebrew not found at /opt/homebrew." >&2 + echo " Install it first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" >&2 + exit 1 +fi +# Install only what's missing -- don't force-upgrade the runner's existing tools. +for t in cmake ninja; do + if [[ ! -x "/opt/homebrew/bin/$t" ]]; then + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ENV_HINTS=1 /opt/homebrew/bin/brew install --quiet "$t" + fi + case "$(file -b "/opt/homebrew/bin/$t" 2>/dev/null)" in + *arm64*) echo " $t: arm64 (native) ok" ;; + *) echo " WARNING: /opt/homebrew/bin/$t missing or not arm64" >&2 ;; + esac +done + +echo "==> 2/3 Rosetta 2 (to run x86_64 output + configure probes)" +if /usr/bin/pgrep -q oahd; then + echo " already installed" +else + softwareupdate --install-rosetta --agree-to-license +fi + +echo "==> 3/3 x86_64 Homebrew at /usr/local (source of x86_64 bottles)" +if [[ -x /usr/local/bin/brew ]]; then + echo " already present ($(arch -x86_64 /usr/local/bin/brew --version | head -1))" +else + echo " bootstrapping x86_64 Homebrew (will prompt for sudo)..." + arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +fi +if ! arch -x86_64 /usr/local/bin/brew config 2>/dev/null | grep -qi 'macOS:.*x86_64'; then + echo " WARNING: /usr/local brew does not report an x86_64 platform." >&2 +fi + +if [[ "$PREWARM" == "1" ]]; then + echo "==> x86_64 formulae pre-warm (optional; CI installs these anyway)" + CLANG_VER="$(xargs < "$REPO_ROOT/scripts/mrbind/clang_version_macos.txt")" + { cat "$REPO_ROOT/requirements/macos.txt"; printf '%s\n' pybind11 make grep lld "llvm@${CLANG_VER}"; } \ + | HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ENV_HINTS=1 \ + xargs arch -x86_64 /usr/local/bin/brew install --quiet +else + echo "==> formulae pre-warm skipped (pass --prewarm to install them now)" +fi + +echo "==> done. Native x86_64 cross build recipe: macos/crossplatform-builds/README.md" From ecda5ddf6b23d0a97cb615973a25051a142ff833 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Sat, 18 Jul 2026 01:43:13 +0300 Subject: [PATCH 03/17] macos: init crossplatform build --- .github/workflows/build-test-macos.yml | 60 ++++++++++++++++++++------ scripts/build_source.sh | 19 ++++++++ scripts/build_thirdparty.sh | 25 +++++++++-- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index e15eb2743f33..133085907689 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -68,6 +68,15 @@ jobs: c-compiler-template: BREW_PREFIX/opt/llvm@22/bin/clang runner: [ self-hosted, macos, arm64, build ] # any macos version instance: self-hosted-arm + - arch: x64-cross + config: Release + compiler: AppleClang + cxx-compiler-template: /usr/bin/clang++ + c-compiler-template: /usr/bin/clang + runner: [ self-hosted, macos, arm64, build ] + instance: self-hosted-arm-x64-native + cross-osx-arch: x86_64 + skip-bindings: true permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout @@ -99,6 +108,27 @@ jobs: # mrbind needs deps/cppdecl; recurse only there bash scripts/retry.sh -- git -C thirdparty/mrbind submodule update --init --depth 1 deps/cppdecl + - name: Configure native x86_64 cross-build environment + if: ${{ matrix.cross-osx-arch == 'x86_64' }} + run: | + SHIM="$RUNNER_TEMP/x86_64-cross-shim" + mkdir -p "$SHIM" + ln -sf /opt/homebrew/bin/cmake "$SHIM/cmake" + ln -sf /opt/homebrew/bin/ninja "$SHIM/ninja" + ln -sf /usr/local/bin/python3.10 "$SHIM/python3.10" + ln -sf /usr/local/bin/python3.10-config "$SHIM/python3.10-config" + # A symlink-to-a-symlink breaks Homebrew's self-location (it + # mis-resolves its own repo path); a wrapper that execs the real + # binary works. + printf '#!/bin/bash\nexec /usr/local/bin/brew "$@"\n' > "$SHIM/brew" + chmod +x "$SHIM/brew" + echo "$SHIM" >> "$GITHUB_PATH" + echo "CMAKE_OSX_ARCHITECTURES=${{ matrix.cross-osx-arch }}" >> "$GITHUB_ENV" + echo "CMAKE_MAKE_PROGRAM=/opt/homebrew/bin/ninja" >> "$GITHUB_ENV" + echo "CMAKE_PREFIX_PATH=/usr/local" >> "$GITHUB_ENV" + echo "MESHLIB_HOMEBREW_PREFIX=/usr/local" >> "$GITHUB_ENV" + echo "HOMEBREW_DIR=/usr/local" >> "$GITHUB_ENV" + - name: Install thirdparty libs id: thirdparty uses: ./.github/actions/install-macos-thirdparty @@ -120,14 +150,14 @@ jobs: build_config: ${{ matrix.config }} - name: Install MRBind deps - if: ${{ inputs.mrbind || inputs.mrbind_c }} + if: ${{ (inputs.mrbind || inputs.mrbind_c) && !matrix.skip-bindings }} env: HOMEBREW_NO_INSTALL_UPGRADE: '1' # don't upgrade an already-installed llvm/lld, no z3, no rebuild HOMEBREW_NO_INSTALL_CLEANUP: '1' # don't prune the known-good keg run: ./scripts/mrbind/install_deps_macos.sh - name: Build MRBind - if: ${{ inputs.mrbind || inputs.mrbind_c }} + if: ${{ (inputs.mrbind || inputs.mrbind_c) && !matrix.skip-bindings }} uses: ./.github/actions/build-mrbind with: # Keyed on the runner image (matrix.instance) plus the brew-prefix hash @@ -150,14 +180,14 @@ jobs: python3 -m pip install pytest - name: Download C bindings - if: ${{ inputs.mrbind_c }} + if: ${{ inputs.mrbind_c && !matrix.skip-bindings }} uses: actions/download-artifact@v8 with: name: CBindings path: MeshLib/CbindingsTmp - name: Prepare C bindings folders - if: ${{ inputs.mrbind_c }} + if: ${{ inputs.mrbind_c && !matrix.skip-bindings }} run: | rm -rf source/MeshLibC2 mv MeshLib/CbindingsTmp/MeshLibC2 source @@ -172,18 +202,21 @@ jobs: CMAKE_CXX_COMPILER: ${{ steps.thirdparty.outputs.cxx-compiler }} MR_VERSION: ${{ inputs.app_version }} MR_CMAKE_OPTIONS: > - -DMESHLIB_BUILD_MRMESH_PY_LEGACY=${{ fromJSON('["ON", "OFF"]')[inputs.mrbind] }} + -DMESHLIB_BUILD_MRMESH_PY_LEGACY=${{ (matrix.skip-bindings && 'OFF') || fromJSON('["ON", "OFF"]')[inputs.mrbind] }} -DMR_CXX_STANDARD=23 - -DMESHLIB_BUILD_GENERATED_C_BINDINGS=${{ fromJSON('["OFF", "ON"]')[inputs.mrbind_c] }} + -DMESHLIB_BUILD_GENERATED_C_BINDINGS=${{ (matrix.skip-bindings && 'OFF') || fromJSON('["OFF", "ON"]')[inputs.mrbind_c] }} -DMR_PCH_USE_EXTRA_HEADERS=ON - name: MRMesh Exported Symbols + # Needs llvm-cxxfilt from brewed llvm@22, which "Install MRBind deps" + # installs; skipped alongside bindings for skip-bindings entries + if: ${{ !matrix.skip-bindings }} run: | export PATH="$(brew --prefix llvm@22)/bin:$PATH" nm -gU ./build/${{ matrix.config }}/bin/libMRMesh.dylib | llvm-cxxfilt - name: Generate and build Python bindings - if: ${{ inputs.mrbind }} + if: ${{ inputs.mrbind && !matrix.skip-bindings }} env: PATH: ${{ steps.thirdparty.outputs.brew-prefix }}/opt/make/libexec/gnubin:${{ steps.thirdparty.outputs.brew-prefix }}/opt/grep/libexec/gnubin:${{env.PATH}} CXX: ${{ steps.thirdparty.outputs.cxx-compiler }} @@ -199,7 +232,7 @@ jobs: run: ./build/${{ matrix.config }}/bin/MeshViewer -tryHidden -noEventLoop -unloadPluginsAtEnd - name: Verify meshlib.mrmeshpy import - if: ${{ inputs.mrbind }} + if: ${{ inputs.mrbind && !matrix.skip-bindings }} timeout-minutes: 3 uses: ./.github/actions/verify-meshlib-python-import with: @@ -209,20 +242,21 @@ jobs: env: GTEST_OUTPUT: 'xml:unit_tests_report_gtest.xml' timeout-minutes: 10 - run: ./build/${{ matrix.config }}/bin/MRTest + run: ./build/${{ matrix.config }}/bin/MRTest${{ matrix.skip-bindings && ' --no-python-tests' || '' }} - name: C Unit Tests - if: ${{ inputs.mrbind_c }} + if: ${{ inputs.mrbind_c && !matrix.skip-bindings }} timeout-minutes: 10 run: ./build/${{ matrix.config }}/bin/MRTestC2 - name: Python Sanity Tests + if: ${{ !matrix.skip-bindings }} timeout-minutes: 8 working-directory: ./build/${{ matrix.config }}/bin run: python3 -u ./../../../scripts/run_python_test_script.py -d '../test_python' -a ' --junit-xml=../unit_tests_report_pytest.xml' - name: Python Regression Tests - if: ${{ inputs.internal_build }} + if: ${{ inputs.internal_build && !matrix.skip-bindings }} env: RUN_CUDA_ARG: "--run-cuda=negative --junit-xml=../unit_tests_report_regression.xml" uses: ./.github/actions/python-regression-tests @@ -303,7 +337,7 @@ jobs: stats_file_suffix: -${{ steps.collect-runner-stats.outputs.job_id }} - name: Create and fix fake Wheel for NuGet - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' && !matrix.skip-bindings }} shell: bash run: | python3 -m venv ./wheel_venv @@ -313,7 +347,7 @@ jobs: ./scripts/nuget_patch/fix_macos_rpath.sh ./patched_content/libMeshLibC2.dylib - name: Upload NuGet files to Artifacts - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' && !matrix.skip-bindings }} uses: actions/upload-artifact@v7 with: name: DotNetPatchArchiveMacOs-${{ matrix.arch }} diff --git a/scripts/build_source.sh b/scripts/build_source.sh index 1d7748f90108..615bf40d8805 100755 --- a/scripts/build_source.sh +++ b/scripts/build_source.sh @@ -37,6 +37,25 @@ fi # add env options to cmake MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS:-}" +# Cross-compilation knobs for building the x86_64 target on an arm64 macOS host +# with a NATIVE toolchain (no Rosetta). Each is a no-op when unset, so native +# and other-platform builds are unaffected. +# - CMAKE_OSX_ARCHITECTURES pins the produced object code arch. +# - CMAKE_MAKE_PROGRAM forces the native (arm64) ninja; otherwise CMake's +# find_program picks the x86_64 ninja under /usr/local +# and the compile silently runs under Rosetta. +# - MESHLIB_HOMEBREW_PREFIX points library discovery at the x86_64 Homebrew +# (passed to ConfigureHomebrew.cmake as HOMEBREW_PREFIX). +if [ -n "${CMAKE_OSX_ARCHITECTURES}" ]; then + MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D CMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES}" +fi +if [ -n "${CMAKE_MAKE_PROGRAM}" ]; then + MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D CMAKE_MAKE_PROGRAM=${CMAKE_MAKE_PROGRAM}" +fi +if [ -n "${MESHLIB_HOMEBREW_PREFIX}" ]; then + MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D HOMEBREW_PREFIX=${MESHLIB_HOMEBREW_PREFIX}" +fi + if command -v ninja >/dev/null 2>&1 ; then MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -G Ninja" fi diff --git a/scripts/build_thirdparty.sh b/scripts/build_thirdparty.sh index c04b7a37f305..39e965860cbf 100755 --- a/scripts/build_thirdparty.sh +++ b/scripts/build_thirdparty.sh @@ -57,6 +57,20 @@ MR_CMAKE_OPTIONS="\ -D CMAKE_BUILD_TYPE=Release \ " +# Cross-compilation knobs for building the x86_64 target on an arm64 macOS host +# with a native toolchain (no Rosetta). No-ops when unset. CMAKE_OSX_ARCHITECTURES +# pins the output arch; CMAKE_MAKE_PROGRAM forces the native (arm64) ninja (else +# CMake's find_program picks the x86_64 ninja under /usr/local and the compile runs +# under Rosetta). x86_64 dependencies are located via the CMAKE_PREFIX_PATH env var, +# which CMake reads automatically. These options also flow to the sub-builds +# (clip/fastmcpp) via CMAKE_OPTIONS below. +if [ -n "${CMAKE_OSX_ARCHITECTURES}" ]; then + MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D CMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES}" +fi +if [ -n "${CMAKE_MAKE_PROGRAM}" ]; then + MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D CMAKE_MAKE_PROGRAM=${CMAKE_MAKE_PROGRAM}" +fi + if [ "${MR_EMSCRIPTEN}" != "ON" ] ; then CMAKE_C_COMPILER="${CMAKE_C_COMPILER:-${CC}}" if [ -n "${CMAKE_C_COMPILER}" ] ; then @@ -120,10 +134,13 @@ if [ "${MR_EMSCRIPTEN}" == "ON" ]; then fi fi -if [[ $OSTYPE == 'darwin'* ]]; then - NPROC=$(sysctl -n hw.logicalcpu) -else - NPROC=$(nproc) +# Respect a caller-provided NPROC +if [ -z "${NPROC}" ]; then + if [[ $OSTYPE == 'darwin'* ]]; then + NPROC=$(sysctl -n hw.logicalcpu) + else + NPROC=$(nproc) + fi fi # build From 40212f4306a33b7f3c0daf75547236ebad73a7dd Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Tue, 28 Jul 2026 23:48:23 +0300 Subject: [PATCH 04/17] use separate label for macos crossplatform build --- .github/workflows/build-test-macos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index 133085907689..8427819308e8 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -73,7 +73,7 @@ jobs: compiler: AppleClang cxx-compiler-template: /usr/bin/clang++ c-compiler-template: /usr/bin/clang - runner: [ self-hosted, macos, arm64, build ] + runner: [ self-hosted, macos, arm64, crossplatform-build ] instance: self-hosted-arm-x64-native cross-osx-arch: x86_64 skip-bindings: true From e4b5b7f670a54a44a65d0bbc6d6624d266c3e59c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 1 Aug 2026 17:59:41 +0000 Subject: [PATCH 05/17] fix bindings setup --- .github/workflows/build-test-macos.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index 732fe56f0b50..1d4ebb26c05f 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -76,7 +76,7 @@ jobs: runner: [ self-hosted, macos, arm64, crossplatform-build ] instance: self-hosted-arm-x64-native cross-osx-arch: x86_64 - skip-bindings: true + skip-bindings: false permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout @@ -115,6 +115,7 @@ jobs: echo "CMAKE_PREFIX_PATH=/usr/local" >> "$GITHUB_ENV" echo "MESHLIB_HOMEBREW_PREFIX=/usr/local" >> "$GITHUB_ENV" echo "HOMEBREW_DIR=/usr/local" >> "$GITHUB_ENV" + echo "PKG_CONFIG_PATH=$(/usr/local/bin/brew --prefix python@3.10)/lib/pkgconfig" >> "$GITHUB_ENV" - name: Install thirdparty libs id: thirdparty @@ -293,7 +294,7 @@ jobs: --parallel $(sysctl -n hw.physicalcpu) - name: Build C examples - if: ${{ matrix.config == 'Release' }} + if: ${{ matrix.config == 'Release' && !matrix.skip-bindings }} env: CC: ${{ steps.thirdparty.outputs.c-compiler }} run: | From f55500f0884b3a4195d4ed896bc79210505133b5 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 1 Aug 2026 19:29:58 +0000 Subject: [PATCH 06/17] force thirdparties to rebuild --- .github/workflows/build-test-macos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index 1d4ebb26c05f..ccef02ee63a5 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -93,7 +93,7 @@ jobs: # Selective init -- parent Checkout drops submodules:true. # https://github.com/actions/checkout/issues/1779 # Retried via retry.sh: submodule endpoints occasionally 500. - bash scripts/retry.sh -- scripts/clone_submodules_linux.sh --skip-prebuilt-thirdparty + bash scripts/retry.sh -- scripts/clone_submodules_macos.sh - name: Configure native x86_64 cross-build environment if: ${{ matrix.cross-osx-arch == 'x86_64' }} From 28e9ea6477006943f8ec6b7651284cf28c6404ba Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Sun, 2 Aug 2026 22:32:02 +0000 Subject: [PATCH 07/17] test-distribution: add x64-cross macos distro test --- .github/workflows/test-distribution.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-distribution.yml b/.github/workflows/test-distribution.yml index 3d2927c6c69b..46694c733000 100644 --- a/.github/workflows/test-distribution.yml +++ b/.github/workflows/test-distribution.yml @@ -261,6 +261,9 @@ jobs: - arch: x64 runner: macos-26-intel pkg_pattern: '*x64.pkg' + - arch: x64-cross + runner: macos-15-intel + pkg_pattern: '*x64-cross.pkg' steps: - name: Checkout uses: actions/checkout@v7 From b021c6301526cb0bbd9aeb4a40f1bb70d5a16967 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:19:18 +0000 Subject: [PATCH 08/17] cmake: validate overridden Homebrew prefix on Apple --- cmake/Modules/ConfigureHomebrew.cmake | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cmake/Modules/ConfigureHomebrew.cmake b/cmake/Modules/ConfigureHomebrew.cmake index 24a6211c6af3..fca385ea8ac7 100644 --- a/cmake/Modules/ConfigureHomebrew.cmake +++ b/cmake/Modules/ConfigureHomebrew.cmake @@ -1,19 +1,18 @@ IF(APPLE) message("building for Apple") # Allow an explicit Homebrew prefix override (e.g. -D HOMEBREW_PREFIX=/usr/local - # to link the x86_64 bottles when cross-building Intel on an arm64 host with a - # native toolchain). Falls back to `brew --prefix` for the common native case. + # to link the x86_64 bottles when cross-building Intel on an arm64 host). + # Falls back to `brew --prefix` for the common native case. IF(NOT HOMEBREW_PREFIX) execute_process( COMMAND brew --prefix - RESULT_VARIABLE CMD_ERROR OUTPUT_VARIABLE HOMEBREW_PREFIX OUTPUT_STRIP_TRAILING_WHITESPACE ) - IF(NOT (CMD_ERROR EQUAL 0 AND EXISTS "${HOMEBREW_PREFIX}")) - message("Homebrew not found!") - message(FATAL_ERROR "${CMD_ERROR} ${HOMEBREW_PREFIX}") - ENDIF() + ENDIF() + # Validate whichever prefix we ended up with (auto-detected or overridden). + IF(NOT EXISTS "${HOMEBREW_PREFIX}") + message(FATAL_ERROR "Homebrew prefix not found: '${HOMEBREW_PREFIX}'") ENDIF() message("Homebrew prefix: ${HOMEBREW_PREFIX}") From cb25a81721e6786c987e8b37cd71cbb1fcbcc3a3 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:19:18 +0000 Subject: [PATCH 09/17] macos: key thirdparty cache on target arch --- .github/actions/install-macos-thirdparty/action.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/install-macos-thirdparty/action.yml b/.github/actions/install-macos-thirdparty/action.yml index 7a68a3978afa..dc98b98e1177 100644 --- a/.github/actions/install-macos-thirdparty/action.yml +++ b/.github/actions/install-macos-thirdparty/action.yml @@ -104,6 +104,8 @@ runs: echo "brew-hash=$(printf %s "$BREW_PREFIX" | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" echo "thirdparty-hash=$( { printf '%s\n' "$BREW_PREFIX" + # Discriminate the cross (x86_64) outputs from a same-instance native build. + if [ -n "${CMAKE_OSX_ARCHITECTURES}" ]; then printf 'osx-arch=%s\n' "${CMAKE_OSX_ARCHITECTURES}"; fi git ls-tree HEAD \ thirdparty/googletest \ thirdparty/OpenCTM-git \ From 6965c1a3dd44d35dbc641aa155e13c04a813792e Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:19:19 +0000 Subject: [PATCH 10/17] macos: trim cross-build knob comment blocks --- scripts/build_source.sh | 11 ++--------- scripts/build_thirdparty.sh | 9 ++------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/scripts/build_source.sh b/scripts/build_source.sh index 46bcf80f1e74..59a9ec6982e6 100755 --- a/scripts/build_source.sh +++ b/scripts/build_source.sh @@ -39,15 +39,8 @@ MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS:-}" # Extra flags for `cmake --build`. MR_CMAKE_BUILD_OPTIONS="${MR_CMAKE_BUILD_OPTIONS:-}" -# Cross-compilation knobs for building the x86_64 target on an arm64 macOS host -# with a NATIVE toolchain (no Rosetta). Each is a no-op when unset, so native -# and other-platform builds are unaffected. -# - CMAKE_OSX_ARCHITECTURES pins the produced object code arch. -# - CMAKE_MAKE_PROGRAM forces the native (arm64) ninja; otherwise CMake's -# find_program picks the x86_64 ninja under /usr/local -# and the compile silently runs under Rosetta. -# - MESHLIB_HOMEBREW_PREFIX points library discovery at the x86_64 Homebrew -# (passed to ConfigureHomebrew.cmake as HOMEBREW_PREFIX). +# Cross-compilation knobs for building x86_64 on an arm64 macOS host with a native +# toolchain (no-ops when unset). See macos/crossplatform-builds/README.md. if [ -n "${CMAKE_OSX_ARCHITECTURES}" ]; then MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D CMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES}" fi diff --git a/scripts/build_thirdparty.sh b/scripts/build_thirdparty.sh index e86c263553b3..7ede5b3aca0e 100755 --- a/scripts/build_thirdparty.sh +++ b/scripts/build_thirdparty.sh @@ -57,13 +57,8 @@ MR_CMAKE_OPTIONS="\ -D CMAKE_BUILD_TYPE=Release \ " -# Cross-compilation knobs for building the x86_64 target on an arm64 macOS host -# with a native toolchain (no Rosetta). No-ops when unset. CMAKE_OSX_ARCHITECTURES -# pins the output arch; CMAKE_MAKE_PROGRAM forces the native (arm64) ninja (else -# CMake's find_program picks the x86_64 ninja under /usr/local and the compile runs -# under Rosetta). x86_64 dependencies are located via the CMAKE_PREFIX_PATH env var, -# which CMake reads automatically. These options also flow to the sub-builds -# (clip/fastmcpp) via CMAKE_OPTIONS below. +# Cross-compilation knobs for building x86_64 on an arm64 macOS host with a native +# toolchain (no-ops when unset). See macos/crossplatform-builds/README.md. if [ -n "${CMAKE_OSX_ARCHITECTURES}" ]; then MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} -D CMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES}" fi From 095148e7f4e7eac21059d7b64fce6d4c157783b9 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:19:19 +0000 Subject: [PATCH 11/17] macos: cross x64 ships full bindings; fix platform label, assert arch, harden env --- .github/workflows/build-test-macos.yml | 68 +++++++++++++++----------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index ccef02ee63a5..6eb4ba8282a6 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -76,7 +76,6 @@ jobs: runner: [ self-hosted, macos, arm64, crossplatform-build ] instance: self-hosted-arm-x64-native cross-osx-arch: x86_64 - skip-bindings: false permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout @@ -98,24 +97,36 @@ jobs: - name: Configure native x86_64 cross-build environment if: ${{ matrix.cross-osx-arch == 'x86_64' }} run: | + PYVER=3.10 SHIM="$RUNNER_TEMP/x86_64-cross-shim" mkdir -p "$SHIM" - ln -sf /opt/homebrew/bin/cmake "$SHIM/cmake" - ln -sf /opt/homebrew/bin/ninja "$SHIM/ninja" - ln -sf /usr/local/bin/python3.10 "$SHIM/python3.10" - ln -sf /usr/local/bin/python3.10-config "$SHIM/python3.10-config" - # A symlink-to-a-symlink breaks Homebrew's self-location (it - # mis-resolves its own repo path); a wrapper that execs the real - # binary works. + # The native arm64 Homebrew prefix varies across the self-hosted fleet + # (/opt/homebrew, ~/.homebrew, ...); find it by its arm64 cmake so build + # tools run natively, not the x86_64 copies under /usr/local (which would + # drag clang into Rosetta). Fail loudly rather than dangle shim symlinks. + ARM_BREW="" + for p in /opt/homebrew "$HOME/.homebrew"; do + if file -b "$p/bin/cmake" 2>/dev/null | grep -q arm64; then ARM_BREW="$p"; break; fi + done + [ -n "$ARM_BREW" ] || { echo "::error::x86_64 cross setup: no arm64 Homebrew cmake found"; exit 1; } + for t in "$ARM_BREW/bin/cmake" "$ARM_BREW/bin/ninja" \ + "/usr/local/bin/python$PYVER" "/usr/local/bin/python$PYVER-config" /usr/local/bin/brew; do + [ -x "$t" ] || { echo "::error::x86_64 cross setup: missing $t"; exit 1; } + done + ln -sf "$ARM_BREW/bin/cmake" "$SHIM/cmake" + ln -sf "$ARM_BREW/bin/ninja" "$SHIM/ninja" + ln -sf "/usr/local/bin/python$PYVER" "$SHIM/python$PYVER" + ln -sf "/usr/local/bin/python$PYVER-config" "$SHIM/python$PYVER-config" + # A symlink-to-a-symlink breaks Homebrew's self-location, so wrap brew. printf '#!/bin/bash\nexec /usr/local/bin/brew "$@"\n' > "$SHIM/brew" chmod +x "$SHIM/brew" echo "$SHIM" >> "$GITHUB_PATH" echo "CMAKE_OSX_ARCHITECTURES=${{ matrix.cross-osx-arch }}" >> "$GITHUB_ENV" - echo "CMAKE_MAKE_PROGRAM=/opt/homebrew/bin/ninja" >> "$GITHUB_ENV" + echo "CMAKE_MAKE_PROGRAM=$ARM_BREW/bin/ninja" >> "$GITHUB_ENV" echo "CMAKE_PREFIX_PATH=/usr/local" >> "$GITHUB_ENV" echo "MESHLIB_HOMEBREW_PREFIX=/usr/local" >> "$GITHUB_ENV" echo "HOMEBREW_DIR=/usr/local" >> "$GITHUB_ENV" - echo "PKG_CONFIG_PATH=$(/usr/local/bin/brew --prefix python@3.10)/lib/pkgconfig" >> "$GITHUB_ENV" + echo "PKG_CONFIG_PATH=$(/usr/local/bin/brew --prefix python@$PYVER)/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" >> "$GITHUB_ENV" - name: Install thirdparty libs id: thirdparty @@ -138,14 +149,14 @@ jobs: build_config: ${{ matrix.config }} - name: Install MRBind deps - if: ${{ (inputs.mrbind || inputs.mrbind_c) && !matrix.skip-bindings }} + if: ${{ inputs.mrbind || inputs.mrbind_c }} env: HOMEBREW_NO_INSTALL_UPGRADE: '1' # don't upgrade an already-installed llvm/lld, no z3, no rebuild HOMEBREW_NO_INSTALL_CLEANUP: '1' # don't prune the known-good keg run: ./scripts/mrbind/install_deps_macos.sh - name: Build MRBind - if: ${{ (inputs.mrbind || inputs.mrbind_c) && !matrix.skip-bindings }} + if: ${{ inputs.mrbind || inputs.mrbind_c }} uses: ./.github/actions/build-mrbind with: # Keyed on the runner image (matrix.instance) plus the brew-prefix hash @@ -174,14 +185,14 @@ jobs: job-name: generate-c-bindings - name: Download C bindings - if: ${{ inputs.mrbind_c && !matrix.skip-bindings }} + if: ${{ inputs.mrbind_c }} uses: actions/download-artifact@v8 with: name: CBindings path: MeshLib/CbindingsTmp - name: Prepare C bindings folders - if: ${{ inputs.mrbind_c && !matrix.skip-bindings }} + if: ${{ inputs.mrbind_c }} run: | rm -rf source/MeshLibC2 mv MeshLib/CbindingsTmp/MeshLibC2 source @@ -196,21 +207,23 @@ jobs: CMAKE_CXX_COMPILER: ${{ steps.thirdparty.outputs.cxx-compiler }} MR_VERSION: ${{ inputs.app_version }} MR_CMAKE_OPTIONS: > - -DMESHLIB_BUILD_MRMESH_PY_LEGACY=${{ (matrix.skip-bindings && 'OFF') || fromJSON('["ON", "OFF"]')[inputs.mrbind] }} + -DMESHLIB_BUILD_MRMESH_PY_LEGACY=${{ fromJSON('["ON", "OFF"]')[inputs.mrbind] }} -DMR_CXX_STANDARD=23 - -DMESHLIB_BUILD_GENERATED_C_BINDINGS=${{ (matrix.skip-bindings && 'OFF') || fromJSON('["OFF", "ON"]')[inputs.mrbind_c] }} + -DMESHLIB_BUILD_GENERATED_C_BINDINGS=${{ fromJSON('["OFF", "ON"]')[inputs.mrbind_c] }} -DMR_PCH_USE_EXTRA_HEADERS=ON + ${{ matrix.cross-osx-arch == 'x86_64' && '-DMR_PLATFORM=APPLE_x86_64' || '' }} + + - name: Verify x86_64 output + if: ${{ matrix.cross-osx-arch == 'x86_64' }} + run: lipo -archs build/${{ matrix.config }}/bin/libMRMesh.dylib | grep -qx x86_64 - name: MRMesh Exported Symbols - # Needs llvm-cxxfilt from brewed llvm@22, which "Install MRBind deps" - # installs; skipped alongside bindings for skip-bindings entries - if: ${{ !matrix.skip-bindings }} run: | export PATH="$(brew --prefix llvm@22)/bin:$PATH" nm -gU ./build/${{ matrix.config }}/bin/libMRMesh.dylib | llvm-cxxfilt - name: Generate and build Python bindings - if: ${{ inputs.mrbind && !matrix.skip-bindings }} + if: ${{ inputs.mrbind }} env: PATH: ${{ steps.thirdparty.outputs.brew-prefix }}/opt/make/libexec/gnubin:${{ steps.thirdparty.outputs.brew-prefix }}/opt/grep/libexec/gnubin:${{env.PATH}} CXX: ${{ steps.thirdparty.outputs.cxx-compiler }} @@ -226,7 +239,7 @@ jobs: run: ./build/${{ matrix.config }}/bin/MeshViewer -tryHidden -noEventLoop -unloadPluginsAtEnd - name: Verify meshlib.mrmeshpy import - if: ${{ inputs.mrbind && !matrix.skip-bindings }} + if: ${{ inputs.mrbind }} timeout-minutes: 3 uses: ./.github/actions/verify-meshlib-python-import with: @@ -236,21 +249,20 @@ jobs: env: GTEST_OUTPUT: 'xml:unit_tests_report_gtest.xml' timeout-minutes: 10 - run: ./build/${{ matrix.config }}/bin/MRTest${{ matrix.skip-bindings && ' --no-python-tests' || '' }} + run: ./build/${{ matrix.config }}/bin/MRTest - name: C Unit Tests - if: ${{ inputs.mrbind_c && !matrix.skip-bindings }} + if: ${{ inputs.mrbind_c }} timeout-minutes: 10 run: ./build/${{ matrix.config }}/bin/MRTestC2 - name: Python Sanity Tests - if: ${{ !matrix.skip-bindings }} timeout-minutes: 8 working-directory: ./build/${{ matrix.config }}/bin run: python3 -u ./../../../scripts/run_python_test_script.py -d '../test_python' -a ' --junit-xml=../unit_tests_report_pytest.xml' - name: Python Regression Tests - if: ${{ inputs.internal_build && !matrix.skip-bindings }} + if: ${{ inputs.internal_build }} env: RUN_CUDA_ARG: "--run-cuda=negative --junit-xml=../unit_tests_report_regression.xml" uses: ./.github/actions/python-regression-tests @@ -294,7 +306,7 @@ jobs: --parallel $(sysctl -n hw.physicalcpu) - name: Build C examples - if: ${{ matrix.config == 'Release' && !matrix.skip-bindings }} + if: ${{ matrix.config == 'Release' }} env: CC: ${{ steps.thirdparty.outputs.c-compiler }} run: | @@ -331,7 +343,7 @@ jobs: stats_file_suffix: -${{ steps.collect-runner-stats.outputs.job_id }} - name: Create and fix fake Wheel for NuGet - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' && !matrix.skip-bindings }} + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} shell: bash run: | python3 -m venv ./wheel_venv @@ -341,7 +353,7 @@ jobs: ./scripts/nuget_patch/fix_macos_rpath.sh ./patched_content/libMeshLibC2.dylib - name: Upload NuGet files to Artifacts - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' && !matrix.skip-bindings }} + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} uses: actions/upload-artifact@v7 with: name: DotNetPatchArchiveMacOs-${{ matrix.arch }} From 26d7266e0c3f71d82915631d4d0dcaf5f1d93a40 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:26:11 +0000 Subject: [PATCH 12/17] macos: gate native x86_64 cross build in its own skippable job --- .github/workflows/build-test-distribute.yml | 1 + .github/workflows/build-test-macos.yml | 305 ++++++++++++++++++++ .github/workflows/config.yml | 4 + 3 files changed, 310 insertions(+) diff --git a/.github/workflows/build-test-distribute.yml b/.github/workflows/build-test-distribute.yml index c257b23e2634..46fe37250bb5 100644 --- a/.github/workflows/build-test-distribute.yml +++ b/.github/workflows/build-test-distribute.yml @@ -151,6 +151,7 @@ jobs: upload_artifacts: ${{ needs.config.outputs.upload_artifacts == 'true' }} upload_test_artifacts: ${{ needs.config.outputs.upload_test_artifacts == 'true' }} nuget_build_patch: ${{ needs.config.outputs.build_enable_windows == 'true' && needs.config.outputs.upload_artifacts == 'true'}} + build_macos_crossplatform: ${{ needs.config.outputs.build_enable_macos_crossplatform == 'true' }} secrets: inherit update-win-version: diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index 6eb4ba8282a6..48919755d57e 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -31,6 +31,13 @@ on: default: false required: false type: boolean + build_macos_crossplatform: + # Gates the self-hosted native-x86_64 cross job. When false it is skipped + # (never scheduled), so a busy/down crossplatform-build runner can't hang + # the rest of macOS CI to the job timeout. + default: true + required: false + type: boolean jobs: macos-build-test: @@ -68,6 +75,304 @@ jobs: c-compiler-template: BREW_PREFIX/opt/llvm@22/bin/clang runner: [ self-hosted, macos, arm64, build ] # any macos version instance: self-hosted-arm + permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + actions: read # This is required for wait-for-job + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: macOS Diagnostics + uses: ./.github/actions/macos-diagnostics + + - name: Checkout third-party submodules + run: | + # Selective init -- parent Checkout drops submodules:true. + # https://github.com/actions/checkout/issues/1779 + # Retried via retry.sh: submodule endpoints occasionally 500. + bash scripts/retry.sh -- scripts/clone_submodules_macos.sh + + - name: Configure native x86_64 cross-build environment + if: ${{ matrix.cross-osx-arch == 'x86_64' }} + run: | + PYVER=3.10 + SHIM="$RUNNER_TEMP/x86_64-cross-shim" + mkdir -p "$SHIM" + # The native arm64 Homebrew prefix varies across the self-hosted fleet + # (/opt/homebrew, ~/.homebrew, ...); find it by its arm64 cmake so build + # tools run natively, not the x86_64 copies under /usr/local (which would + # drag clang into Rosetta). Fail loudly rather than dangle shim symlinks. + ARM_BREW="" + for p in /opt/homebrew "$HOME/.homebrew"; do + if file -b "$p/bin/cmake" 2>/dev/null | grep -q arm64; then ARM_BREW="$p"; break; fi + done + [ -n "$ARM_BREW" ] || { echo "::error::x86_64 cross setup: no arm64 Homebrew cmake found"; exit 1; } + for t in "$ARM_BREW/bin/cmake" "$ARM_BREW/bin/ninja" \ + "/usr/local/bin/python$PYVER" "/usr/local/bin/python$PYVER-config" /usr/local/bin/brew; do + [ -x "$t" ] || { echo "::error::x86_64 cross setup: missing $t"; exit 1; } + done + ln -sf "$ARM_BREW/bin/cmake" "$SHIM/cmake" + ln -sf "$ARM_BREW/bin/ninja" "$SHIM/ninja" + ln -sf "/usr/local/bin/python$PYVER" "$SHIM/python$PYVER" + ln -sf "/usr/local/bin/python$PYVER-config" "$SHIM/python$PYVER-config" + # A symlink-to-a-symlink breaks Homebrew's self-location, so wrap brew. + printf '#!/bin/bash\nexec /usr/local/bin/brew "$@"\n' > "$SHIM/brew" + chmod +x "$SHIM/brew" + echo "$SHIM" >> "$GITHUB_PATH" + echo "CMAKE_OSX_ARCHITECTURES=${{ matrix.cross-osx-arch }}" >> "$GITHUB_ENV" + echo "CMAKE_MAKE_PROGRAM=$ARM_BREW/bin/ninja" >> "$GITHUB_ENV" + echo "CMAKE_PREFIX_PATH=/usr/local" >> "$GITHUB_ENV" + echo "MESHLIB_HOMEBREW_PREFIX=/usr/local" >> "$GITHUB_ENV" + echo "HOMEBREW_DIR=/usr/local" >> "$GITHUB_ENV" + echo "PKG_CONFIG_PATH=$(/usr/local/bin/brew --prefix python@$PYVER)/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" >> "$GITHUB_ENV" + + - name: Install thirdparty libs + id: thirdparty + uses: ./.github/actions/install-macos-thirdparty + with: + cache-instance: ${{ matrix.instance }} + cache-compiler: ${{ matrix.compiler }} + cxx-compiler-template: ${{ matrix.cxx-compiler-template }} + c-compiler-template: ${{ matrix.c-compiler-template }} + + - name: Collect runner's system stats + if: ${{ inputs.internal_build }} + id: collect-runner-stats + continue-on-error: true + uses: ./.github/actions/collect-runner-stats + with: + target_os: macos + target_arch: ${{ matrix.arch }} + cxx_compiler: ${{ steps.thirdparty.outputs.cxx-compiler }} + build_config: ${{ matrix.config }} + + - name: Install MRBind deps + if: ${{ inputs.mrbind || inputs.mrbind_c }} + env: + HOMEBREW_NO_INSTALL_UPGRADE: '1' # don't upgrade an already-installed llvm/lld, no z3, no rebuild + HOMEBREW_NO_INSTALL_CLEANUP: '1' # don't prune the known-good keg + run: ./scripts/mrbind/install_deps_macos.sh + + - name: Build MRBind + if: ${{ inputs.mrbind || inputs.mrbind_c }} + uses: ./.github/actions/build-mrbind + with: + # Keyed on the runner image (matrix.instance) plus the brew-prefix hash + # to discriminate between fleet members of the self-hosted ARM label set. + # Shared with pip-build.yml's macOS section where the two also match. + cache-key-prefix: ${{ matrix.instance }}-clang + build-script: scripts/mrbind/install_mrbind_macos.sh + extra-cache-key: ${{ hashFiles('scripts/mrbind/clang_version_macos.txt') }}-${{ steps.thirdparty.outputs.brew-hash }} + + - name: Create virtualenv + run: | + python3.10 -m venv .venv + . .venv/bin/activate + echo PATH=$PATH >> $GITHUB_ENV + + - name: Setup python requirements + run: | + curl -sS https://bootstrap.pypa.io/get-pip.py | python3 + python3 -m pip install -r ./requirements/python/requirements.txt + python3 -m pip install pytest + + - name: Wait for C bindings + if: ${{ inputs.mrbind_c }} + uses: ./.github/actions/wait-for-job + with: + job-name: generate-c-bindings + + - name: Download C bindings + if: ${{ inputs.mrbind_c }} + uses: actions/download-artifact@v8 + with: + name: CBindings + path: MeshLib/CbindingsTmp + + - name: Prepare C bindings folders + if: ${{ inputs.mrbind_c }} + run: | + rm -rf source/MeshLibC2 + mv MeshLib/CbindingsTmp/MeshLibC2 source + # Cuda placeholders: + cp -R scripts/mrbind/cuda_placeholder_generated_c/{include,src} source/MeshLibC2Cuda + + - name: Build + run: ./scripts/build_source.sh + env: + MESHLIB_BUILD_RELEASE: ${{ fromJSON('["OFF", "ON"]')[matrix.config == 'Release'] }} + MESHLIB_BUILD_DEBUG: ${{ fromJSON('["OFF", "ON"]')[matrix.config == 'Debug'] }} + CMAKE_CXX_COMPILER: ${{ steps.thirdparty.outputs.cxx-compiler }} + MR_VERSION: ${{ inputs.app_version }} + MR_CMAKE_OPTIONS: > + -DMESHLIB_BUILD_MRMESH_PY_LEGACY=${{ fromJSON('["ON", "OFF"]')[inputs.mrbind] }} + -DMR_CXX_STANDARD=23 + -DMESHLIB_BUILD_GENERATED_C_BINDINGS=${{ fromJSON('["OFF", "ON"]')[inputs.mrbind_c] }} + -DMR_PCH_USE_EXTRA_HEADERS=ON + ${{ matrix.cross-osx-arch == 'x86_64' && '-DMR_PLATFORM=APPLE_x86_64' || '' }} + + - name: Verify x86_64 output + if: ${{ matrix.cross-osx-arch == 'x86_64' }} + run: lipo -archs build/${{ matrix.config }}/bin/libMRMesh.dylib | grep -qx x86_64 + + - name: MRMesh Exported Symbols + run: | + export PATH="$(brew --prefix llvm@22)/bin:$PATH" + nm -gU ./build/${{ matrix.config }}/bin/libMRMesh.dylib | llvm-cxxfilt + + - name: Generate and build Python bindings + if: ${{ inputs.mrbind }} + env: + PATH: ${{ steps.thirdparty.outputs.brew-prefix }}/opt/make/libexec/gnubin:${{ steps.thirdparty.outputs.brew-prefix }}/opt/grep/libexec/gnubin:${{env.PATH}} + CXX: ${{ steps.thirdparty.outputs.cxx-compiler }} + run: | + make --version + make -f scripts/mrbind/generate.mk \ + -B --trace \ + PYTHON_PKGCONF_NAME=python-3.10-embed \ + MESHLIB_SHLIB_DIR=build/${{matrix.config}}/bin + + - name: Run Start-and-Exit Tests + timeout-minutes: 3 + run: ./build/${{ matrix.config }}/bin/MeshViewer -tryHidden -noEventLoop -unloadPluginsAtEnd + + - name: Verify meshlib.mrmeshpy import + if: ${{ inputs.mrbind }} + timeout-minutes: 3 + uses: ./.github/actions/verify-meshlib-python-import + with: + build-bin-dir: ./build/${{ matrix.config }}/bin + + - name: Unit Tests + env: + GTEST_OUTPUT: 'xml:unit_tests_report_gtest.xml' + timeout-minutes: 10 + run: ./build/${{ matrix.config }}/bin/MRTest + + - name: C Unit Tests + if: ${{ inputs.mrbind_c }} + timeout-minutes: 10 + run: ./build/${{ matrix.config }}/bin/MRTestC2 + + - name: Python Sanity Tests + timeout-minutes: 8 + working-directory: ./build/${{ matrix.config }}/bin + run: python3 -u ./../../../scripts/run_python_test_script.py -d '../test_python' -a ' --junit-xml=../unit_tests_report_pytest.xml' + + - name: Python Regression Tests + if: ${{ inputs.internal_build }} + env: + RUN_CUDA_ARG: "--run-cuda=negative --junit-xml=../unit_tests_report_regression.xml" + uses: ./.github/actions/python-regression-tests + with: + build_config: ${{ matrix.config }} + smoke: ${{ !inputs.full_config_build && matrix.config == 'Debug' }} + test_artifacts_path: macos/${{ matrix.arch }} + upload_test_artifacts: ${{ inputs.upload_test_artifacts }} + + - name: Generate Test Performance Report + continue-on-error: true + run: | + scripts/junit_to_csv.py unit_tests_report.csv unit_tests_report_*.xml + cat unit_tests_report.csv + + - name: Create Pkg + if: ${{ matrix.config == 'Release' }} + run: | + ./scripts/distribution_apple.sh ${{ inputs.app_version }} + mv MeshLib_.pkg meshlib_${{matrix.arch}}.pkg + + - name: Extract Pkg + if: ${{ matrix.config == 'Release' }} + run: | + # https://gist.github.com/ugultopu/1adf8e08acb87be649d69419cf7aca3c + pkgutil --expand meshlib_${{ matrix.arch }}.pkg ./meshlib_install + cd ./meshlib_install/MeshLib.pkg + cat Payload | gunzip | cpio -i + + - name: Build C++ examples + if: ${{ matrix.config == 'Release' }} + env: + CXX: ${{ steps.thirdparty.outputs.cxx-compiler }} + run: | + cmake \ + -S examples/cpp-examples \ + -B cpp-examples-build \ + -D CMAKE_FRAMEWORK_PATH=$(pwd)/meshlib_install/MeshLib.pkg/Frameworks/ + cmake \ + --build cpp-examples-build \ + --parallel $(sysctl -n hw.physicalcpu) + + - name: Build C examples + if: ${{ matrix.config == 'Release' }} + env: + CC: ${{ steps.thirdparty.outputs.c-compiler }} + run: | + cmake \ + -S examples/c-examples \ + -B c-examples-build \ + -D CMAKE_FRAMEWORK_PATH=$(pwd)/meshlib_install/MeshLib.pkg/Frameworks/ + cmake \ + --build c-examples-build \ + --parallel $(sysctl -n hw.physicalcpu) + + - name: Upload Macos Distribution + if: ${{ inputs.upload_artifacts && matrix.config == 'Release' }} + env: + ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS: 1800000 + uses: actions/upload-artifact@v7 + with: + name: Distributives_macos-${{matrix.arch}} + path: meshlib_${{matrix.arch}}.pkg + retention-days: 1 + overwrite: true + + - name: Collect artifact stats + # Mirror `Upload Macos Distribution`'s gate -- otherwise Debug jobs + # invoke this step with no matching .pkg and the action fails (with + # `continue-on-error: true` the job still succeeds, but a confusing + # `failure`-level annotation is surfaced on the run summary). + if: ${{ inputs.internal_build && inputs.upload_artifacts && matrix.config == 'Release' }} + continue-on-error: true + uses: ./.github/actions/collect-artifact-stats + with: + artifact_path: ${{ github.workspace }} + artifact_glob: meshlib_${{matrix.arch}}.pkg + stats_file_suffix: -${{ steps.collect-runner-stats.outputs.job_id }} + + - name: Create and fix fake Wheel for NuGet + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} + shell: bash + run: | + python3 -m venv ./wheel_venv + source ./wheel_venv/bin/activate + python3 -m pip install delocate==0.10.7 + python3 ./scripts/nuget_patch/patch_library_deps.py ./patched_content/ ./build/Release/bin/lib{MeshLibC2,MeshLibC2Cuda}.dylib + ./scripts/nuget_patch/fix_macos_rpath.sh ./patched_content/libMeshLibC2.dylib + + - name: Upload NuGet files to Artifacts + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} + uses: actions/upload-artifact@v7 + with: + name: DotNetPatchArchiveMacOs-${{ matrix.arch }} + path: ./patched_content/* + retention-days: 1 + overwrite: true + + # Duplicate of macos-build-test for the single native x86_64 cross config, split + # into its own job so it can be gated (skipped when the crossplatform-build + # runner is unavailable) without hanging the hosted macOS legs. When the cross + # build replaces the hosted x64 leg, that entry is dropped from macos-build-test + # above and this job becomes the sole x64 producer. Keep the steps in sync. + macos-build-test-crossplatform: + if: ${{ inputs.build_macos_crossplatform }} + timeout-minutes: 100 + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: - arch: x64-cross config: Release compiler: AppleClang diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index bc6bc9433b54..8c5df2d3b2bf 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -75,6 +75,9 @@ on: build_enable_macos: description: value: ${{ !( jobs.prepare-config.outputs.tag-update-doc-only == 'true' || jobs.prepare-config.outputs.tag-disable-macos == 'true' ) }} + build_enable_macos_crossplatform: + description: "macOS native x86_64 cross job; also off whenever macOS is disabled" + value: ${{ !( jobs.prepare-config.outputs.tag-update-doc-only == 'true' || jobs.prepare-config.outputs.tag-disable-macos == 'true' || jobs.prepare-config.outputs.tag-disable-macos-crossplatform == 'true' ) }} build_enable_emscripten: description: value: ${{ !( jobs.prepare-config.outputs.tag-update-doc-only == 'true' || jobs.prepare-config.outputs.tag-disable-emscripten == 'true' ) }} @@ -109,6 +112,7 @@ jobs: tag-disable-ubuntu-arm64: ${{ steps.live-labels.outputs.tag-disable-ubuntu-arm64 }} tag-disable-linux-vcpkg: ${{ steps.live-labels.outputs.tag-disable-linux-vcpkg }} tag-disable-macos: ${{ steps.live-labels.outputs.tag-disable-macos }} + tag-disable-macos-crossplatform: ${{ steps.live-labels.outputs.tag-disable-macos-crossplatform }} tag-disable-emscripten: ${{ steps.live-labels.outputs.tag-disable-emscripten }} runs-on: ubuntu-latest From ddcec5a576a92a3751a0f8162eb960c01934c3e8 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:27:31 +0000 Subject: [PATCH 13/17] macos/crossplatform: fix runner label, sudo Rosetta install, document brew arch --- macos/crossplatform-builds/provision-runner.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/crossplatform-builds/provision-runner.sh b/macos/crossplatform-builds/provision-runner.sh index 5d160d7603c2..42a137856a82 100755 --- a/macos/crossplatform-builds/provision-runner.sh +++ b/macos/crossplatform-builds/provision-runner.sh @@ -14,7 +14,7 @@ # binaries and CMake configure-time # probes; compilation itself is native. # -# The runner must also carry the labels [self-hosted, macos, arm64, build]. +# The runner must also carry the labels [self-hosted, macos, arm64, crossplatform-build]. set -euo pipefail PREWARM=0 @@ -46,10 +46,14 @@ echo "==> 2/3 Rosetta 2 (to run x86_64 output + configure probes)" if /usr/bin/pgrep -q oahd; then echo " already installed" else - softwareupdate --install-rosetta --agree-to-license + # Needs root; run under sudo so `set -e` doesn't abort on a Rosetta-less host. + sudo softwareupdate --install-rosetta --agree-to-license fi echo "==> 3/3 x86_64 Homebrew at /usr/local (source of x86_64 bottles)" +# `arch -x86_64` below is only needed to BOOTSTRAP Intel brew; once installed, +# /usr/local brew operates on its Intel prefix regardless of the invoking arch, +# so CI's native `exec /usr/local/bin/brew` (the cross-build shim) is equivalent. if [[ -x /usr/local/bin/brew ]]; then echo " already present ($(arch -x86_64 /usr/local/bin/brew --version | head -1))" else From 598f55d9cf3dea46c442e13d675c7454ef6f9df3 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Mon, 3 Aug 2026 18:29:39 +0000 Subject: [PATCH 14/17] macos/crossplatform: rewrite README as maintenance docs --- macos/crossplatform-builds/README.md | 153 +++++++++++++-------------- 1 file changed, 71 insertions(+), 82 deletions(-) diff --git a/macos/crossplatform-builds/README.md b/macos/crossplatform-builds/README.md index f3858ed968ec..6e67c56c91f7 100644 --- a/macos/crossplatform-builds/README.md +++ b/macos/crossplatform-builds/README.md @@ -1,102 +1,91 @@ -# macOS cross-platform builds — native (non-Rosetta) x86_64 on Apple Silicon - -Build the **Intel (`x86_64`)** macOS target of MeshLib on **Apple Silicon (`arm64`)** using a -**native arm64 toolchain that cross-targets x86_64** — the compiler runs natively (no Rosetta -translation of the build tools, so compiles are fast) and emits x86_64 code via `-arch x86_64`, -linking the x86_64 libraries from the `/usr/local` Homebrew. - -> This is the alternative to the "run everything under Rosetta" approach: there the whole -> toolchain (cmake/ninja/clang) runs as x86_64 under Rosetta, which is simpler but slower to -> compile. Here the toolchain stays native arm64 and only the *output* is x86_64. +# macOS Intel (x86_64) cross build on Apple Silicon + +Builds the **Intel (`x86_64`)** macOS target of MeshLib on an **Apple Silicon (`arm64`)** self-hosted +runner, using a **native arm64 toolchain that cross-targets x86_64**: cmake/ninja/clang run natively +(fast compiles) and emit x86_64 via `-arch x86_64`, linking the x86_64 Homebrew at `/usr/local`. The +binaries run on Intel Macs — and on the build host under Rosetta, which is how CI runs their tests. + +## Where it lives in CI + +- Job `macos-build-test-crossplatform` in + [`build-test-macos.yml`](../../.github/workflows/build-test-macos.yml) — a separate, gated copy of + the macOS build/test steps for the single `x64-cross` config. +- Gated by `build_enable_macos_crossplatform` (in [`config.yml`](../../.github/workflows/config.yml)): + on by default. Add the `disable-macos-crossplatform` PR label to skip just this job (e.g. when the + self-hosted runner is down) so it can't hang the hosted macOS legs; `disable-macos` skips all macOS. +- Runs on a runner labelled `[self-hosted, macos, arm64, crossplatform-build]`, provisioned by + [`provision-runner.sh`](provision-runner.sh). +- Produces `meshlib_x64-cross.pkg`; [`test-distribution.yml`](../../.github/workflows/test-distribution.yml) + installs and smoke-tests it on a real Intel Mac. + +> **Intent:** once proven, this replaces the GitHub-hosted `macos-15-intel` x64 leg (Intel runners are +> being retired). Until then both run, and both `.pkg`s are published (the cross one suffixed +> `-x64-cross`). ## How it works -- **cmake / ninja / clang run natively as arm64** (fast). `CMAKE_OSX_ARCHITECTURES=x86_64` - makes AppleClang emit x86_64 objects; the resulting binaries run on Intel Macs, or on this - host under Rosetta. -- The x86_64 dependencies come from the **x86_64 Homebrew at `/usr/local`** (coexists with the - native arm64 Homebrew at `/opt/homebrew`). [`ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) - now honors an explicit `-D HOMEBREW_PREFIX=/usr/local` instead of always calling `brew --prefix`. - -## Critical gotchas (why the naive attempt silently falls back to Rosetta) - -1. **Force the arm64 ninja.** CMake's `find_program` searches `/usr/local/bin` *by default*, even - when it isn't on `PATH`, so it picks up the **x86_64** ninja from the Intel Homebrew — and an - x86_64 ninja spawns **x86_64 clang under Rosetta**, silently defeating the native build. Pass - `-D CMAKE_MAKE_PROGRAM=/opt/homebrew/bin/ninja`. (Verify with `vmmap | grep "Code Type"` - → must say `ARM64`, not `X86-64 (Translated)`.) -2. **Point find_package at `/usr/local`** with `-D CMAKE_PREFIX_PATH=/usr/local` so packages like - `Python`, OpenSSL, etc. resolve their x86_64 copies (not the arm64 `/opt/homebrew` ones). -3. **x86_64 Python vs native cmake PATH tension.** Native cmake/ninja want `/opt/homebrew` first - on `PATH`, but Python must be the x86_64 one from `/usr/local`. Resolve with a small shim dir - on `PATH` that maps `cmake`/`ninja` → `/opt/homebrew` (arm64) and `python3.10`/`python3.10-config` - → `/usr/local` (x86_64). -4. **`CMAKE_SYSTEM_PROCESSOR` stays `arm64`** (it reflects the *host*, since the cmake process is - native). This is harmless for MeshLib's own code (its SIMD is gated on target macros - `__x86_64__`/`__aarch64__`), and the heavy SIMD dependencies (OpenVDB, TBB, blosc) come from - x86_64 Homebrew *binaries* — not built from source here. Cosmetic side effect: `MR_PLATFORM` - is labelled `APPLE_arm64` for an x86_64 build. -5. Configure-time feature probes (`try_run` / `find_package(Python)` running the interpreter) - execute x86_64 test binaries, which the OS runs via Rosetta *transparently*. The bulk - compilation is native; only these brief configure probes touch Rosetta. Going fully - Rosetta-free would require a CMake toolchain file with `CMAKE_CROSSCOMPILING` + pre-seeded - `try_run` results. - -## What changed (vs upstream) - -| Change | File | -|---|---| -| Honor `-D HOMEBREW_PREFIX=` override (falls back to `brew --prefix`) | [`cmake/Modules/ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) | -| Honor a caller-set `NPROC` to cap build parallelism | [`scripts/build_source.sh`](../../scripts/build_source.sh) | - -Everything else is driven by CMake `-D` flags at invocation (below), so no other source changes -are required. - -## Recipe (Apple Silicon → native x86_64 cross build) - -Prerequisites: Rosetta 2 (only for *running* the resulting x86_64 binaries / configure probes) and -an x86_64 Homebrew bootstrapped at `/usr/local` with the `requirements/macos.txt` formulae. +- **cmake / ninja / clang run natively as arm64.** `CMAKE_OSX_ARCHITECTURES=x86_64` makes AppleClang + emit x86_64 objects. `-D MR_PLATFORM=APPLE_x86_64` labels the binary correctly — otherwise it + inherits the host's `CMAKE_SYSTEM_PROCESSOR` (`arm64`) and ships a wrong platform string. +- x86_64 dependencies come from the **x86_64 Homebrew at `/usr/local`** (coexisting with the native + arm64 Homebrew). [`ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) honors + `-D HOMEBREW_PREFIX=/usr/local`. + +## Critical gotchas (why a naive attempt silently falls back to Rosetta) + +1. **Force the arm64 ninja.** CMake's `find_program` searches `/usr/local/bin` by default and picks up + the **x86_64** ninja, which spawns **x86_64 clang under Rosetta** — silently defeating the native + build. Pass `-D CMAKE_MAKE_PROGRAM=/bin/ninja`. Verify with + `vmmap | grep "Code Type"` → must say `ARM64`, not `X86-64 (Translated)`. +2. **Point find_package at `/usr/local`** with `-D CMAKE_PREFIX_PATH=/usr/local` so Python, OpenSSL, + etc. resolve their x86_64 copies. +3. **x86_64 Python vs native cmake PATH tension.** Resolve with a small PATH shim mapping + `cmake`/`ninja` → the arm64 brew and `python3.10*` → `/usr/local` (x86_64). See the + "Configure native x86_64 cross-build environment" step. +4. **`CMAKE_SYSTEM_PROCESSOR` stays `arm64`** (it reflects the host, since cmake is native). Harmless + for MeshLib's own SIMD (gated on the target macros `__x86_64__`/`__aarch64__`); `MR_PLATFORM` is + set explicitly to compensate for the label. +5. Configure-time `try_run` probes execute x86_64 test binaries, which the OS runs via Rosetta + transparently. Only these brief probes touch Rosetta; the bulk compilation is native. + +## Provisioning a runner + +Run [`provision-runner.sh`](provision-runner.sh) once per host (see its header for prerequisites). It +ensures a native arm64 Homebrew (cmake + ninja), Rosetta 2, and an x86_64 Homebrew at `/usr/local` +with the `requirements/macos.txt` formulae (`--prewarm` also installs the binding-generation deps). + +## Reproducing locally ```bash -# shim: native arm64 cmake/ninja + x86_64 python on PATH -SHIM=/tmp/mlnative_bin; mkdir -p "$SHIM" -ln -sf /opt/homebrew/bin/cmake "$SHIM/cmake" -ln -sf /opt/homebrew/bin/ninja "$SHIM/ninja" -ln -sf /usr/local/bin/python3.10 "$SHIM/python3.10" +SHIM=$(mktemp -d) +ln -sf "$(brew --prefix)/bin/cmake" "$SHIM/cmake" # native arm64 cmake/ninja +ln -sf "$(brew --prefix)/bin/ninja" "$SHIM/ninja" +ln -sf /usr/local/bin/python3.10 "$SHIM/python3.10" # x86_64 Python ln -sf /usr/local/bin/python3.10-config "$SHIM/python3.10-config" env -i HOME="$HOME" \ - PATH="$SHIM:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" \ - NPROC=5 MESHLIB_BUILD_RELEASE=ON MESHLIB_BUILD_DEBUG=OFF \ + PATH="$SHIM:$(brew --prefix)/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + MESHLIB_BUILD_RELEASE=ON MESHLIB_BUILD_DEBUG=OFF \ CMAKE_C_COMPILER=/usr/bin/clang CMAKE_CXX_COMPILER=/usr/bin/clang++ \ MR_CMAKE_OPTIONS="\ - -D CMAKE_MAKE_PROGRAM=/opt/homebrew/bin/ninja \ + -D CMAKE_MAKE_PROGRAM=$(brew --prefix)/bin/ninja \ -D HOMEBREW_PREFIX=/usr/local \ -D CMAKE_PREFIX_PATH=/usr/local \ -D CMAKE_OSX_ARCHITECTURES=x86_64 \ + -D MR_PLATFORM=APPLE_x86_64 \ -D MR_CXX_STANDARD=23 -D MR_PCH_USE_EXTRA_HEADERS=ON" \ bash ./scripts/build_source.sh ``` -The thirdparty-from-source libraries build the same way (native arm64 tools + the same `-D` flags); -their x86_64 output is bit-for-bit equivalent regardless of whether they were built natively or -under Rosetta. - -## Validation +Confirm the output arch with `lipo -archs build/Release/bin/libMRMesh.dylib` → `x86_64` (CI asserts +this). The thirdparty-from-source libraries build the same way (native tools + the same `-D` flags). -Validated on an Apple **M4** (macOS 15.7): MeshLib core built with a **native arm64 clang** -(confirmed `Code Type: ARM64` for the live compiler processes) cross-targeting x86_64; all -binaries are x86_64 and link the `/usr/local` x86_64 Homebrew dylibs; **294/294 unit tests pass** -(the x86_64 test binary runs under Rosetta). +## Source changes this requires -## Native vs Rosetta — which to use +Everything else is CI wiring (the workflow job, the `config.yml` gate, the runner shim in the +workflow); the only non-CI source changes are: -| | Rosetta (`…-rosetta` branch) | Native (this branch) | -|---|---|---| -| Toolchain | x86_64 under Rosetta | native arm64 | -| Compile speed | slower (translated clang) | **faster (native clang)** | -| CMake setup | just PATH + a couple env vars | more `-D` flags; ninja/find_program gotchas | -| `CMAKE_SYSTEM_PROCESSOR` | `x86_64` (SIMD paths correct for source builds) | `arm64` (cosmetic `MR_PLATFORM` mislabel) | -| Robustness | higher (fewer moving parts) | needs care (silent Rosetta fallback if ninja wrong) | - -Native wins on build speed; Rosetta wins on simplicity/robustness. Pick per priority. +| Change | File | +|---|---| +| Honor `-D HOMEBREW_PREFIX=` (falls back to `brew --prefix`) and validate it | [`ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) | +| Forward the cross knobs (`CMAKE_OSX_ARCHITECTURES`, `CMAKE_MAKE_PROGRAM`, `HOMEBREW_PREFIX`) and honor a caller `NPROC` | [`build_source.sh`](../../scripts/build_source.sh), [`build_thirdparty.sh`](../../scripts/build_thirdparty.sh) | From 1580a4858f653f585982a344f6bc7352a2378e2e Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Sun, 20 Sep 2026 03:08:18 +0300 Subject: [PATCH 15/17] CI/macos: single build job with JSON matrix, x64-cross gated by label Replace the duplicated macos-build-test-crossplatform job with one macos-build-test job whose matrix comes from .github/workflows/matrix/macos-config.json via config_matrix (the Windows/Ubuntu pattern). config.yml's set-macos-matrix drops the self-hosted x64-cross leg when the PR carries the disable-build-macos-crossplatform label, so a down runner cannot hang macOS CI, and the cross-only steps are live again for that leg. The job name is pinned to "macos-build-test (, )" so check names do not change. NuGet patch steps skip x64-cross: create-nuget-package downloads the exact DotNetPatchArchiveMacOs-x64 artifact, so the cross copy was uploaded only to be deleted. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build-test-distribute.yml | 2 +- .github/workflows/build-test-macos.yml | 360 +------------------- .github/workflows/config.yml | 18 +- .github/workflows/matrix/macos-config.json | 41 +++ 4 files changed, 74 insertions(+), 347 deletions(-) create mode 100644 .github/workflows/matrix/macos-config.json diff --git a/.github/workflows/build-test-distribute.yml b/.github/workflows/build-test-distribute.yml index 46fe37250bb5..12ca02800726 100644 --- a/.github/workflows/build-test-distribute.yml +++ b/.github/workflows/build-test-distribute.yml @@ -151,7 +151,7 @@ jobs: upload_artifacts: ${{ needs.config.outputs.upload_artifacts == 'true' }} upload_test_artifacts: ${{ needs.config.outputs.upload_test_artifacts == 'true' }} nuget_build_patch: ${{ needs.config.outputs.build_enable_windows == 'true' && needs.config.outputs.upload_artifacts == 'true'}} - build_macos_crossplatform: ${{ needs.config.outputs.build_enable_macos_crossplatform == 'true' }} + config_matrix: ${{ needs.config.outputs.macos_config_matrix }} secrets: inherit update-win-version: diff --git a/.github/workflows/build-test-macos.yml b/.github/workflows/build-test-macos.yml index faaad0ff18cc..fea82956a972 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -31,356 +31,24 @@ on: default: false required: false type: boolean - build_macos_crossplatform: - # Gates the self-hosted native-x86_64 cross job. When false it is skipped - # (never scheduled), so a busy/down crossplatform-build runner can't hang - # the rest of macOS CI to the job timeout. - default: true - required: false - type: boolean + config_matrix: + # JSON `{"include":[...]}` selected by config.yml from + # .github/workflows/matrix/macos-config.json. + required: true + type: string jobs: macos-build-test: + # Legs live in .github/workflows/matrix/macos-config.json; config.yml drops the + # self-hosted x64-cross leg when the disable-build-macos-crossplatform label is + # set, so a down runner can't hang macOS CI. arm64/Release is pinned to macos-14 + # to share the mrbind cache with pip-build.yml. + name: macos-build-test (${{ matrix.arch }}, ${{ matrix.config }}) timeout-minutes: 100 runs-on: ${{ matrix.runner }} strategy: fail-fast: false - matrix: - arch: [ x64, arm64 ] - config: [ Debug, Release ] - exclude: - - arch: x64 - config: Debug - include: - - arch: x64 - config: Release - compiler: AppleClang - cxx-compiler-template: /usr/bin/clang++ - c-compiler-template: /usr/bin/clang - # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners - runner: macos-15-intel # github hosted - instance: macos-15-intel - - arch: arm64 - config: Release - compiler: AppleClang - cxx-compiler-template: /usr/bin/clang++ - c-compiler-template: /usr/bin/clang - # Pinned to the same image pip-build.yml uses so the mrbind cache is shared. - runner: macos-14 - instance: macos-14 - - arch: arm64 - config: Debug - compiler: brew-llvm22 - cxx-compiler-template: BREW_PREFIX/opt/llvm@22/bin/clang++ - c-compiler-template: BREW_PREFIX/opt/llvm@22/bin/clang - runner: [ self-hosted, macos, arm64, build ] # any macos version - instance: self-hosted-arm - permissions: - id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout - actions: read # This is required for wait-for-job - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: macOS Diagnostics - uses: ./.github/actions/macos-diagnostics - - - name: Checkout third-party submodules - run: | - # Selective init -- parent Checkout drops submodules:true. - # https://github.com/actions/checkout/issues/1779 - # Retried via retry.sh: submodule endpoints occasionally 500. - bash scripts/retry.sh --timeout 300 -- scripts/clone_submodules_macos.sh - - - name: Configure native x86_64 cross-build environment - if: ${{ matrix.cross-osx-arch == 'x86_64' }} - run: | - PYVER=3.10 - SHIM="$RUNNER_TEMP/x86_64-cross-shim" - mkdir -p "$SHIM" - # The native arm64 Homebrew prefix varies across the self-hosted fleet - # (/opt/homebrew, ~/.homebrew, ...); find it by its arm64 cmake so build - # tools run natively, not the x86_64 copies under /usr/local (which would - # drag clang into Rosetta). Fail loudly rather than dangle shim symlinks. - ARM_BREW="" - for p in /opt/homebrew "$HOME/.homebrew"; do - if file -b "$p/bin/cmake" 2>/dev/null | grep -q arm64; then ARM_BREW="$p"; break; fi - done - [ -n "$ARM_BREW" ] || { echo "::error::x86_64 cross setup: no arm64 Homebrew cmake found"; exit 1; } - for t in "$ARM_BREW/bin/cmake" "$ARM_BREW/bin/ninja" \ - "/usr/local/bin/python$PYVER" "/usr/local/bin/python$PYVER-config" /usr/local/bin/brew; do - [ -x "$t" ] || { echo "::error::x86_64 cross setup: missing $t"; exit 1; } - done - ln -sf "$ARM_BREW/bin/cmake" "$SHIM/cmake" - ln -sf "$ARM_BREW/bin/ninja" "$SHIM/ninja" - ln -sf "/usr/local/bin/python$PYVER" "$SHIM/python$PYVER" - ln -sf "/usr/local/bin/python$PYVER-config" "$SHIM/python$PYVER-config" - # A symlink-to-a-symlink breaks Homebrew's self-location, so wrap brew. - printf '#!/bin/bash\nexec /usr/local/bin/brew "$@"\n' > "$SHIM/brew" - chmod +x "$SHIM/brew" - echo "$SHIM" >> "$GITHUB_PATH" - echo "CMAKE_OSX_ARCHITECTURES=${{ matrix.cross-osx-arch }}" >> "$GITHUB_ENV" - echo "CMAKE_MAKE_PROGRAM=$ARM_BREW/bin/ninja" >> "$GITHUB_ENV" - echo "CMAKE_PREFIX_PATH=/usr/local" >> "$GITHUB_ENV" - echo "MESHLIB_HOMEBREW_PREFIX=/usr/local" >> "$GITHUB_ENV" - echo "HOMEBREW_DIR=/usr/local" >> "$GITHUB_ENV" - echo "PKG_CONFIG_PATH=$(/usr/local/bin/brew --prefix python@$PYVER)/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" >> "$GITHUB_ENV" - - - name: Install thirdparty libs - id: thirdparty - uses: ./.github/actions/install-macos-thirdparty - with: - cache-instance: ${{ matrix.instance }} - cache-compiler: ${{ matrix.compiler }} - cxx-compiler-template: ${{ matrix.cxx-compiler-template }} - c-compiler-template: ${{ matrix.c-compiler-template }} - - - name: Collect runner's system stats - if: ${{ inputs.internal_build }} - id: collect-runner-stats - continue-on-error: true - uses: ./.github/actions/collect-runner-stats - with: - target_os: macos - target_arch: ${{ matrix.arch }} - cxx_compiler: ${{ steps.thirdparty.outputs.cxx-compiler }} - build_config: ${{ matrix.config }} - - - name: Install MRBind deps - if: ${{ inputs.mrbind || inputs.mrbind_c }} - env: - HOMEBREW_NO_INSTALL_UPGRADE: '1' # don't upgrade an already-installed llvm/lld, no z3, no rebuild - HOMEBREW_NO_INSTALL_CLEANUP: '1' # don't prune the known-good keg - run: ./scripts/mrbind/install_deps_macos.sh - - - name: Build MRBind - if: ${{ inputs.mrbind || inputs.mrbind_c }} - uses: ./.github/actions/build-mrbind - with: - # Keyed on the runner image (matrix.instance) plus the brew-prefix hash - # to discriminate between fleet members of the self-hosted ARM label set. - # Shared with pip-build.yml's macOS section where the two also match. - cache-key-prefix: ${{ matrix.instance }}-clang - build-script: scripts/mrbind/install_mrbind_macos.sh - extra-cache-key: ${{ hashFiles('scripts/mrbind/clang_version_macos.txt') }}-${{ steps.thirdparty.outputs.brew-hash }} - - - name: Create virtualenv - run: | - python3.10 -m venv .venv - . .venv/bin/activate - echo PATH=$PATH >> $GITHUB_ENV - - - name: Setup python requirements - run: | - curl -sS https://bootstrap.pypa.io/get-pip.py | python3 - python3 -m pip install -r ./requirements/python/requirements.txt - python3 -m pip install pytest - - - name: Wait for C bindings - if: ${{ inputs.mrbind_c }} - uses: ./.github/actions/wait-for-job - with: - job-name: generate-c-bindings - - - name: Download C bindings - if: ${{ inputs.mrbind_c }} - uses: actions/download-artifact@v8 - with: - name: CBindings - path: MeshLib/CbindingsTmp - - - name: Prepare C bindings folders - if: ${{ inputs.mrbind_c }} - run: | - rm -rf source/MeshLibC2 - mv MeshLib/CbindingsTmp/MeshLibC2 source - # Cuda placeholders: - cp -R scripts/mrbind/cuda_placeholder_generated_c/{include,src} source/MeshLibC2Cuda - - - name: Build - run: ./scripts/build_source.sh - env: - MESHLIB_BUILD_RELEASE: ${{ fromJSON('["OFF", "ON"]')[matrix.config == 'Release'] }} - MESHLIB_BUILD_DEBUG: ${{ fromJSON('["OFF", "ON"]')[matrix.config == 'Debug'] }} - CMAKE_CXX_COMPILER: ${{ steps.thirdparty.outputs.cxx-compiler }} - MR_VERSION: ${{ inputs.app_version }} - MR_CMAKE_OPTIONS: > - -DMESHLIB_BUILD_MRMESH_PY_LEGACY=${{ fromJSON('["ON", "OFF"]')[inputs.mrbind] }} - -DMR_CXX_STANDARD=23 - -DMESHLIB_BUILD_GENERATED_C_BINDINGS=${{ fromJSON('["OFF", "ON"]')[inputs.mrbind_c] }} - -DMR_PCH_USE_EXTRA_HEADERS=ON - ${{ matrix.cross-osx-arch == 'x86_64' && '-DMR_PLATFORM=APPLE_x86_64' || '' }} - - - name: Verify x86_64 output - if: ${{ matrix.cross-osx-arch == 'x86_64' }} - run: lipo -archs build/${{ matrix.config }}/bin/libMRMesh.dylib | grep -qx x86_64 - - - name: MRMesh Exported Symbols - run: | - export PATH="$(brew --prefix llvm@22)/bin:$PATH" - nm -gU ./build/${{ matrix.config }}/bin/libMRMesh.dylib | llvm-cxxfilt - - - name: Generate and build Python bindings - if: ${{ inputs.mrbind }} - env: - PATH: ${{ steps.thirdparty.outputs.brew-prefix }}/opt/make/libexec/gnubin:${{ steps.thirdparty.outputs.brew-prefix }}/opt/grep/libexec/gnubin:${{env.PATH}} - CXX: ${{ steps.thirdparty.outputs.cxx-compiler }} - run: | - make --version - make -f scripts/mrbind/generate.mk \ - -B --trace \ - PYTHON_PKGCONF_NAME=python-3.10-embed \ - MESHLIB_SHLIB_DIR=build/${{matrix.config}}/bin - - - name: Run Start-and-Exit Tests - timeout-minutes: 3 - run: ./build/${{ matrix.config }}/bin/MeshViewer -tryHidden -noEventLoop -unloadPluginsAtEnd - - - name: Verify meshlib.mrmeshpy import - if: ${{ inputs.mrbind }} - timeout-minutes: 3 - uses: ./.github/actions/verify-meshlib-python-import - with: - build-bin-dir: ./build/${{ matrix.config }}/bin - - - name: Unit Tests - env: - GTEST_OUTPUT: 'xml:unit_tests_report_gtest.xml' - timeout-minutes: 10 - run: ./build/${{ matrix.config }}/bin/MRTest - - - name: C Unit Tests - if: ${{ inputs.mrbind_c }} - timeout-minutes: 10 - run: ./build/${{ matrix.config }}/bin/MRTestC2 - - - name: Python Sanity Tests - timeout-minutes: 8 - working-directory: ./build/${{ matrix.config }}/bin - run: python3 -u ./../../../scripts/run_python_test_script.py -d '../test_python' -a ' --junit-xml=../unit_tests_report_pytest.xml' - - - name: Python Regression Tests - if: ${{ inputs.internal_build }} - env: - RUN_CUDA_ARG: "--run-cuda=negative --junit-xml=../unit_tests_report_regression.xml" - uses: ./.github/actions/python-regression-tests - with: - build_config: ${{ matrix.config }} - smoke: ${{ !inputs.full_config_build && matrix.config == 'Debug' }} - test_artifacts_path: macos/${{ matrix.arch }} - upload_test_artifacts: ${{ inputs.upload_test_artifacts }} - - - name: Generate Test Performance Report - continue-on-error: true - run: | - scripts/junit_to_csv.py unit_tests_report.csv unit_tests_report_*.xml - cat unit_tests_report.csv - - - name: Create Pkg - if: ${{ matrix.config == 'Release' }} - run: | - ./scripts/distribution_apple.sh ${{ inputs.app_version }} - mv MeshLib_.pkg meshlib_${{matrix.arch}}.pkg - - - name: Extract Pkg - if: ${{ matrix.config == 'Release' }} - run: | - # https://gist.github.com/ugultopu/1adf8e08acb87be649d69419cf7aca3c - pkgutil --expand meshlib_${{ matrix.arch }}.pkg ./meshlib_install - cd ./meshlib_install/MeshLib.pkg - cat Payload | gunzip | cpio -i - - - name: Build C++ examples - if: ${{ matrix.config == 'Release' }} - env: - CXX: ${{ steps.thirdparty.outputs.cxx-compiler }} - run: | - cmake \ - -S examples/cpp-examples \ - -B cpp-examples-build \ - -D CMAKE_FRAMEWORK_PATH=$(pwd)/meshlib_install/MeshLib.pkg/Frameworks/ - cmake \ - --build cpp-examples-build \ - --parallel $(sysctl -n hw.physicalcpu) - - - name: Build C examples - if: ${{ matrix.config == 'Release' }} - env: - CC: ${{ steps.thirdparty.outputs.c-compiler }} - run: | - cmake \ - -S examples/c-examples \ - -B c-examples-build \ - -D CMAKE_FRAMEWORK_PATH=$(pwd)/meshlib_install/MeshLib.pkg/Frameworks/ - cmake \ - --build c-examples-build \ - --parallel $(sysctl -n hw.physicalcpu) - - - name: Upload Macos Distribution - if: ${{ inputs.upload_artifacts && matrix.config == 'Release' }} - env: - ACTIONS_ARTIFACT_UPLOAD_TIMEOUT_MS: 1800000 - uses: actions/upload-artifact@v7 - with: - name: Distributives_macos-${{matrix.arch}} - path: meshlib_${{matrix.arch}}.pkg - retention-days: 1 - overwrite: true - - - name: Collect artifact stats - # Mirror `Upload Macos Distribution`'s gate -- otherwise Debug jobs - # invoke this step with no matching .pkg and the action fails (with - # `continue-on-error: true` the job still succeeds, but a confusing - # `failure`-level annotation is surfaced on the run summary). - if: ${{ inputs.internal_build && inputs.upload_artifacts && matrix.config == 'Release' }} - continue-on-error: true - uses: ./.github/actions/collect-artifact-stats - with: - artifact_path: ${{ github.workspace }} - artifact_glob: meshlib_${{matrix.arch}}.pkg - stats_file_suffix: -${{ steps.collect-runner-stats.outputs.job_id }} - - - name: Create and fix fake Wheel for NuGet - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} - shell: bash - run: | - python3 -m venv ./wheel_venv - source ./wheel_venv/bin/activate - python3 -m pip install delocate==0.10.7 - python3 ./scripts/nuget_patch/patch_library_deps.py ./patched_content/ ./build/Release/bin/lib{MeshLibC2,MeshLibC2Cuda}.dylib - ./scripts/nuget_patch/fix_macos_rpath.sh ./patched_content/libMeshLibC2.dylib - - - name: Upload NuGet files to Artifacts - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} - uses: actions/upload-artifact@v7 - with: - name: DotNetPatchArchiveMacOs-${{ matrix.arch }} - path: ./patched_content/* - retention-days: 1 - overwrite: true - - # Duplicate of macos-build-test for the single native x86_64 cross config, split - # into its own job so it can be gated (skipped when the crossplatform-build - # runner is unavailable) without hanging the hosted macOS legs. When the cross - # build replaces the hosted x64 leg, that entry is dropped from macos-build-test - # above and this job becomes the sole x64 producer. Keep the steps in sync. - macos-build-test-crossplatform: - if: ${{ inputs.build_macos_crossplatform }} - timeout-minutes: 100 - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - arch: x64-cross - config: Release - compiler: AppleClang - cxx-compiler-template: /usr/bin/clang++ - c-compiler-template: /usr/bin/clang - runner: [ self-hosted, macos, arm64, crossplatform-build ] - instance: self-hosted-arm-x64-native - cross-osx-arch: x86_64 + matrix: ${{ fromJSON(inputs.config_matrix) }} permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout @@ -647,8 +315,10 @@ jobs: artifact_glob: meshlib_${{matrix.arch}}.pkg stats_file_suffix: -${{ steps.collect-runner-stats.outputs.job_id }} + # NuGet consumes the exact artifact name DotNetPatchArchiveMacOs-x64, so the + # x64-cross leg's copy would only be uploaded and then deleted; skip it there. - name: Create and fix fake Wheel for NuGet - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' && matrix.arch != 'x64-cross' }} shell: bash run: | python3 -m venv ./wheel_venv @@ -658,7 +328,7 @@ jobs: ./scripts/nuget_patch/fix_macos_rpath.sh ./patched_content/libMeshLibC2.dylib - name: Upload NuGet files to Artifacts - if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' }} + if: ${{ inputs.nuget_build_patch && matrix.config == 'Release' && matrix.arch != 'x64-cross' }} uses: actions/upload-artifact@v7 with: name: DotNetPatchArchiveMacOs-${{ matrix.arch }} diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index b9bc90902f21..b18e210d1882 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -38,6 +38,9 @@ on: windows_x64_config_matrix: description: "Config matrix for Windows builds" value: ${{ jobs.prepare-config.outputs.windows_x64_config_matrix }} + macos_config_matrix: + description: "Config matrix for macOS builds; the self-hosted x64-cross leg is dropped when disabled" + value: ${{ jobs.prepare-config.outputs.macos_config_matrix }} update_doc: description: value: ${{ jobs.prepare-config.outputs.tag-update-doc == 'true' || jobs.prepare-config.outputs.tag-update-doc-only == 'true' }} @@ -76,7 +79,7 @@ on: description: value: ${{ !( jobs.prepare-config.outputs.tag-update-doc-only == 'true' || jobs.prepare-config.outputs.tag-disable-macos == 'true' ) }} build_enable_macos_crossplatform: - description: "macOS native x86_64 cross job; also off whenever macOS is disabled" + description: "macOS native x86_64 cross leg (label disable-build-macos-crossplatform); also off whenever macOS is disabled. Gates its distro test; the build leg itself is dropped from macos_config_matrix" value: ${{ !( jobs.prepare-config.outputs.tag-update-doc-only == 'true' || jobs.prepare-config.outputs.tag-disable-macos == 'true' || jobs.prepare-config.outputs.tag-disable-macos-crossplatform == 'true' ) }} build_enable_emscripten: description: @@ -92,6 +95,7 @@ jobs: need-linux-vcpkg-rebuild: ${{ steps.select-vcpkg-docker-image-tag.outputs.need_rebuild }} ubuntu_x64_config_matrix: ${{ steps.set-ubuntu-x64-matrix.outputs.matrix }} windows_x64_config_matrix: ${{ steps.set-windows-x64-matrix.outputs.matrix }} + macos_config_matrix: ${{ steps.set-macos-matrix.outputs.matrix }} vcpkg-docker-image-tag: ${{ steps.select-vcpkg-docker-image-tag.outputs.image_tag }} windows-changes: ${{ steps.windows-changes.outputs.src }} # Read PR labels live; github.event.pull_request.labels is a @@ -316,3 +320,15 @@ jobs: "$MATRIX_FILE" > tmp.json echo "matrix=$(jq -c . tmp.json)" >> $GITHUB_OUTPUT + + - name: Set matrix for macos builds + id: set-macos-matrix + run: | + # The x64-cross leg needs the self-hosted crossplatform-build runner; drop it + # (never scheduled) when the disable-build-macos-crossplatform label is set. + if [[ "${{ steps.live-labels.outputs.tag-disable-macos-crossplatform }}" == "true" ]]; then + FILTER='del(.include[] | select(.arch == "x64-cross"))' + else + FILTER='.' + fi + echo "matrix=$(jq -c "$FILTER" .github/workflows/matrix/macos-config.json)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/matrix/macos-config.json b/.github/workflows/matrix/macos-config.json new file mode 100644 index 000000000000..29f9bc37d58d --- /dev/null +++ b/.github/workflows/matrix/macos-config.json @@ -0,0 +1,41 @@ +{ + "include": [ + { + "arch": "x64", + "config": "Release", + "compiler": "AppleClang", + "cxx-compiler-template": "/usr/bin/clang++", + "c-compiler-template": "/usr/bin/clang", + "runner": ["macos-15-intel"], + "instance": "macos-15-intel" + }, + { + "arch": "arm64", + "config": "Release", + "compiler": "AppleClang", + "cxx-compiler-template": "/usr/bin/clang++", + "c-compiler-template": "/usr/bin/clang", + "runner": ["macos-14"], + "instance": "macos-14" + }, + { + "arch": "arm64", + "config": "Debug", + "compiler": "brew-llvm22", + "cxx-compiler-template": "BREW_PREFIX/opt/llvm@22/bin/clang++", + "c-compiler-template": "BREW_PREFIX/opt/llvm@22/bin/clang", + "runner": ["self-hosted", "macos", "arm64", "build"], + "instance": "self-hosted-arm" + }, + { + "arch": "x64-cross", + "config": "Release", + "compiler": "AppleClang", + "cxx-compiler-template": "/usr/bin/clang++", + "c-compiler-template": "/usr/bin/clang", + "runner": ["self-hosted", "macos", "arm64", "crossplatform-build"], + "instance": "self-hosted-arm-x64-native", + "cross-osx-arch": "x86_64" + } + ] +} From f66d7e3c7c35039608b9be336782d8b566821e3e Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Sun, 20 Sep 2026 03:08:18 +0300 Subject: [PATCH 16/17] test-distribution: gate the x64-cross leg on test_macos_crossplatform The *x64-cross.pkg only exists when the self-hosted cross build ran. Add the input (wired from build_enable_macos_crossplatform) and build the include-only macos-test matrix with fromJSON(format(...)), because `exclude` cannot remove include-only entries. Without this, disabling the cross build made `gh release download` burn the retry budget on a missing asset. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build-test-distribute.yml | 1 + .github/workflows/test-distribution.yml | 46 +++++++++------------ 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-test-distribute.yml b/.github/workflows/build-test-distribute.yml index 12ca02800726..8b4fadd28197 100644 --- a/.github/workflows/build-test-distribute.yml +++ b/.github/workflows/build-test-distribute.yml @@ -369,6 +369,7 @@ jobs: test_ubuntu_x64: ${{ needs.config.outputs.build_enable_ubuntu_x64 == 'true' }} test_ubuntu_arm64: ${{ needs.config.outputs.build_enable_ubuntu_arm64 == 'true' }} test_macos: ${{ needs.config.outputs.build_enable_macos == 'true' }} + test_macos_crossplatform: ${{ needs.config.outputs.build_enable_macos_crossplatform == 'true' }} test_windows: ${{ needs.config.outputs.build_enable_windows == 'true' && needs.config.outputs.build-release-win == 'true' }} secrets: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-distribution.yml b/.github/workflows/test-distribution.yml index 46694c733000..c3e5151b1685 100644 --- a/.github/workflows/test-distribution.yml +++ b/.github/workflows/test-distribution.yml @@ -15,6 +15,10 @@ on: test_macos: required: true type: boolean + test_macos_crossplatform: + # The *x64-cross.pkg exists only when the self-hosted cross build ran. + required: true + type: boolean test_windows: required: true type: boolean @@ -35,6 +39,9 @@ on: test_macos: type: boolean default: false + test_macos_crossplatform: + type: boolean + default: false test_windows: type: boolean default: false @@ -238,32 +245,19 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false - matrix: - include: - - arch: arm64 - runner: macos-13 - pkg_pattern: '*arm64.pkg' - - arch: arm64 - runner: macos-14 - pkg_pattern: '*arm64.pkg' - - arch: arm64 - runner: macos-15 - pkg_pattern: '*arm64.pkg' - - arch: arm64 - runner: macos-26 - pkg_pattern: '*arm64.pkg' - - arch: x64 - runner: macos-12-intel - pkg_pattern: '*x64.pkg' - - arch: x64 - runner: macos-15-intel - pkg_pattern: '*x64.pkg' - - arch: x64 - runner: macos-26-intel - pkg_pattern: '*x64.pkg' - - arch: x64-cross - runner: macos-15-intel - pkg_pattern: '*x64-cross.pkg' + # One leg per (runner, pkg). The x64-cross leg is appended only when that build + # ran, else `gh release download` finds no *x64-cross.pkg. An include-only matrix + # can't be trimmed with `exclude` (include is applied after it), hence the JSON. + matrix: >- + ${{ fromJSON(format('{{"include":[{0}{1}]}}', + '{"arch":"arm64","runner":"macos-13","pkg_pattern":"*arm64.pkg"}, + {"arch":"arm64","runner":"macos-14","pkg_pattern":"*arm64.pkg"}, + {"arch":"arm64","runner":"macos-15","pkg_pattern":"*arm64.pkg"}, + {"arch":"arm64","runner":"macos-26","pkg_pattern":"*arm64.pkg"}, + {"arch":"x64","runner":"macos-12-intel","pkg_pattern":"*x64.pkg"}, + {"arch":"x64","runner":"macos-15-intel","pkg_pattern":"*x64.pkg"}, + {"arch":"x64","runner":"macos-26-intel","pkg_pattern":"*x64.pkg"}', + inputs.test_macos_crossplatform && ',{"arch":"x64-cross","runner":"macos-15-intel","pkg_pattern":"*x64-cross.pkg"}' || '')) }} steps: - name: Checkout uses: actions/checkout@v7 From 1de94e4d722f10addd9250600e0b16e89eb9e4c5 Mon Sep 17 00:00:00 2001 From: Max Raiskii Date: Sun, 20 Sep 2026 03:08:18 +0300 Subject: [PATCH 17/17] macos/crossplatform: no-sudo runner provisioning, README update provision-runner.sh: two-account model. The administrator installs Rosetta and hands the /usr/local skeleton to the service account once; the service account runs the script, which prints the exact admin commands and exits 1 whenever root is needed. Bootstrap the Intel Homebrew by git clone: the official installer is Apple-Silicon-only since 2026-09 and hard-requires sudo. Drop every `arch -x86_64` (brew at /usr/local picks its x86_64 Ruby by prefix) and discover the native brew at /opt/homebrew or ~/.homebrew like the CI shim does. README: single-job CI wiring and the correct label names, what still runs under Rosetta, the provisioning flow, and Homebrew's Intel Tier-3 support horizon. Co-Authored-By: Claude Fable 5.1 --- macos/crossplatform-builds/README.md | 79 ++++++++-- .../crossplatform-builds/provision-runner.sh | 137 ++++++++++++------ 2 files changed, 161 insertions(+), 55 deletions(-) diff --git a/macos/crossplatform-builds/README.md b/macos/crossplatform-builds/README.md index 6e67c56c91f7..50684d4fc030 100644 --- a/macos/crossplatform-builds/README.md +++ b/macos/crossplatform-builds/README.md @@ -3,20 +3,24 @@ Builds the **Intel (`x86_64`)** macOS target of MeshLib on an **Apple Silicon (`arm64`)** self-hosted runner, using a **native arm64 toolchain that cross-targets x86_64**: cmake/ninja/clang run natively (fast compiles) and emit x86_64 via `-arch x86_64`, linking the x86_64 Homebrew at `/usr/local`. The -binaries run on Intel Macs — and on the build host under Rosetta, which is how CI runs their tests. +binaries run on Intel Macs — and on the build host under Rosetta, which is how CI tests them. ## Where it lives in CI -- Job `macos-build-test-crossplatform` in - [`build-test-macos.yml`](../../.github/workflows/build-test-macos.yml) — a separate, gated copy of - the macOS build/test steps for the single `x64-cross` config. -- Gated by `build_enable_macos_crossplatform` (in [`config.yml`](../../.github/workflows/config.yml)): - on by default. Add the `disable-macos-crossplatform` PR label to skip just this job (e.g. when the - self-hosted runner is down) so it can't hang the hosted macOS legs; `disable-macos` skips all macOS. +- The `x64-cross` leg of the `macos-build-test` job in + [`build-test-macos.yml`](../../.github/workflows/build-test-macos.yml). The matrix is + [`matrix/macos-config.json`](../../.github/workflows/matrix/macos-config.json); + [`config.yml`](../../.github/workflows/config.yml) (step `set-macos-matrix`) selects it and **drops the + `x64-cross` leg when the PR carries the `disable-build-macos-crossplatform` label**, so a down or busy + self-hosted runner can never hang the hosted macOS legs (`disable-build-macos` skips all of macOS). + The cross-only steps — the shim setup, `-DMR_PLATFORM=APPLE_x86_64`, `Verify x86_64 output` — are + gated on `matrix.cross-osx-arch`, which only that leg sets. - Runs on a runner labelled `[self-hosted, macos, arm64, crossplatform-build]`, provisioned by [`provision-runner.sh`](provision-runner.sh). - Produces `meshlib_x64-cross.pkg`; [`test-distribution.yml`](../../.github/workflows/test-distribution.yml) - installs and smoke-tests it on a real Intel Mac. + installs and smoke-tests it on a real Intel Mac. That leg is gated by its `test_macos_crossplatform` + input (= `build_enable_macos_crossplatform` from `config.yml`), so disabling the cross build also + disables the distro test that would otherwise look for a `.pkg` that was never published. > **Intent:** once proven, this replaces the GitHub-hosted `macos-15-intel` x64 leg (Intel runners are > being retired). Until then both run, and both `.pkg`s are published (the cross one suffixed @@ -31,6 +35,24 @@ binaries run on Intel Macs — and on the build host under Rosetta, which is how arm64 Homebrew). [`ConfigureHomebrew.cmake`](../../cmake/Modules/ConfigureHomebrew.cmake) honors `-D HOMEBREW_PREFIX=/usr/local`. +## What still runs under Rosetta + +Compilation is native; Rosetta is used for four things, only the last of which is fundamental: + +1. **The `/usr/local` Homebrew itself.** Its Portable Ruby is an x86_64 binary, so every `brew` call + there (install, `--prefix`, config) is translated, as are formula post-install hooks. +2. **Configure-time execution of x86_64 programs:** `python3.10-config` / CMake's FindPython (the + `/usr/local` interpreter is x86_64) and CMake `try_run` probes. +3. **The bindings step.** `scripts/mrbind/generate.mk` has no macOS target-arch flag; it yields an + x86_64 `mrmeshpy.so` because the x86_64 GNU `make` from `/usr/local` is first on `PATH` and its + children (`clang++`, `mrbind`) inherit the translated execution. It works, but it is not native — + a candidate follow-up is a native `make` plus an explicit `-arch x86_64`. +4. **Running the Intel output** for `MRTest`, `MRTestC2`, the MeshViewer smoke test and the Python + tests. Intel code cannot run on Apple Silicon hardware any other way. + +A fully Rosetta-free runner would therefore be build-only (different x86_64 dependency source, a +toolchain file with pre-seeded `try_run` results) with all testing on real Intel hardware. + ## Critical gotchas (why a naive attempt silently falls back to Rosetta) 1. **Force the arm64 ninja.** CMake's `find_program` searches `/usr/local/bin` by default and picks up @@ -45,14 +67,43 @@ binaries run on Intel Macs — and on the build host under Rosetta, which is how 4. **`CMAKE_SYSTEM_PROCESSOR` stays `arm64`** (it reflects the host, since cmake is native). Harmless for MeshLib's own SIMD (gated on the target macros `__x86_64__`/`__aarch64__`); `MR_PLATFORM` is set explicitly to compensate for the label. -5. Configure-time `try_run` probes execute x86_64 test binaries, which the OS runs via Rosetta - transparently. Only these brief probes touch Rosetta; the bulk compilation is native. +5. **A translated parent makes every child translated.** Anything started from an x86_64 process + (the `/usr/local` `make`, a translated shell) runs `/usr/bin/clang++` as x86_64 too. Keep the + compile driven by the native `ninja` (gotcha 1); this is also why the bindings step is translated. ## Provisioning a runner -Run [`provision-runner.sh`](provision-runner.sh) once per host (see its header for prerequisites). It -ensures a native arm64 Homebrew (cmake + ninja), Rosetta 2, and an x86_64 Homebrew at `/usr/local` -with the `requirements/macos.txt` formulae (`--prewarm` also installs the binding-generation deps). +Two accounts are involved: an **administrator** (has sudo) and the CI **service account** (`runner`, no +sudo, runs the jobs). Run [`provision-runner.sh`](provision-runner.sh) as the service account; wherever +root is required it prints the exact one-time command for the administrator and exits 1: + +1. **Rosetta 2** — `sudo softwareupdate --install-rosetta --agree-to-license`. Needed *before* the first + `/usr/local` brew command (brew's Ruby there is x86_64). +2. **`/usr/local` skeleton owned by the service account** — `sudo mkdir -p /usr/local/{…}` plus + `sudo chown -R runner:staff /usr/local/{…}` over Homebrew's directory set (the script prints the + full list). `/usr/local` itself stays `root:wheel`. + +The service account's re-run then `git clone`s Homebrew into `/usr/local/Homebrew`, links +`/usr/local/bin/brew` and runs `brew update --force --quiet`. The official installer is deliberately +not used: since 2026-09 it is Apple-Silicon-only (it aborts on an x86_64 `uname`, and natively it only +targets `/opt/homebrew`) and it hard-requires sudo. No `arch -x86_64` is needed anywhere: brew at +`/usr/local` selects its x86_64 Ruby by prefix and serves Intel bottles regardless of the caller's +architecture (`/usr/local/bin/brew config` reports `macOS: …-x86_64`), which is exactly how the CI shim +(`exec /usr/local/bin/brew`) invokes it. `--prewarm` installs the `requirements/macos.txt` formulae and +the binding-generation deps up front. + +## Support horizon + +Homebrew 7.0 (2026-09-13) moved Intel macOS to **Tier 3: no new Intel bottles**. Existing bottles keep +installing, but an updated formula may build from source — under Rosetta on this runner, which for +`llvm@22` means hours — and Homebrew intends to stop running on Intel in or after September 2027. This +applies equally to the GitHub-hosted `macos-15-intel` leg, which installs the same Intel bottles. +Consequences: + +- Keep the runner's `/usr/local` formulae pinned: CI already sets `HOMEBREW_NO_AUTO_UPDATE=1` and + `HOMEBREW_NO_INSTALL_UPGRADE=1`; never `brew upgrade` that prefix by hand. +- `brew doctor` on `/usr/local` prints an expected Tier-3 notice; it is not an error. +- The Intel target as a whole has a bounded life; plan its retirement alongside the runner. ## Reproducing locally @@ -82,7 +133,7 @@ this). The thirdparty-from-source libraries build the same way (native tools + t ## Source changes this requires -Everything else is CI wiring (the workflow job, the `config.yml` gate, the runner shim in the +Everything else is CI wiring (the matrix JSON, the `config.yml` gate, the runner shim in the workflow); the only non-CI source changes are: | Change | File | diff --git a/macos/crossplatform-builds/provision-runner.sh b/macos/crossplatform-builds/provision-runner.sh index 42a137856a82..4f90282b04e2 100755 --- a/macos/crossplatform-builds/provision-runner.sh +++ b/macos/crossplatform-builds/provision-runner.sh @@ -1,20 +1,19 @@ #!/bin/bash -# Provision a self-hosted arm64 macOS runner for the NATIVE (non-Rosetta) x86_64 -# cross build of MeshLib (see README.md). Idempotent -- safe to re-run. -# Run once per runner, from the repo root: +# Provision a self-hosted arm64 macOS runner for the native x86_64 cross build of +# MeshLib (see README.md). Idempotent. Run as the CI service account, from the repo root: # ./macos/crossplatform-builds/provision-runner.sh [--prewarm] # -# The native cross build uses a NATIVE arm64 toolchain (fast compiles) that -# cross-targets x86_64 and links the x86_64 Homebrew at /usr/local. -# +# The cross build compiles with the NATIVE arm64 toolchain and links the x86_64 +# Homebrew at /usr/local; Rosetta runs that brew, its python and the built tests. # Prerequisites: -# 1. native arm64 cmake + ninja (/opt/homebrew) -- the build driver (runs natively) -# 2. x86_64 Homebrew at /usr/local (+ formulae) -- the x86_64 libraries to link against -# 3. Rosetta 2 -- ONLY to run the resulting x86_64 test -# binaries and CMake configure-time -# probes; compilation itself is native. +# 1. Xcode Command Line Tools + a native arm64 Homebrew with cmake and ninja +# 2. Rosetta 2 (root -- administrator, once) +# 3. x86_64 Homebrew at /usr/local, owned by the service account (git clone; the +# official installer is Apple-Silicon-only since 2026-09 and needs sudo) # -# The runner must also carry the labels [self-hosted, macos, arm64, crossplatform-build]. +# The service account has no sudo. Wherever root is needed this script prints the +# exact one-time command for an administrator and exits 1; re-run it afterwards. +# The runner must carry the labels [self-hosted, macos, arm64, crossplatform-build]. set -euo pipefail PREWARM=0 @@ -24,54 +23,110 @@ if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then echo "Run on an arm64 macOS host (cross-builds x86_64)." >&2; exit 1 fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ME="$(id -un)" +X64_PREFIX=/usr/local -echo "==> 1/3 native arm64 toolchain (cmake + ninja @ /opt/homebrew)" -if [[ ! -x /opt/homebrew/bin/brew ]]; then - echo " ERROR: native arm64 Homebrew not found at /opt/homebrew." >&2 - echo " Install it first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" >&2 +can_sudo() { sudo -n true 2>/dev/null; } +need_admin() { # $1 = what is missing; the rest = command(s) for the administrator + echo " NEEDS ADMINISTRATOR: $1" >&2 + echo " Run once from an admin account, then re-run this script as ${ME}:" >&2 + shift; printf ' %s\n' "$@" >&2 + exit 1 +} + +echo "==> 1/4 Xcode Command Line Tools (Intel formulae may build from source)" +xcode-select -p >/dev/null 2>&1 || need_admin "Command Line Tools are not installed" "xcode-select --install" +echo " $(xcode-select -p)" + +echo "==> 2/4 native arm64 toolchain (cmake + ninja)" +# Same discovery as the CI shim: the fleet has brew at /opt/homebrew or ~/.homebrew. +ARM_BREW="" +for p in /opt/homebrew "$HOME/.homebrew"; do + [[ -x "$p/bin/brew" ]] && { ARM_BREW="$p"; break; } +done +if [[ -z "$ARM_BREW" ]]; then + echo " ERROR: no native Homebrew at /opt/homebrew or ~/.homebrew." >&2 + echo " Either an administrator installs it (https://brew.sh -> /opt/homebrew), or clone" >&2 + echo " a per-user copy: git clone https://github.com/Homebrew/brew ~/.homebrew" >&2 exit 1 fi -# Install only what's missing -- don't force-upgrade the runner's existing tools. for t in cmake ninja; do - if [[ ! -x "/opt/homebrew/bin/$t" ]]; then - HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ENV_HINTS=1 /opt/homebrew/bin/brew install --quiet "$t" + if [[ ! -x "$ARM_BREW/bin/$t" ]]; then # install only what's missing; never upgrade + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 HOMEBREW_NO_ENV_HINTS=1 \ + "$ARM_BREW/bin/brew" install --quiet "$t" + fi + if [[ "$(file -b "$ARM_BREW/bin/$t")" == *arm64* ]]; then + echo " $t: $ARM_BREW (arm64) ok" + else + echo " ERROR: $ARM_BREW/bin/$t is not an arm64 binary" >&2; exit 1 fi - case "$(file -b "/opt/homebrew/bin/$t" 2>/dev/null)" in - *arm64*) echo " $t: arm64 (native) ok" ;; - *) echo " WARNING: /opt/homebrew/bin/$t missing or not arm64" >&2 ;; - esac done -echo "==> 2/3 Rosetta 2 (to run x86_64 output + configure probes)" +echo "==> 3/4 Rosetta 2 (runs the x86_64 brew, python and the built test binaries)" if /usr/bin/pgrep -q oahd; then - echo " already installed" -else - # Needs root; run under sudo so `set -e` doesn't abort on a Rosetta-less host. + echo " present" +elif can_sudo; then sudo softwareupdate --install-rosetta --agree-to-license +else + need_admin "Rosetta 2 is not installed" "sudo softwareupdate --install-rosetta --agree-to-license" fi -echo "==> 3/3 x86_64 Homebrew at /usr/local (source of x86_64 bottles)" -# `arch -x86_64` below is only needed to BOOTSTRAP Intel brew; once installed, -# /usr/local brew operates on its Intel prefix regardless of the invoking arch, -# so CI's native `exec /usr/local/bin/brew` (the cross-build shim) is equivalent. -if [[ -x /usr/local/bin/brew ]]; then - echo " already present ($(arch -x86_64 /usr/local/bin/brew --version | head -1))" +echo "==> 4/4 x86_64 Homebrew at ${X64_PREFIX} (source of Intel bottles)" +# Must exist and be writable: Homebrew's installer mkdir set + the git checkout dir. +X64_DIRS=(bin etc include lib sbin share var opt Cellar Caskroom Frameworks Homebrew + share/zsh share/zsh/site-functions var/homebrew var/homebrew/linked) +# Should be writable when present (keg.rb must_be_writable_directories); `brew doctor` +# only warns about these, and formulae we use don't write there, so warn likewise. +X64_DIRS_OPT=(etc/bash_completion.d lib/cps lib/pkgconfig share/aclocal share/doc share/info + share/locale share/man share/man/man{1..8} share/cps share/pwsh + share/pwsh/completions var/log) +BAD=(); WARN=() +for d in "${X64_DIRS[@]}"; do + [[ -d "${X64_PREFIX}/$d" && -w "${X64_PREFIX}/$d" ]] || BAD+=("${X64_PREFIX}/$d") +done +for d in "${X64_DIRS_OPT[@]}"; do + [[ ! -d "${X64_PREFIX}/$d" || -w "${X64_PREFIX}/$d" ]] || WARN+=("${X64_PREFIX}/$d") +done +if (( ${#WARN[@]} )); then + echo " WARNING: not writable by ${ME} (brew doctor will complain): ${WARN[*]}" >&2 + echo " fix if a formula needs it: sudo chown -R ${ME}:staff ${WARN[*]}" >&2 +fi +if (( ${#BAD[@]} )); then + DIRS="${X64_PREFIX}/{$(IFS=,; echo "${X64_DIRS[*]}")}" + # ${X64_PREFIX} itself stays root:wheel; only its children are handed to the account. + need_admin "${#BAD[@]} dir(s) under ${X64_PREFIX} missing or not writable by ${ME} (first: ${BAD[0]})" \ + "sudo mkdir -p ${DIRS}" \ + "sudo chown -R ${ME}:staff ${DIRS}" \ + "sudo chmod ug=rwx ${DIRS}" \ + "sudo chmod go-w ${X64_PREFIX}/share/zsh ${X64_PREFIX}/share/zsh/site-functions" +fi +if [[ -x "${X64_PREFIX}/bin/brew" ]]; then + echo " present ($("${X64_PREFIX}/bin/brew" --version | head -1))" else - echo " bootstrapping x86_64 Homebrew (will prompt for sudo)..." - arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + echo " bootstrapping (git clone; no sudo needed)..." + [[ -d "${X64_PREFIX}/Homebrew/.git" ]] || git clone https://github.com/Homebrew/brew "${X64_PREFIX}/Homebrew" + ln -sfn ../Homebrew/bin/brew "${X64_PREFIX}/bin/brew" + HOMEBREW_NO_ENV_HINTS=1 "${X64_PREFIX}/bin/brew" update --force --quiet + chmod -R go-w "${X64_PREFIX}/share/zsh" fi -if ! arch -x86_64 /usr/local/bin/brew config 2>/dev/null | grep -qi 'macOS:.*x86_64'; then - echo " WARNING: /usr/local brew does not report an x86_64 platform." >&2 +# No `arch -x86_64` anywhere: brew at /usr/local picks its x86_64 Ruby by prefix and +# serves Intel bottles whatever the caller's arch -- exactly how the CI shim calls it. +if "${X64_PREFIX}/bin/brew" config 2>/dev/null | grep -q 'macOS:.*x86_64'; then + echo " reports an x86_64 (Intel) platform ok" +else + echo " WARNING: ${X64_PREFIX} brew does not report an x86_64 platform." >&2 fi -if [[ "$PREWARM" == "1" ]]; then +if (( PREWARM )); then echo "==> x86_64 formulae pre-warm (optional; CI installs these anyway)" + # Intel macOS is Homebrew Tier 3 (no new bottles): a fresh formula may build from + # source. Keep the runner pinned afterwards -- never `brew upgrade` this prefix. CLANG_VER="$(xargs < "$REPO_ROOT/scripts/mrbind/clang_version_macos.txt")" { cat "$REPO_ROOT/requirements/macos.txt"; printf '%s\n' pybind11 make grep lld "llvm@${CLANG_VER}"; } \ - | HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ENV_HINTS=1 \ - xargs arch -x86_64 /usr/local/bin/brew install --quiet + | HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 HOMEBREW_NO_ENV_HINTS=1 \ + xargs "${X64_PREFIX}/bin/brew" install --quiet else echo "==> formulae pre-warm skipped (pass --prewarm to install them now)" fi -echo "==> done. Native x86_64 cross build recipe: macos/crossplatform-builds/README.md" +echo "==> done. See macos/crossplatform-builds/README.md"