macos: bundle Homebrew dylibs into the .pkg framework; cover arm64 .pkg in test-distribution#6098
Open
Fedr wants to merge 11 commits into
Open
macos: bundle Homebrew dylibs into the .pkg framework; cover arm64 .pkg in test-distribution#6098Fedr wants to merge 11 commits into
Fedr wants to merge 11 commits into
Conversation
The macOS distribution leaves runtime deps to Homebrew via requirements/macos.txt, so when a bottle's SOMAJOR alias disappears (e.g. jsoncpp dropping libjsoncpp.27.dylib) every installed MeshLib binary fails to launch with a dyld "Library not loaded" error even though `brew install` succeeds. Add scripts/macos_bundle_dylibs.py to walk Mach-O files under the framework's bin/ and lib/, copy any Homebrew dep into lib/, rewrite the load commands to @rpath/<basename>, add an @executable_path/../lib (for executables) or @loader_path/. (for dylibs) rpath, and ad-hoc re-sign. System libs and libpython* are left as external references on purpose. Invoke it from scripts/distribution_apple.sh after `cmake --install`.
Bottles often reference internal siblings via @rpath/<name> (e.g. gdcm's libgdcmMEXD references @rpath/libsocketxx.1.2.dylib at @loader_path/.) rather than absolute paths. Initial bundling logic only chased absolute Homebrew paths and stopped at @rpath/..., leaving siblings unbundled and MeshViewer aborting on a fresh install with "Library not loaded: @rpath/libsocketxx.1.2.dylib". Track each bundled lib's original source directory and resolve any @rpath/X or @loader_path/X dep encountered inside that lib against the source dir first, then fall back to the standard Homebrew lib dir for cross-package references.
Modern Homebrew bottles install with LC_ID_DYLIB = @rpath/<basename>, so MeshLib binaries linked against them store the dep as @rpath/<basename> rather than an absolute /usr/local/... path. Previous bundling logic only chased absolute Homebrew paths, leaving libs like glfw (referenced as @rpath/libglfw.3.dylib by MeshViewer) unbundled. The installed .pkg then aborted with "Library not loaded: @rpath/libglfw.3.dylib" because none of the CMake-set rpaths resolve to Homebrew's location. Extend the BFS: for any @rpath/X or @loader_path/X dep whose basename isn't already shipped in the framework's lib tree (own libs + thirdparty libs copied in by distribution_apple.sh), look X up under any Homebrew lib dir (/usr/local/lib, /usr/local/opt/*/lib, /usr/local/Cellar/*/*/lib and the /opt/homebrew/ equivalents) and bundle it. Cache the lookup.
Homebrew bottles install both a version-tagged real file (libglfw.3.4.dylib) and SOMAJOR symlinks (libglfw.3.dylib) that point at it. Binaries link via the SOMAJOR symlink, so their LC_LOAD_DYLIB stores "@rpath/libglfw.3.dylib". Previous bundling logic resolved the symlink chain, copied the file under the realpath's basename (libglfw.3.4.dylib), and left the binary still asking dyld for libglfw.3.dylib -- which is no longer present anywhere on a fresh test machine. Same shape for fmt, tbb, spdlog, openvdb, opencascade, etc. Plumb the referrer's requested basename through bundle_from so the dst inside the framework lib/ matches the name the load command actually references. install_name_tool -id (run later in the rewrite pass) then stamps the bundled file's own id with that same name.
Hardcoded HOMEBREW_PREFIXES only covered /usr/local and /opt/homebrew, but the arm64 Release build runs on a self-hosted macos runner whose Homebrew lives at /Users/runner/.homebrew. There the script silently bundled 0 dylibs (arm .pkg size unchanged at 39 MB while x64 doubled to 82 MB), leaving the arm64 .pkg as fragile to bottle drift as before. Call `brew --prefix` at startup and prepend the result to the prefix tuple. Falls back to the defaults if brew is not on PATH (e.g. for local syntax checks on non-mac dev machines).
test-distribution validated only the x64 macOS .pkg, so the arm64 .pkg was uploaded to releases without any end-to-end install/launch check -- mirroring the gap that linux-x64-test / linux-arm64-test long ago closed for Linux. Split the single macos-test job into macos-x64-test (existing, macos-15-intel, *x64.pkg) and a new macos-arm64-test (macos-latest github-hosted Apple Silicon, *arm.pkg). Steps are otherwise identical: install via sudo installer, brew install runtime deps from the bundled requirements/macos.txt, run MeshViewer / meshconv, build C and C++ examples against the installed framework.
2 tasks
oitel
reviewed
May 15, 2026
| cmake --build build | ||
|
|
||
|
|
||
| macos-arm64-test: |
Contributor
There was a problem hiding this comment.
Can it be generalized using a matrix instead?
| return sorted(p for p in root.rglob("*") if is_macho(p)) | ||
|
|
||
|
|
||
| def otool_L(p: Path) -> list[str]: |
Contributor
There was a problem hiding this comment.
Could this self-written script be replaced with an already existing external utility like dylibbundler or by extending the CMake config?
Comment on lines
+150
to
+154
| break | ||
| if result is not None: | ||
| break | ||
| if result is not None: | ||
| break |
- Collapse the macos-x64-test / macos-arm64-test pair in test-distribution.yml into a single matrixed macos-test job. Steps were identical other than runner and *.pkg pattern. - Rename otool_L to get_load_dylibs; it's a wrapper that lists the load-command dylibs and the new name says so. - Drop the nested break-and-check pattern in _resolve_homebrew_basename and just return early on first hit. - Note in the script docstring why we didn't lean on dylibbundler or CMake BundleUtilities (referrer-basename preservation, runtime Homebrew-prefix detection for the arm64 self-hosted runner, intra-bottle @rpath sibling resolution, no extra build-time dependency).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The macOS
.pkgshipped only the MeshLib binaries and offloaded every runtime dep to Homebrew viarequirements/macos.txt. When a Homebrew bottle drops aSOMAJORsymlink (e.g.jsoncpp 1.9.6no longer shipslibjsoncpp.27.dylib), every binary in an already-published.pkgfails to launch with adyld: Library not loadedabort even thoughbrew installsucceeds. The triggering breakage on master: run 25858581152, job 75992495208.Fix
Bundle Homebrew dylib deps into the framework so the
.pkgis self-contained for the fragile bits. The.pkgno longer relies on any Homebrew symlink shape — only on Homebrew being present forlibpython3.10and the toolchain.scripts/macos_bundle_dylibs.py(new)Walks every Mach-O under
MeshLib.framework/Versions/<X.Y.Z.W>/{bin,lib}and, for each load command:/usr/local/...,/opt/homebrew/..., or whateverbrew --prefixreports) — copy the resolved real file into.../lib/and rewrite theLC_LOAD_DYLIBto@rpath/<basename>.@rpath/<X>/@loader_path/<X>— modern Homebrew bottles install with@rpath/<basename>, so the load command never mentions/usr/local/.... IfXisn't a MeshLib-shipped lib, resolve it via:libsocketxx.1.2.dylib).lib,opt/*/lib,Cellar/*/*/lib)./usr/lib,/System) andlibpython*/ Python framework — left as external references so the host system's Python is used.The dst basename is the referrer's requested name, not the realpath's name — so
libglfw.3.4.dylib(the realpath after symlink resolution) is bundled aslibglfw.3.dylib(what the binary asks for via@rpath/libglfw.3.dylib). Same shape for fmt / tbb / spdlog / openvdb / opencascade etc. Otherwise dyld would not find the bundled copy by the name it actually asks for.After copying, each Mach-O is rewritten via
install_name_tool(id, load commands), an rpath is added (@executable_path/../libfor executables,@loader_path/.for dylibs), and the file is ad-hoc re-signed viacodesign -s -.Wired into
scripts/distribution_apple.shright aftercmake --install.test-distribution.ymlAdds a
macos-arm64-testjob mirroringmacos-x64-testonmacos-latest(github-hosted Apple Silicon) pulling*arm.pkg. Previously only the x64.pkgwas end-to-end-tested, leaving arm64 packaging regressions invisible.Why this is robust to future Homebrew drift
Once a
.pkgis built, the binaries inside reference only@rpath/<basename>for their bundled deps; dyld resolves that to the copy underVersions/Current/lib/, which is part of the.pkgpayload. Future SOMAJOR bumps or alias removals in Homebrew cannot break already-installed binaries.requirements/macos.txtand thexargs brew installstep intest-distribution.ymlare kept untouched on purpose — they're harmless once load commands are@rpath/...(bundled libs win via the new rpath), and keeping them avoids churn in the dev install / NuGet patch paths.Validated in CI
Latest run 25916902755 — all green including both new end-to-end tests:
macos-build-test (x64, Release)✅macos-build-test (arm64, Release)✅macos-build-test (arm64, Debug)✅test-distribution / macos-x64-test✅ — installsmeshlib_*x64.pkg, launchesMeshViewerfrom the installed frameworktest-distribution / macos-arm64-test✅ — same formeshlib_*arm.pkgon Apple Silicon.pkg size impact
The ~40 MB delta on each arch is the bundled Homebrew runtime tree (boost, opencascade libTK*, openvdb, tbb, fmt, spdlog, jsoncpp, glfw, libpng, libtiff, libharu, etc.) — payload that previously the user had to install via
brew installand that broke whenever a bottle's SOMAJOR symlink changed.Iteration log (debugging the bundling design in CI)
For future spelunkers, each green push uncovered the next hidden assumption in the original design:
@rpath/libsocketxx.1.2.dylibfrom libgdcmMEXD).@rpath/Xresolution via source-dir lookup → bundled libsocketxx, but MeshViewer still failed on@rpath/libglfw.3.dylib.@rpath/Xas bundleable even on MeshLib's own binaries (since modern bottles install with@rpath/<basename>install_name, so MeshLib binaries store@rpath/...too) — but bundledlibglfw.3.4.dylib(the realpath) instead oflibglfw.3.dylib(the requested basename)./Users/runner/.homebrew, not/usr/local/or/opt/homebrew/.brew --prefix. Both archs now bundle ~40 MB of dylibs and pass the full end-to-end test.