Notcurses-Native.git | .github/actions/bundle-macos/ | action.yml


name: bundle-macos description: >- Stage the three notcurses libs into `bundle/`, run dylibbundler to resolve and copy ffmpeg sibling dylibs with @loader_path rewrites, create SONAME + unversioned symlinks, strip + ad-hoc-codesign every shipped dylib, and (if a deployment-target is given) audit every bundled dylib's LC_BUILD_VERSION minos to ensure it's <= the target. Macros calling this action must already have: - notcurses built under $NOTCURSES_SRC_DIR/build/ (the value written by scripts/ci/fetch-notcurses-source.sh into $GITHUB_ENV) - `bundle/` writable (empty is fine; action creates it if absent) - dylibbundler on PATH (from brew) inputs: audit-min-macos: description: >- If set, every shipped dylib's LC_BUILD_VERSION minos is verified to be <= this value (e.g. '10.15' for the x86_64 lane to catch a brew bottle that slipped through targeting 12.0+). Empty = skip the audit (arm64 lane stays on 11.0 implicitly). required: false default: '' extra-search-path: description: >- Additional `-s` search path for dylibbundler. Both macOS lanes source-build their ffmpeg chain (libdav1d / libvpx / libopus / ffmpeg) plus libunistring into a per-arch workspace cache dir — x86_64 adds ncurses / libdeflate on top, since brew's x86_64 bottles target macOS 14+ and the bundle must load on 10.15. Those .dylibs carry @rpath install_names, so dylibbundler has to be told where they live in addition to $NOTCURSES_SRC_DIR/build. Empty default = search only the notcurses build dir plus whatever system paths dylibbundler already knows. required: false default: '' runs: using: composite steps: - name: Stage + relocate libs (dylibbundler + symlinks + codesign) shell: bash env: EXTRA_SEARCH_PATH: ${{ inputs.extra-search-path }} run: | set -euxo pipefail : "${NOTCURSES_SRC_DIR:?must be set — call scripts/ci/fetch-notcurses-source.sh first}" mkdir -p bundle # Only stage the two ENTRY-POINT libs: # - libnotcurses (full, with multimedia) # - libnotcurses-ffi (static-inline C wrappers; # NOT a dep of libnotcurses) # # libnotcurses-core gets auto-copied into bundle/ by # dylibbundler as a dependency of libnotcurses. Pre-staging # it here would cause dylibbundler to try `cp bundle/x.dylib # bundle/x.dylib` and fail with "identical files not copied" # because dylibbundler doesn't recognise already-bundled deps. for lib in libnotcurses libnotcurses-ffi; do real=$(find "$NOTCURSES_SRC_DIR/build" -name "${lib}.*.dylib" -type f ! -type l | sort | tail -1) if [[ -z "$real" ]]; then echo "❌ Could not find real file for $lib" exit 1 fi cp "$real" "bundle/$(basename "$real")" done # Run dylibbundler ONCE with both entry-points as `-x` args # so it builds a single deduped dep graph and copies each # transitive dep exactly once. Earlier two-call pattern # (one invocation per entry-point) broke on the x86_64 lane: # the second invocation re-resolved deps already copied to # `bundle/` (dylibbundler implicitly searches its destination), # then tried `cp -f bundle/libdeflate.0.dylib # bundle/libdeflate.0.dylib`, which macOS BSD cp rejects as # "identical files, not copied". Source-built dylibs trigger # this because their install_name is `@rpath/` — # they resolve to the already-bundled copy on the second pass. # brew bottles dodged it because their install_names are # absolute Cellar paths (no @rpath round-tripping). # # -s flags: # * $NOTCURSES_SRC_DIR/build — for libnotcurses-core.3.dylib # (cmake leaves it as a SONAME symlink in the build dir). # * EXTRA_SEARCH_PATH (x86_64 lane only): the lib dir of # our workspace cache, where source-built ffmpeg / # ncurses / libunistring / libdeflate live. dylibbundler # resolves their @rpath references via this -s. bundler_paths=( -s "$NOTCURSES_SRC_DIR/build" ) if [[ -n "${EXTRA_SEARCH_PATH:-}" ]]; then bundler_paths+=( -s "$EXTRA_SEARCH_PATH" ) fi x_args=() for lib in libnotcurses libnotcurses-ffi; do real_in_bundle=$(ls bundle/${lib}.*.*.dylib | head -1) x_args+=( -x "$real_in_bundle" ) done dylibbundler -of -b -ns \ "${x_args[@]}" \ "${bundler_paths[@]}" \ -d bundle/ \ -p '@loader_path/' # Create SONAME + unversioned symlinks AFTER dylibbundler # finishes, for each of the three notcurses libs now in # bundle/. NativeCall looks up the unversioned name # (libnotcurses.dylib) via %?RESOURCES; dylibbundler's # rewritten refs look for the SONAME-style name # (libnotcurses-core.3.dylib). Both resolve to the real # versioned file via these symlinks. cd bundle for lib in libnotcurses libnotcurses-core libnotcurses-ffi; do real=$(ls ${lib}.*.*.dylib 2>/dev/null | head -1) if [[ -z "$real" ]]; then echo "⚠️ No real file for $lib in bundle/ — dylibbundler may have used a different name." ls ${lib}* 2>/dev/null || true exit 1 fi major=$(echo "$real" | sed -E "s|^${lib}\.([0-9]+)\..*|\1|") ln -sf "$real" "${lib}.${major}.dylib" ln -sf "$real" "${lib}.dylib" done cd .. # Strip debug symbols from every real dylib we ship (skip symlinks). find bundle -name '*.dylib' -type f ! -type l -exec strip -x {} \; 2>/dev/null || true # Re-sign every modified dylib. dylibbundler's # install_name_tool calls invalidate the linker-emitted # ad-hoc signature; strip invalidates it again. macOS # Sequoia (14+) hard-rejects dylibs with stale signatures # via SIGKILL at first page access — load looks like it # succeeds via dyld, then the kernel kills the process # the moment any code from the bad page executes. Ad-hoc # resign produces a fresh signature matching the modified # bytes; no developer cert needed. find bundle -name '*.dylib' -type f ! -type l \ -exec codesign --force --sign - --timestamp=none {} \; # Sanity-check: every dylib should now verify cleanly. # Loud failure here = wrong arch / corrupt artifact / etc. for f in bundle/*.dylib; do [[ -L "$f" ]] && continue codesign --verify "$f" || { echo "❌ $f failed verify"; exit 1; } done echo "--- bundle contents ---" ls -la bundle/ echo "--- otool dump (libnotcurses) ---" otool -L bundle/libnotcurses.dylib - name: Audit dylib LC_BUILD_VERSION minos <= target if: inputs.audit-min-macos != '' shell: bash run: | set -euo pipefail target='${{ inputs.audit-min-macos }}' fail=0 # vtool -show emits a block like: # Load command N # cmd LC_BUILD_VERSION # cmdsize 32 # platform macos # minos 10.15 # sdk 15.0 # ntools 1 # We grep for `minos` from each LC_BUILD_VERSION block. The # older LC_VERSION_MIN_MACOSX has the same `minos` line shape. for f in bundle/*.dylib; do [[ -L "$f" ]] && continue minos=$(vtool -show "$f" 2>/dev/null \ | awk '/^[[:space:]]*minos[[:space:]]/{print $2; exit}') if [[ -z "$minos" ]]; then echo "::warning::$f has no LC_BUILD_VERSION / LC_VERSION_MIN_MACOSX — pre-10.14 build?" continue fi # `sort -V` sorts as versions; if target sorts after minos # then minos > target, which is the failure case. if [[ "$(printf '%s\n%s\n' "$minos" "$target" | sort -V | head -1)" != "$minos" ]]; then echo "::error file=$f::minos=$minos > target=$target — won't load on macOS $target." fail=1 else echo "ok: $f minos=$minos (<= $target)" fi done if (( fail != 0 )); then echo "❌ One or more dylibs have minos > $target." echo " Likely a brew bottle targeting newer macOS slipped through." echo " Either downgrade the dep (brew install --build-from-source)" echo " or rebuild it with MACOSX_DEPLOYMENT_TARGET=$target." exit 1 fi echo "✅ All bundled dylibs target macOS $target or older." - name: Audit bundle self-containment (otool) shell: bash run: | set -euo pipefail # For every bundled dylib, walk its LC_LOAD_DYLIB entries # via `otool -L`. Each entry must be one of: # * @loader_path/X with X present as a file in bundle/ # * @rpath/X (treated the same — dylibbundler rewrote # paths but @rpath sometimes survives) # * /usr/lib/X or /System/* — Apple-provided, present # on every macOS install regardless of brew status # Anything else (e.g. /opt/homebrew/Cellar/..., /usr/local/... # /Users/.../_ci-cache/... from a leaked source-build path) # means the bundle references something the user's machine # might not have, and we'd silently work in CI (brew is # installed on the runner) but fail at user install. Block # the release. fail=0 for f in bundle/*.dylib; do [[ -L "$f" ]] && continue while IFS= read -r line; do # otool -L format: "\tPATH (compatibility version X, current version Y)" # First line of output is the binary's own name + colon, skip it. [[ "$line" =~ ^[^[:space:]] ]] && continue dep=$(printf '%s' "$line" \ | sed -E 's/^[[:space:]]+//; s/[[:space:]]*\(compatibility[^)]*\)$//') [[ -z "$dep" ]] && continue # Skip the binary's own LC_ID_DYLIB entry (matches its # install_name; not a load command). self_id=$(otool -D "$f" 2>/dev/null | tail -n 1 | sed 's/^[[:space:]]*//') [[ "$dep" == "$self_id" ]] && continue case "$dep" in @loader_path/*|@rpath/*|@executable_path/*) # Resolve to a basename and look in bundle/. base="${dep##*/}" if [[ ! -e "bundle/$base" ]]; then echo "::error file=$f::references '$dep' but 'bundle/$base' does not exist" fail=1 fi ;; /usr/lib/*|/System/*|/System/Volumes/*) # Apple-provided system path. Always available on # any macOS install. OK. ;; /opt/homebrew/*|/usr/local/Cellar/*|/usr/local/opt/*) # Brew bottle path — leaks brew install state into # the bundle. Users without brew would see dyld # fail to load with "Library not loaded". echo "::error file=$f::leaks brew path '$dep' — dylibbundler missed rewriting this" fail=1 ;; "$GITHUB_WORKSPACE"/*|/Users/*/_ci-cache/*|/work/*) # Build-time cache path leaked through — same # problem class as brew paths. echo "::error file=$f::leaks build-cache path '$dep' — dylibbundler missed rewriting this" fail=1 ;; *) # Anything else is suspect. Could be a one-off Apple # framework path, but better to fail loud and # whitelist explicitly if needed. echo "::error file=$f::references unknown-class path '$dep'" fail=1 ;; esac done < <(otool -L "$f") done if (( fail != 0 )); then echo echo "❌ Bundle audit failed: one or more dylibs reference" echo " paths outside bundle/ or the macOS system whitelist." echo " The bundle would not load on a user's machine." exit 1 fi echo "✅ Bundle self-containment audit passed: every dylib's" echo " LC_LOAD_DYLIB entries resolve inside bundle/ or to" echo " Apple system libraries."