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


name: bundle-dll
description: >-
  Stage the three notcurses DLLs into `bundle/`, recursively walk
  MSYS2's ldd to copy sibling DLL deps into the same dir, then
  belt-and-braces sweep for ffmpeg codec + text-rendering DLLs that
  ldd sometimes misses on CLANGARM64.

  Caller must already have:
    - notcurses built under $NOTCURSES_SRC_DIR/build/ (the value
      written by scripts/ci/fetch-notcurses-source.sh into
      $GITHUB_ENV)
    - The MSYS2 default shell active (msys2 {0})
    - `bundle/` writable
    - msys2 ldd + timeout + sed on PATH

inputs:
  extra-search-path:
    description: >-
      Additional directory to search for DLLs, ahead of the msystem
      bin dirs. Workspace-relative POSIX path (absolute is also
      accepted). _build-windows.yml points this at the bin/ of its
      source-built codec + ffmpeg prefix: ffmpeg's mingw32 target
      installs shared objects under $PREFIX/bin, which is on no
      default search path, so without this neither ldd (which
      resolves via PATH) nor the objdump backstop (which resolves via
      find_dll_by_name) can locate avcodec-<N>.dll and friends. The
      directory is also prepended to PATH for the duration of the
      step so ldd's own resolution works. Empty default = search only
      the msystem bin dirs, i.e. the pre-source-build behaviour.
    required: false
    default: ''

runs:
  using: composite
  steps:
    - name: Stage + bundle sibling DLLs
      shell: 'msys2 {0}'
      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}"
        # Convert any \ path GHA may have put in NOTCURSES_SRC_DIR
        # (the env propagation goes through GitHub's Windows-style
        # interpolation) into POSIX form for MSYS2's bash.
        if command -v cygpath >/dev/null 2>&1; then
          NOTCURSES_SRC_DIR=$(cygpath -u "$NOTCURSES_SRC_DIR")
        fi
        workspace="$PWD"
        mkdir -p "$workspace/bundle"

        # Resolve the caller's extra search dir (if any) before we
        # leave the workspace. Fail loudly on a path that doesn't
        # exist: the caller asserting "the DLLs are over here" and
        # being wrong is exactly the case where silently falling back
        # to the msystem bin dirs would produce a bundle missing every
        # source-built library, which then only shows up as a
        # confusing ldd wedge or a runtime load failure on a user's
        # machine.
        extra_dll_dir=""
        if [[ -n "${EXTRA_SEARCH_PATH:-}" ]]; then
          case "$EXTRA_SEARCH_PATH" in
            /*) extra_dll_dir="$EXTRA_SEARCH_PATH" ;;
            *)  extra_dll_dir="$workspace/$EXTRA_SEARCH_PATH" ;;
          esac
          if [[ ! -d "$extra_dll_dir" ]]; then
            echo "❌ extra-search-path '$EXTRA_SEARCH_PATH' resolves to"
            echo "   '$extra_dll_dir', which is not a directory."
            exit 1
          fi
          # ldd resolves imports through the Windows DLL search order,
          # which reads PATH — find_dll_by_name below only covers the
          # cases where ldd has already given up.
          export PATH="$extra_dll_dir:$PATH"
          echo "Extra DLL search dir: $extra_dll_dir"
        fi

        cd "$NOTCURSES_SRC_DIR/build"

        # MinGW DLL naming varies: libfoo-N.dll, libfoo.dll, foo.dll.
        # BUT: `notcurses` is a prefix of `notcurses-core` and
        # `notcurses-ffi`, so a loose *notcurses*.dll glob matches
        # all three and head -1 picks the same file every iteration.
        # Restrict: only allow `.` or `-<digits>` after the name.
        for lib in notcurses notcurses-core notcurses-ffi; do
          found=$(find . -type f \( \
              -iname "${lib}.dll" -o \
              -iname "lib${lib}.dll" -o \
              -iname "${lib}-[0-9]*.dll" -o \
              -iname "lib${lib}-[0-9]*.dll" \
            \) | head -1)
          if [[ -z "$found" ]]; then
            echo "❌ Could not locate built DLL for $lib"
            find . -iname "*${lib}*" -type f
            exit 1
          fi
          cp "$found" "$workspace/bundle/lib${lib}.dll"
          echo "Staged lib${lib}.dll (from $(basename $found))"
        done

        cd "$workspace"

        # On Windows, DLL search order finds siblings in the same
        # directory as the DLL being loaded — no rpath needed.
        # We just need to ensure every transitive dep is present
        # in bundle/. MSYS2's `ldd` works on Windows DLLs, with the
        # objdump backstop below covering the cases where ldd lies
        # (notably on CLANGARM64).
        #
        # Single source of truth for "is system DLL": the
        # scripts/ci/win-system-dll.sh helper. Covers API Set
        # forwarders (api-ms-*, ext-ms-*, ms-win-*), versioned
        # runtime DLLs (vcruntime140*.dll, d3dcompiler_47.dll),
        # the long-standing Win32/GDI/COM/security/networking/
        # multimedia DLLs by exact name, and any path under
        # C:\Windows as a belt-and-braces fallback.
        source scripts/ci/win-system-dll.sh

        # Fallback: search known DLL dirs by name when ldd gives us a
        # bogus path. CLANGARM64's ldd is unreliable — often reports
        # directories or partial paths instead of the actual DLL
        # location. Silently skipping those would lose legit codec
        # DLLs. The caller's extra dir goes first so a source-built
        # library always wins over a same-named pacman one.
        # Explicit `if` rather than `[[ … ]] && arr+=( … )`: the
        # AND-list form returns non-zero when the guard is false,
        # which is only survivable under `set -e` because bash
        # exempts the left side of an && list. Not worth relying on.
        DLL_SEARCH_DIRS=()
        if [[ -n "$extra_dll_dir" ]]; then
          DLL_SEARCH_DIRS+=( "$extra_dll_dir" )
        fi
        DLL_SEARCH_DIRS+=( /clangarm64/bin /ucrt64/bin /mingw64/bin /usr/bin )
        find_dll_by_name() {
          local name="$1"
          local search
          for search in "${DLL_SEARCH_DIRS[@]}"; do
            if [[ -f "$search/$name" ]]; then
              echo "$search/$name"
              return 0
            fi
          done
          return 1
        }
        # Track DLLs we've already inspected so a cyclic ldd graph
        # (or one that lies the same way twice) can't loop forever.
        declare -A SEEN_DEPS=()

        process_deps() {
          local dll="$1"
          local depth="${2:-0}"
          # Hard recursion cap. The real DLL graph is ~3 deep;
          # anything past 12 is a cycle or a buggy ldd, not real.
          if (( depth > 12 )); then
            echo "process_deps: depth cap hit at $dll, stopping"
            return 0
          fi
          local key="$(basename "$dll")"
          [[ -n "${SEEN_DEPS[$key]:-}" ]] && return 0
          SEEN_DEPS[$key]=1

          # Wrap ldd in `timeout` — on Windows ARM, ldd
          # subprocesses periodically wedge inside xtajit64
          # emulation and never return. 30s is generous; a healthy
          # ldd call on these DLLs finishes in well under a second.
          # Retry a few times: most xtajit64 deadlocks clear on a
          # fresh process. If it stays stuck after 4 tries, fail
          # the job — silently skipping would ship a bundle missing
          # this DLL's transitive deps, which then explodes at
          # runtime on a user's machine. A failed CI job is much
          # cheaper than a broken release.
          local ldd_out=""
          local attempt
          for attempt in 1 2 3 4; do
            if ldd_out="$(timeout 30s ldd "$dll" 2>/dev/null)"; then
              break
            fi
            echo "process_deps: ldd attempt $attempt timed out on $dll, retrying..."
            sleep 2
            ldd_out=""
          done
          if [[ -z "$ldd_out" ]]; then
            echo "❌ ldd permanently wedged on $dll after 4 attempts."
            echo "   Almost certainly a xtajit64 deadlock on Windows ARM —"
            echo "   restart the workflow run. If it keeps failing on the"
            echo "   same DLL, that DLL itself is broken or the runner is"
            echo "   in a bad state."
            exit 1
          fi

          local line name path
          while IFS=$'\t' read -r name path; do
            [[ -z "$path" ]] && continue
            if is_system_dll "$name" "$path"; then
              continue
            fi
            if [[ ! -f "$path" ]]; then
              if resolved=$(find_dll_by_name "$name"); then
                path="$resolved"
                echo "Resolved $name via fallback: $path"
              else
                echo "Skipping $name: unresolvable (ldd path='$path', not in known bin dirs)"
                continue
              fi
            fi
            if [[ ! -f "bundle/$name" ]]; then
              cp "$path" "bundle/$name"
              echo "Bundled: $name  (from $path)"
              process_deps "bundle/$name" $((depth + 1))
            fi
          done < <(printf '%s\n' "$ldd_out" \
                     | sed -n 's/ (.*)$//; s/^[[:space:]]*\([^ ]\+\) => \(.\+\)$/\1\t\2/p')
        }

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

        # Belt-and-braces sweep: some msystem variants (notably
        # CLANGARM64) have ldd failures that cause deps to be
        # silently missed. If the DLLs exist in one of the search
        # dirs, copy them anyway — can't hurt (they're already
        # dynamic deps somewhere in the graph), and catches the cases
        # where ldd lies. Process each copied lib through
        # process_deps for its own transitive deps.
        #
        # Names not found are silently skipped; that resilience is
        # the point of the list, and it is also why the list has to
        # be curated rather than left to grow. A stale entry costs
        # nothing at runtime but is a lie about what the bundle
        # contains, and the entries below were read as documentation
        # more than once.
        #
        # This list lost ~20 entries when the lane stopped installing
        # MSYS2's GPL ffmpeg and started source-building an
        # LGPL decode-only one. Everything removed was reachable only
        # through that package's dependency closure and cannot exist
        # in the bundle any more:
        #   * encoders we never shipped a decoder path for —
        #     libx264-165, libx265-215, libSvtAv1Enc-4, libmp3lame-0,
        #     libvorbisenc-2 (the GPL/encoder tree this conversion
        #     exists to remove);
        #   * external codecs our configure does not enable, so
        #     ffmpeg uses its internal decoder instead — libvorbis-0,
        #     libogg-0, libopenjp2-7;
        #   * ffmpeg subsystems we compile out — liblzma-5, libbz2-1
        #     (--disable-lzma/--disable-bzlib), libxml2-*, libexpat-1
        #     (--disable-libxml2), and the libass/drawtext font stack
        #     (libfreetype-6, libfontconfig-1, libharfbuzz-0,
        #     libgraphite2, libbrotli*);
        #   * image libraries that only ever came in via ffmpeg's
        #     optional wrappers — libpng16-16, libjpeg-8, libwebp-7,
        #     libwebpmux-3, libsharpyuv-0, libgif-7, libtiff-6;
        #   * libvpx-1, which no longer exists in any form on
        #     Windows: libvpx's configure refuses to build a DLL off
        #     ELF/OS-2/Darwin, so build-libvpx.sh links it statically
        #     into avcodec here. See build-libvpx.sh's header.
        #
        # What stayed: notcurses' own non-codec deps — ncurses (still
        # pacman's) and libunistring (source-built as of the
        # third-party-manifest work, so it now resolves out of the
        # caller's extra search dir like the codec DLLs do), the
        # libiconv the latter and avcodec both import (source-built
        # too as of r11, and resolved from the same extra dir),
        # winpthreads, zlib (ffmpeg --enable-zlib), and the three
        # codec libraries the source-built chain still emits as DLLs —
        # libdeflate, libdav1d, libopus.
        #
        # r11 removed four more names, and those removals are
        # decisions rather than tidy-ups. A PE import-reachability
        # closure computed from libnotcurses{,-core,-ffi}.dll over
        # every DLL in both published r10 packs found each of them
        # reachable from nothing at all:
        #   * libintl-8.dll — GNU gettext's message-translation
        #     runtime, in both packs, imported by NOTHING. Not
        #     avcodec, not ncursesw, not libunistring, not even
        #     libiconv (the traffic goes the other way: libintl
        #     imports libiconv). It was there solely because this list
        #     named it. LGPL and pacman-sourced, so we were
        #     redistributing a copyleft binary whose corresponding
        #     source we cannot produce, in exchange for nothing.
        #   * libstdc++-6.dll and libgomp-1.dll — imported by nothing
        #     in the UCRT64 pack, and absent from the CLANGARM64 one.
        #     notcurses is configured -DUSE_CXX=OFF and nothing in the
        #     decoder chain uses OpenMP, so neither ever had a caller.
        #   * libgcc_s_seh-1.dll — imported only by libstdc++-6.dll,
        #     i.e. reachable only from another orphan. gcc links the
        #     unwinder statically into everything else here, which is
        #     why no av*/sw* DLL references it.
        # The last three are GPL-3.0-with-GCC-exception; the exception
        # makes redistributing them fine, but redistributing a
        # compiler runtime nothing loads is still ~2 MB of a user's
        # download spent on nothing.
        #
        # None of the four has a component in
        # resources/third-party.json any more, and the reachability
        # gate below fails the lane on any bundled DLL nothing
        # imports. If one comes back, something has started importing
        # it — that is a real dependency change to understand, not a
        # line to restore here.
        # ffmpeg's own av*/sw* DLLs are deliberately NOT listed: they
        # are direct imports of libnotcurses-core, so the ldd walk
        # and the objdump backstop both reach them, and hardcoding
        # their SONAME majors here would rot on every ffmpeg bump.
        #
        # libunistring-2.dll left with the same broom that swept the
        # encoder tree: our pin (build-libunistring.sh, 1.4.x) has
        # libtool -version-info 7:R:2, i.e. SONAME major 5, so a
        # `-2` could only ever come from a package-manager build we
        # no longer install. resources/third-party.json's Windows
        # pattern for the component spells the same expectation, and
        # scripts/ci/audit-third-party.sh fails the lane if the DLL
        # that lands doesn't match it. libiconv-2.dll's major is
        # stable for the same kind of reason (-version-info 9:1:7
        # gives 2), but unlike libunistring its pacman twin stays
        # installed — the toolchain group depends on it — so the
        # caller's extra search dir being first in DLL_SEARCH_DIRS is
        # what decides which one this line copies. _build-windows.yml
        # re-checks that decision by hash after the sweep.
        echo "--- belt-and-braces sweep ---"
        for name in \
            libncursesw6.dll libncurses6.dll \
            libunistring-5.dll libiconv-2.dll \
            libwinpthread-1.dll \
            zlib1.dll \
            libdeflate.dll libdav1d-7.dll libopus-0.dll \
        ; do
          if [[ ! -f "bundle/$name" ]]; then
            if resolved=$(find_dll_by_name "$name"); then
              cp "$resolved" "bundle/$name"
              echo "Swept in: $name  (from $resolved)"
              process_deps "bundle/$name"
            fi
            # If not found, silently skip — this variant's ffmpeg
            # simply doesn't ship it, either statically baked in
            # or unsupported on this platform.
          fi
        done

        # Backstop pass: walk every bundled DLL's PE import table
        # via `objdump -p`. Catches the failure mode where ldd's
        # walk lies (well-documented on CLANGARM64 — xtajit64
        # emulation quirks cause ldd to silently return incomplete
        # output) and the belt-and-braces sweep doesn't have the
        # missing DLL in its hardcoded list. objdump reads the PE
        # header directly without invoking the binary, so it's
        # immune to ldd's runtime issues.
        #
        # Uses the same `is_system_dll` whitelist sourced at the
        # top of the step, so what's classified as "system" here
        # matches what the audit step uses — both can't disagree.
        echo "--- objdump-based backstop walk ---"
        declare -A SEEN_OBJDUMP=()
        process_deps_objdump() {
          local dll="$1"
          local depth="${2:-0}"
          if (( depth > 12 )); then
            echo "process_deps_objdump: depth cap hit at $dll"
            return 0
          fi
          local key="$(basename "$dll")"
          [[ -n "${SEEN_OBJDUMP[$key]:-}" ]] && return 0
          SEEN_OBJDUMP[$key]=1

          local name
          while IFS= read -r name; do
            [[ -z "$name" ]] && continue
            if is_system_dll "$name"; then
              continue
            fi
            if [[ -f "bundle/$name" ]]; then
              process_deps_objdump "bundle/$name" $((depth + 1))
              continue
            fi
            if resolved=$(find_dll_by_name "$name"); then
              cp "$resolved" "bundle/$name"
              echo "Bundled (objdump): $name  (from $resolved)"
              process_deps_objdump "bundle/$name" $((depth + 1))
            else
              echo "::warning::objdump-walk: '$name' not in known msys2 bin dirs — audit will flag if it isn't a system DLL"
            fi
          done < <(objdump -p "$dll" 2>/dev/null | awk '/DLL Name:/ {print $3}')
        }
        # Iterate the bundle after both prior passes. Any DLL ldd or
        # the sweep already grabbed acts as an entry point; we walk
        # its real PE imports and pick up anything missed.
        for dll in bundle/*.dll; do
          [[ -L "$dll" ]] && continue
          process_deps_objdump "$dll"
        done

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

    - name: Audit bundle self-containment (objdump imports)
      shell: 'msys2 {0}'
      run: |
        set -euo pipefail
        # For every bundled .dll, list its PE import table via
        # `objdump -p` and reject any imported DLL name that isn't
        # either a Windows system DLL (universally available on
        # every supported Windows version) or a sibling in bundle/.
        # Catches the failure mode where ldd's recursive walk
        # missed a DLL and the belt-and-braces sweep didn't catch
        # it either — the bundle works in CI (msys2 has the DLL
        # on PATH) but fails on a user's machine.
        # Shared whitelist (same as bundling step uses for ldd +
        # objdump walks) — guaranteed agreement between what's
        # classified as system and what gets bundled.
        source scripts/ci/win-system-dll.sh
        fail=0
        for dll in bundle/*.dll; do
          [[ -L "$dll" ]] && continue
          # objdump -p output has lines like:
          #   DLL Name: libavcodec-62.dll
          # Match those, extract the name.
          while IFS= read -r imp; do
            [[ -z "$imp" ]] && continue
            if is_system_dll "$imp"; then
              continue
            fi
            if [[ -f "bundle/$imp" ]]; then
              continue
            fi
            echo "::error file=$dll::imports '$imp' which is neither a system DLL nor a bundle sibling"
            fail=1
          done < <(objdump -p "$dll" 2>/dev/null | awk '/DLL Name:/{print $3}')
        done
        if (( fail != 0 )); then
          echo
          echo "❌ Bundle audit failed: one or more bundled DLLs import"
          echo "   non-system DLLs that aren't present in bundle/."
          echo "   Those imports would fail on a user's machine. Either"
          echo "   add the missing DLLs to the belt-and-braces sweep in"
          echo "   process_deps's belt-and-braces section above, or fix"
          echo "   ldd's walk to capture them."
          exit 1
        fi
        echo "✅ Bundle self-containment audit passed: every bundled DLL's"
        echo "   imports resolve inside bundle/ or to Windows system DLLs."

    - name: Audit bundle over-collection (import reachability)
      shell: 'msys2 {0}'
      run: |
        set -euo pipefail
        # The mirror image of the step above. That one asks "can every
        # import be resolved?"; this one asks "does anything actually
        # import this?". A DLL nothing loads is not a broken pack, so
        # nothing else in the pipeline notices it — it is just a
        # binary we redistribute, and therefore a licence we are
        # responsible for, a notice we owe, and (if it is copyleft)
        # corresponding source we must be able to produce, all in
        # exchange for nothing.
        #
        # That is not hypothetical. The published r10 packs carried
        # four such DLLs: libintl-8.dll in both (LGPL, pacman-sourced,
        # no pinnable source), and libstdc++-6.dll, libgomp-1.dll and
        # libgcc_s_seh-1.dll in the UCRT64 one. Every one arrived
        # through the belt-and-braces sweep list, which by design
        # copies things whether or not anything needs them. This gate
        # is what stops that list — or a future ldd/objdump walk that
        # over-reaches — putting an unowned binary in a release again.
        #
        # Roots are the three notcurses DLLs, which is what the pack
        # exists to deliver. The perf shim is a fourth root rather
        # than a reachable node — Notcurses::Native loads it directly
        # and nothing imports it — but the caller compiles it into
        # bundle/ AFTER this action runs, so it is only picked up if
        # the step order ever changes. Listing it anyway costs
        # nothing and keeps that reordering from turning into a
        # baffling "nothing imports the shim" failure.
        roots=(bundle/libnotcurses.dll bundle/libnotcurses-core.dll bundle/libnotcurses-ffi.dll)
        for root in "${roots[@]}"; do
          if [[ ! -f "$root" ]]; then
            echo "❌ $root is missing — staging did not run."
            exit 1
          fi
        done
        if [[ -f bundle/libnotcurses_native_shim.dll ]]; then
          roots+=( bundle/libnotcurses_native_shim.dll )
        fi

        # The reached set is a space-delimited string of lowercased
        # basenames rather than an associative array, so this can be
        # replayed against a published pack under any bash — including
        # macOS's 3.2, which is where the r10 over-collection was
        # actually found. Lowercased because a PE import table spells
        # a name however the linker that wrote it felt at the time
        # (KERNEL32.dll, libavcodec-62.dll) while the file on disk has
        # exactly one spelling, and Windows resolves the two against
        # each other case-insensitively.
        reachable=' '
        walk() {
          local dll="$1"
          local depth="${2:-0}"
          if (( depth > 12 )); then
            echo "walk: depth cap hit at $dll"
            return 0
          fi
          local key
          key="$(basename "$dll" | tr '[:upper:]' '[:lower:]')"
          case "$reachable" in
            *" $key "*) return 0 ;;
          esac
          reachable="$reachable$key "
          local name
          while IFS= read -r name; do
            [[ -z "$name" ]] && continue
            # `-f` goes through the Windows filesystem, which is
            # case-insensitive, so this matches whatever casing the
            # import table used.
            if [[ -f "bundle/$name" ]]; then
              walk "bundle/$name" $(( depth + 1 ))
            fi
          done < <(objdump -p "$dll" 2>/dev/null | awk '/DLL Name:/{print $3}')
        }
        for root in "${roots[@]}"; do
          walk "$root"
        done

        fail=0
        for dll in bundle/*.dll; do
          [[ -L "$dll" ]] && continue
          base="$(basename "$dll")"
          key="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
          case "$reachable" in
            *" $key "*)
              echo "ok:      $base"
              continue ;;
          esac
          echo "::error file=$dll::nothing in this pack imports '$base'"
          fail=1
        done

        if (( fail != 0 )); then
          echo
          echo "❌ The pack contains DLLs that nothing in it loads."
          echo "   Work out why each one arrived — usually a stale name in"
          echo "   the belt-and-braces sweep list — and stop copying it,"
          echo "   rather than adding a component to"
          echo "   resources/third-party.json for a library no user will"
          echo "   ever execute. If a name here IS newly needed, then"
          echo "   something started importing it and the reachability walk"
          echo "   would have found it; check that objdump can read the"
          echo "   importer before concluding otherwise."
          exit 1
        fi
        echo "✅ Bundle over-collection audit passed: every bundled DLL is"
        echo "   reachable from libnotcurses{,-core,-ffi}.dll."