Notcurses-Native.git | scripts/ci/ | bundle-elf.sh edit


#!/usr/bin/env bash
# Stage notcurses .so libs into bundle/, recursively walk ldd to copy
# ffmpeg sibling shared libs into the same directory, set DT_RUNPATH
# to $ORIGIN on every shipped .so so they find each other at load
# time, and strip.
#
# Honours $EXTRA_SKIP_LIBS (space-separated glob patterns) — alpine
# musl callers pass 'ld-musl-*.so.1 libc.musl-*.so.1' so we don't
# bundle musl's loader / libc (alpine systems already have them;
# bundling would produce a 2-libc conflict).
#
# Reads the notcurses build dir from $NOTCURSES_SRC_DIR/build —
# caller (build-linux-*.sh) sets this after fetch-notcurses-source.sh
# resolves the pinned SHA.
#
# Designed to run INSIDE a build container (manylinux2014 for glibc,
# alpine:3.20 for musl) — needs ldd, patchelf, strip on PATH. The
# extra LD_LIBRARY_PATH the caller may have exported (to point at
# workspace-cached ffmpeg) is preserved through to ldd via the env.

set -euxo pipefail

: "${NOTCURSES_SRC_DIR:?must be set — call scripts/ci/fetch-notcurses-source.sh first}"

# Capture the workspace root BEFORE we cd into the build dir, so
# absolute-path copies don't depend on `cd ../../..` relative
# math (the build dir lives under _ci-cache/notcurses-source/<sha>/
# which isn't a fixed depth from the workspace).
workspace="$PWD"
mkdir -p "$workspace/bundle"

(
  cd "$NOTCURSES_SRC_DIR/build"
  for lib in libnotcurses libnotcurses-core libnotcurses-ffi; do
    found=$(find . -name "${lib}.so.*" -type f | sort | tail -1)
    [[ -z "$found" ]] && found=$(find . -name "${lib}.so" -type f | head -1)
    cp "$found" "$workspace/bundle/${lib}.so"
  done
)

# Skiplist: stay-dynamic system libraries. Bundling these would
# produce crt clashes / loader conflicts.
is_system_lib() {
  local name="$1"
  case "$name" in
    libc.so.*|libm.so.*|libpthread.so.*|libdl.so.*| \
    librt.so.*|libstdc++.so.*|ld-linux*|libgcc_s.so.*| \
    libresolv.so.*|libnsl.so.*|linux-vdso.so.*| \
    libutil.so.*|libcrypt.so.*)
      return 0 ;;
  esac
  # Caller-supplied extra patterns (alpine musl loader names, etc.).
  if [[ -n "${EXTRA_SKIP_LIBS:-}" ]]; then
    local pat
    for pat in $EXTRA_SKIP_LIBS; do
      # shellcheck disable=SC2053
      [[ "$name" == $pat ]] && return 0
    done
  fi
  return 1
}

process_deps() {
  local lib="$1"
  # Strip any `(...)` trailing marker from ldd output and skip
  # non-absolute paths (catches `not found`, bare filenames, etc.).
  ldd "$lib" 2>/dev/null \
    | sed -n 's/ (.*)$//; s/^[[:space:]]*\([^ ]\+\) => \(.\+\)$/\1\t\2/p' \
    | while IFS=$'\t' read -r name path; do
        is_system_lib "$name" && continue
        [[ -z "$path" ]] && continue
        [[ "$path" != /* && "$path" != ?:[/\\]* ]] && continue
        if [[ ! -f "bundle/$name" ]]; then
          cp "$path" "bundle/$name"
          echo "Bundled: $name  (from $path)"
          process_deps "bundle/$name"  # recurse
        fi
      done
}

for lib in bundle/libnotcurses.so bundle/libnotcurses-core.so bundle/libnotcurses-ffi.so; do
  process_deps "$lib"
done

# Set rpath to $ORIGIN on every shipped .so so they find siblings
# in their own directory at load time. NO `|| true` here — silent
# patchelf failures would leave dylibs falling back to system paths
# at user-runtime; we fail loud instead.
find bundle -name '*.so*' -type f | while read f; do
  patchelf --set-rpath '$ORIGIN' "$f"
done

find bundle -name '*.so*' -type f -exec strip --strip-unneeded {} \; 2>/dev/null || true

# Hard audit: every non-symlink .so in bundle/ must now report
# RUNPATH=$ORIGIN. patchelf above can't set it if a binary has no
# PT_DYNAMIC, or already has DT_RPATH instead of DT_RUNPATH, or
# similar quirk — and the per-file failures get masked by the
# pipeline. Verify and fail loudly here.
echo "--- RUNPATH audit ---"
fail=0
for f in $(find bundle -name '*.so*' -type f ! -type l); do
    rpath=$(patchelf --print-rpath "$f" 2>/dev/null)
    if [[ "$rpath" != '$ORIGIN' ]]; then
        echo "::error file=$f::RUNPATH is '$rpath', expected '\$ORIGIN'"
        fail=1
    else
        echo "ok: $f RUNPATH=\$ORIGIN"
    fi
done
if (( fail != 0 )); then
    echo "❌ One or more bundled .so files don't have RUNPATH=\$ORIGIN."
    echo "   They'll fall back to /lib64/ etc. at user-runtime instead"
    echo "   of finding bundle siblings. Investigate the patchelf step."
    exit 1
fi

echo "--- bundle contents ---"
ls -la bundle/

# Diagnostic ldd in CLEAN env: the build set LD_LIBRARY_PATH +
# CMAKE_PREFIX_PATH etc. pointing at $CACHE/lib so cmake/configure
# could find source-built deps. Those env vars are still set when
# bundle-elf.sh runs, so a plain `ldd bundle/libnotcurses.so` would
# resolve via LD_LIBRARY_PATH instead of the binary's $ORIGIN
# RUNPATH, painting a misleading picture (deps showing as resolving
# to $CACHE/lib that won't exist on user machines). `env -i` wipes
# all env vars except PATH, mimicking the user-runtime ld.so view.
echo "--- ldd in clean env (user-runtime view) ---"
LDD_OUT=$(env -i PATH=/usr/bin:/bin ldd bundle/libnotcurses.so 2>&1 || true)
echo "$LDD_OUT"

# Hard audit: every "lib.so.N => path" line must have its path
# inside bundle/ UNLESS the lib is a system library we deliberately
# don't bundle (libc, libm, libpthread, libdl, librt, libgcc_s,
# linux-vdso, libstdc++, libresolv, libnsl, libutil, libcrypt,
# libcrypto for Linux; ld-linux* / ld-musl* loaders).
echo "--- non-system dep resolution audit ---"
fail=0
while IFS= read -r line; do
    # Skip lines that aren't "X => Y" form (linux-vdso prints just
    # "linux-vdso.so.1 (0x...)" without an arrow — those are system).
    if ! [[ "$line" =~ \=\> ]]; then
        continue
    fi
    # "    libfoo.so.N => /some/path (0x...)" — strip leading
    # whitespace, split on ' => '.
    libname="${line#"${line%%[![:space:]]*}"}"  # ltrim
    libname="${libname%% =>*}"
    rest="${line#*=> }"
    libpath="${rest% \(*}"
    case "$libname" in
        libc.so.*|libc.musl-*.so.*|\
        libm.so.*|libpthread.so.*|libdl.so.*|\
        librt.so.*|libstdc++.so.*|libgcc_s.so.*|libresolv.so.*|\
        libnsl.so.*|libutil.so.*|libcrypt.so.*|libcrypto.so.*|\
        libssl.so.*|ld-linux*|ld-musl*|linux-vdso.so.*)
            # musl's libc is `libc.musl-<arch>.so.1` (and confusingly
            # ldd reports the same loader at both `libc.musl-<arch>.so.1`
            # AND `ld-musl-<arch>.so.1` — they're literally the same
            # binary; the first is libc, the second is the dynamic
            # loader, and musl ships them as one file via symlink).
            # Both names are system libs that must stay dynamic.
            continue ;;
    esac
    case "$libpath" in
        not\ found)
            echo "::error::Non-system lib '$libname' is NOT FOUND in clean env"
            fail=1 ;;
        bundle/*|*/bundle/*)
            # Inside bundle/ — matches both relative (`bundle/foo`,
            # which is what ldd emits when ld.so resolves via
            # $ORIGIN from the binary's own dir) and absolute
            # (`/work/bundle/foo`) forms.
            : ;;
        *)
            echo "::error::Non-system lib '$libname' resolves to '$libpath' (outside bundle/)"
            fail=1 ;;
    esac
done <<< "$LDD_OUT"
if (( fail != 0 )); then
    echo "❌ Bundle audit failed: one or more non-system deps don't"
    echo "   resolve to bundle/ at user-runtime. The shipped tarball"
    echo "   would fall back to system paths (different libs / missing"
    echo "   libs) on the user's machine."
    exit 1
fi
echo "✅ Bundle audit passed: every non-system dep resolves inside bundle/"