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