Notcurses-Native.git | .github/actions/bundle-macos/ | action.yml edit
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/<libname>` —
# 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."