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."