Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/actions/install-macos-thirdparty/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/build-test-distribute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
354 changes: 353 additions & 1 deletion .github/workflows/build-test-macos.yml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .github/workflows/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) }}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test-distribution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions cmake/Modules/ConfigureHomebrew.cmake
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
91 changes: 91 additions & 0 deletions macos/crossplatform-builds/README.md
Original file line number Diff line number Diff line change
@@ -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=<arm64-brew>/bin/ninja`. Verify with
`vmmap <clang-pid> | 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=<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) |
77 changes: 77 additions & 0 deletions macos/crossplatform-builds/provision-runner.sh
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 20 additions & 4 deletions scripts/build_source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"

Expand Down
20 changes: 16 additions & 4 deletions scripts/build_thirdparty.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading