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 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 fbb431684905..faaad0ff18cc 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: @@ -84,7 +91,347 @@ 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 --timeout 300 -- scripts/clone_submodules_linux.sh --skip-prebuilt-thirdparty + 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 + 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 @@ -169,6 +516,11 @@ jobs: -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: | diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 204a63341a28..b9bc90902f21 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 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 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..6e67c56c91f7 --- /dev/null +++ b/macos/crossplatform-builds/README.md @@ -0,0 +1,91 @@ +# 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.** `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=$(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 workflow job, the `config.yml` gate, the runner shim in the +workflow); the only non-CI source 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) | diff --git a/macos/crossplatform-builds/provision-runner.sh b/macos/crossplatform-builds/provision-runner.sh new file mode 100755 index 000000000000..42a137856a82 --- /dev/null +++ b/macos/crossplatform-builds/provision-runner.sh @@ -0,0 +1,77 @@ +#!/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, 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)" + +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 + # 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 + 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" 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 4fd3dcd54d6b..7ede5b3aca0e 100755 --- a/scripts/build_thirdparty.sh +++ b/scripts/build_thirdparty.sh @@ -57,6 +57,15 @@ 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