diff --git a/.github/actions/install-llvm-pgo-keg/action.yml b/.github/actions/install-llvm-pgo-keg/action.yml index 1c7863ab974f..76ec34d8c22e 100644 --- a/.github/actions/install-llvm-pgo-keg/action.yml +++ b/.github/actions/install-llvm-pgo-keg/action.yml @@ -17,8 +17,17 @@ runs: echo "llvm-pgo keg already present" exit 0 fi - # The published kegs are built for the standard prefixes only. - if [[ "$(uname -m)" == "arm64" ]]; then + # The published kegs are built for the standard prefixes only, and each one is + # tied to a prefix: /opt/homebrew is arm64, /usr/local is x86_64. Pick by prefix, + # not by `uname -m`, so the x86_64-on-arm64 cross runner (arm64 host, Intel brew + # at /usr/local) gets the Intel keg. Custom prefixes fall back to the host arch; + # those machines carry the keg already and returned above. + case "$BP" in + /usr/local) KEG_ARCH=x86_64 ;; + /opt/homebrew) KEG_ARCH=arm64 ;; + *) KEG_ARCH="$(uname -m)" ;; + esac + if [[ "$KEG_ARCH" == "arm64" ]]; then NEED=/opt/homebrew ASSET=llvm-pgo-22.1.8_2-arm64/llvm-pgo-22.1.8_2.arm64_opt-homebrew.tar.gz SHA=94f76fb55ec64490605d0e68115259426a428a78f269f7e9160726d90a22e197 @@ -38,7 +47,8 @@ runs: - name: Install the PGO ld64.lld into the keg shell: bash run: | - KEG="$(brew --prefix)/Cellar/llvm-pgo/22.1.8_2" + BP="$(brew --prefix)" + KEG="$BP/Cellar/llvm-pgo/22.1.8_2" # Homebrew has no `lld` bottle for Intel macOS since it became a Tier 3 # configuration, and brew's llvm@22 carries no ld64.lld either, so the linker # comes from our own release. Next to clang++, which resolves `-fuse-ld=lld` @@ -46,7 +56,13 @@ runs: if [[ -x "$KEG/bin/ld64.lld" ]]; then echo "ld64.lld already in the keg" else - if [[ "$(uname -m)" == "arm64" ]]; then + # Match the keg's architecture, which follows the prefix (see above). + case "$BP" in + /usr/local) KEG_ARCH=x86_64 ;; + /opt/homebrew) KEG_ARCH=arm64 ;; + *) KEG_ARCH="$(uname -m)" ;; + esac + if [[ "$KEG_ARCH" == "arm64" ]]; then ASSET=ld64.lld-22.1.8-pgo.arm64-macos13 SHA=8abf0ba324dea357ae4fbd8baee835353b560f62e9b1d001588a6df888755550 else 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 \ diff --git a/.github/workflows/build-test-distribute.yml b/.github/workflows/build-test-distribute.yml index 792325de47d2..20fcda13e9fc 100644 --- a/.github/workflows/build-test-distribute.yml +++ b/.github/workflows/build-test-distribute.yml @@ -177,6 +177,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'}} + config_matrix: ${{ needs.config.outputs.macos_config_matrix }} secrets: inherit update-win-version: @@ -427,6 +428,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/build-test-macos.yml b/.github/workflows/build-test-macos.yml index 984e4dcfd11c..b89e5f94beb9 100644 --- a/.github/workflows/build-test-macos.yml +++ b/.github/workflows/build-test-macos.yml @@ -31,52 +31,24 @@ on: default: false 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 - # mrbind and the bindings use the PGO keg fetched from - # MeshInspector/toolchains releases; the app stays on AppleClang. - llvm-prefix-template: BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2 - # 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 - # mrbind and the bindings use the PGO keg fetched from - # MeshInspector/toolchains releases; the app stays on AppleClang. - llvm-prefix-template: BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2 - # 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 - # The -O3+PGO+ThinLTO keg, built on each runner via the local/pgo - # tap (llvm.rb with ENV.O3, installed with --build-bottle). - llvm-prefix-template: BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2 - cxx-compiler-template: BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2/bin/clang++ - c-compiler-template: BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2/bin/clang - runner: [ self-hosted, macos, arm64, build ] # any macos version - instance: self-hosted-arm + matrix: ${{ fromJSON(inputs.config_matrix) }} permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout @@ -88,6 +60,44 @@ jobs: - name: macOS Diagnostics uses: ./.github/actions/macos-diagnostics + # Before `Resolve LLVM prefix` and every other `brew --prefix`: the shim makes + # them resolve the x86_64 /usr/local brew whose bottles this target links. + - 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 }}" + echo "CMAKE_MAKE_PROGRAM=$ARM_BREW/bin/ninja" + echo "CMAKE_PREFIX_PATH=/usr/local" + echo "MESHLIB_HOMEBREW_PREFIX=/usr/local" + echo "HOMEBREW_DIR=/usr/local" + echo "PKG_CONFIG_PATH=$(/usr/local/bin/brew --prefix python@$PYVER)/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + } >> "$GITHUB_ENV" + # LLVM_PREFIX selects the LLVM keg for the build and the mrbind scripts; # resolved per machine since the self-hosted label set spans runners # with different homebrew prefixes. @@ -168,6 +178,11 @@ jobs: -DMR_CXX_STANDARD=23 -DMESHLIB_BUILD_GENERATED_C_BINDINGS=OFF -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: Wait for C bindings if: ${{ inputs.mrbind_c }} @@ -330,8 +345,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 @@ -341,7 +358,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 cff6cadaa4ab..da22f163f7bb 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -41,6 +41,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' }} @@ -78,6 +81,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 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: value: ${{ !( jobs.prepare-config.outputs.tag-update-doc-only == 'true' || jobs.prepare-config.outputs.tag-disable-emscripten == 'true' ) }} @@ -93,6 +99,7 @@ jobs: ubuntu_arm64_config_matrix: ${{ steps.set-ubuntu-arm64-matrix.outputs.matrix }} 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 @@ -113,6 +120,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 @@ -351,3 +359,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..af65d14ad521 --- /dev/null +++ b/.github/workflows/matrix/macos-config.json @@ -0,0 +1,45 @@ +{ + "include": [ + { + "arch": "x64", + "config": "Release", + "compiler": "AppleClang", + "cxx-compiler-template": "/usr/bin/clang++", + "c-compiler-template": "/usr/bin/clang", + "llvm-prefix-template": "BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2", + "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", + "llvm-prefix-template": "BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2", + "runner": ["macos-14"], + "instance": "macos-14" + }, + { + "arch": "arm64", + "config": "Debug", + "compiler": "brew-llvm22", + "cxx-compiler-template": "BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2/bin/clang++", + "c-compiler-template": "BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2/bin/clang", + "llvm-prefix-template": "BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2", + "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", + "llvm-prefix-template": "BREW_PREFIX/Cellar/llvm-pgo/22.1.8_2", + "runner": ["self-hosted", "macos", "arm64", "crossplatform-build"], + "instance": "self-hosted-arm-x64-native", + "cross-osx-arch": "x86_64" + } + ] +} diff --git a/.github/workflows/test-distribution.yml b/.github/workflows/test-distribution.yml index 8894ca4af4c2..d81b45cdcb77 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 @@ -244,29 +251,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' + # 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 diff --git a/cmake/Modules/ConfigureHomebrew.cmake b/cmake/Modules/ConfigureHomebrew.cmake index 923850d0ee70..fca385ea8ac7 100644 --- a/cmake/Modules/ConfigureHomebrew.cmake +++ b/cmake/Modules/ConfigureHomebrew.cmake @@ -1,17 +1,20 @@ 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). + # Falls back to `brew --prefix` for the common native case. + IF(NOT HOMEBREW_PREFIX) + execute_process( + COMMAND brew --prefix + OUTPUT_VARIABLE HOMEBREW_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + 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}") 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..bf38bf4227a6 --- /dev/null +++ b/macos/crossplatform-builds/README.md @@ -0,0 +1,150 @@ +# 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 tests them. + +## Where it lives in CI + +- 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. 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 +> `-x64-cross`). + +## How it works + +- **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`. + +## 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. **mrbind and the bindings.** They use the `llvm-pgo` keg, which on this leg is the **x86_64** keg + in `/usr/local/Cellar` (see below), so `mrbind` and the bindings compiler are translated. + `scripts/mrbind/generate.mk` has no macOS target-arch flag either; it yields an x86_64 + `mrmeshpy.so` because the x86_64 GNU `make` from `/usr/local` is first on `PATH` and its children + inherit the translated execution. It works, but it is not native — a candidate follow-up. +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 + 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. **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. +6. **The LLVM keg follows the Homebrew prefix, not the host CPU.** The shim makes `brew --prefix` + report `/usr/local`, so `LLVM_PREFIX` resolves to the Intel keg there, which is the right one: the + bindings are x86_64 and Homebrew ships no Intel `lld` any more. `install-llvm-pgo-keg` therefore + selects its published asset by prefix (`/usr/local` → x86_64, `/opt/homebrew` → arm64) rather than + by `uname -m`, which on this runner reports the arm64 *host*. + +## Provisioning a runner + +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 + +```bash +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:$(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=$(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 +``` + +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). + +## Source changes this requires + +Everything else is CI wiring (the matrix JSON, the `config.yml` gate, the runner shim in the +workflow); the remaining changes are: + +| 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) | +| Key the thirdparty cache on the target arch | [`install-macos-thirdparty`](../../.github/actions/install-macos-thirdparty/action.yml) | +| Pick the published LLVM keg by Homebrew prefix instead of `uname -m` (gotcha 6) | [`install-llvm-pgo-keg`](../../.github/actions/install-llvm-pgo-keg/action.yml) | diff --git a/macos/crossplatform-builds/provision-runner.sh b/macos/crossplatform-builds/provision-runner.sh new file mode 100755 index 000000000000..4f90282b04e2 --- /dev/null +++ b/macos/crossplatform-builds/provision-runner.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# 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 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. 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 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 +[[ "${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)" +ME="$(id -un)" +X64_PREFIX=/usr/local + +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 +for t in cmake ninja; do + 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 +done + +echo "==> 3/4 Rosetta 2 (runs the x86_64 brew, python and the built test binaries)" +if /usr/bin/pgrep -q oahd; then + 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 "==> 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 (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 +# 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 )); 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_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. See macos/crossplatform-builds/README.md" diff --git a/scripts/build_source.sh b/scripts/build_source.sh index 6a7298924770..59a9ec6982e6 100755 --- a/scripts/build_source.sh +++ b/scripts/build_source.sh @@ -39,6 +39,18 @@ MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS:-}" # Extra flags for `cmake --build`. MR_CMAKE_BUILD_OPTIONS="${MR_CMAKE_BUILD_OPTIONS:-}" +# 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 +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 @@ -104,10 +116,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}" diff --git a/scripts/build_thirdparty.sh b/scripts/build_thirdparty.sh index 6d1f2ee619ab..03a9ececd49f 100755 --- a/scripts/build_thirdparty.sh +++ b/scripts/build_thirdparty.sh @@ -57,6 +57,15 @@ MR_CMAKE_OPTIONS="${MR_CMAKE_OPTIONS} \ -D CMAKE_BUILD_TYPE=Release \ " +# 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 +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 +129,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