Notcurses-Native.git | .github/workflows/ | _build-windows.yml


# Reusable workflow: build the windows-{x86_64,arm64} prebuilt notcurses # archive inside MSYS2 (UCRT64 for x86_64; CLANGARM64 for arm64). # # Both lanes source-build the codec + ffmpeg chain (libdeflate, # libdav1d, libvpx, libopus, ffmpeg) plus libunistring and libiconv # into a per-arch workspace cache dir, then point notcurses' cmake at # that prefix. # # libunistring and libiconv are in that list for a different reason # than the codec libraries. Both are LGPL and both ship inside the # .zip, so the licence obliges us to be able to hand a user the # corresponding source for the exact binary they received — and # `pacman -S mingw-w64-*-libunistring` / `-libiconv` give versions # that move under us and get garbage-collected off the mirrors. A # pinned tarball with its SHA-256 recorded in # resources/third-party.json makes that answerable indefinitely. # ncursesw stays package-managed: MIT-style X11, no source-conveyance # duty. # # libiconv joined that pair in r11. It is Windows-only — glibc, musl # and libSystem all carry iconv inside libc — and it reaches the pack # because two libraries we build link it: ffmpeg's configure # autodetects iconv, and libunistring's AM_ICONV takes one when the # toolchain has it. A PE import scan of both r10 packs found exactly # those two importers and no others. The same scan found libintl-8.dll # imported by NOTHING: it was pure over-collection by the sweep list # in .github/actions/bundle-dll, so it is no longer swept, has no # component in resources/third-party.json, and would now fail the # audit gate if it came back. Keeping it out is also why # build-libiconv.sh configures --disable-nls. # # Why the lane stopped using `pacman -S mingw-w64-*-ffmpeg`: MSYS2's # ffmpeg package is a `--enable-gpl --enable-version3` build carrying # x264, x265, SvtAv1Enc, lame, rubberband and the rest of the encoder # tree, and bundle-dll faithfully swept that whole tree into the .zip # we publish. That made the Windows prebuilts GPL-encumbered — for a # library that only ever decodes — and far larger than they needed to # be. Source-building the same LGPL-2.1, decoder-only ffmpeg the other # three platforms already use puts all four on identical codec surface # and keeps the archive's licensing story simple. build-ffmpeg.sh # asserts the LGPL license line at configure time and the # "Assert bundle carries no GPL codec code" step below re-checks the # shipped binaries, so this can't silently regress. # # MSYS2-specific things worth knowing before editing this file: # # * Paths stay POSIX. msys2-runtime converts POSIX paths (and # `:`-separated POSIX path lists) in both argv and the environment # into Windows form when a native program — cmake, meson, pkgconf, # gcc/clang — is spawned. Feeding those tools Windows-form paths # instead breaks the other direction: a `D:/x;D:/y` list has a `:` # in it and gets mangled by the same conversion layer. So every # path this file builds is POSIX, derived from $PWD, and cygpath # is used only where a value has to be compared against something # a native tool wrote (CMakeCache entries). # # * MSYSTEM_CARCH, not `uname -m`, identifies the target CPU. # msys2-runtime is an x86_64 Cygwin fork even when running # emulated on Windows-on-ARM, so `uname -m` says x86_64 on the # CLANGARM64 lane. The build scripts follow ffmpeg's own # precedence and prefer MSYSTEM_CARCH. # # * libvpx is static on Windows and shared everywhere else — its # configure refuses to emit a DLL off ELF/OS-2/Darwin. It gets # absorbed into avcodec-.dll, so there is no libvpx-*.dll in # the bundle and the libvpx_vp8 / libvpx_vp9 decoders the codec # probe gates on live inside avcodec. See build-libvpx.sh's header. name: _build-windows on: workflow_call: inputs: arch: description: '`x86_64` or `arm64`.' type: string required: true artifact-name: description: 'Archive basename, e.g. notcurses-windows-x86_64.' type: string required: true env: CMAKE_FLAGS: >- -DUSE_MULTIMEDIA=ffmpeg -DBUILD_FFI_LIBRARY=ON -DUSE_CXX=OFF -DBUILD_EXECUTABLES=OFF -DUSE_PANDOC=OFF -DUSE_DOCTEST=OFF -DUSE_POC=OFF -DUSE_STATIC=OFF -DCMAKE_BUILD_TYPE=Release jobs: build: name: build-windows-${{ inputs.arch }} # ARM Windows runners are flaky — ldd subprocesses periodically # hang indefinitely (likely xtajit64 emulation deadlocks). The # first line of defence is inside bundle-dll: every ldd call runs # under `timeout 30s` with four retries and a hard failure after. # This job-level cap is the backstop for anything that wedges # outside that. It was 60 minutes while the lane installed a # prebuilt ffmpeg; a cold (cache-miss) source build of the codec # chain adds ~30 min on UCRT64 and more on CLANGARM64, where the # emulated msys2-runtime makes every configure script crawl even # though the compilers themselves are native ARM64. 180 still # fails a wedged runner in a useful timeframe instead of eating # GitHub's 6h default. timeout-minutes: 180 runs-on: ${{ inputs.arch == 'arm64' && 'windows-11-arm' || 'windows-2022' }} defaults: run: shell: 'msys2 {0}' env: # The MSYS2 environment and its package-name prefix. Derived # once here rather than ternary-per-line so a new dependency is # one readable line and can't drift between the two lanes. MSYS_SUBSYSTEM: ${{ inputs.arch == 'arm64' && 'CLANGARM64' || 'UCRT64' }} MSYS_PKG_PREFIX: mingw-w64-${{ inputs.arch == 'arm64' && 'clang-aarch64' || 'ucrt-x86_64' }} # Workspace-relative install prefix for the source-built chain. # Relative so actions/cache can address it directly and so the # MSYS2 steps can make it absolute against $PWD without ever # touching $GITHUB_WORKSPACE (which is Windows-form). DEPS_PREFIX_REL: _ci-cache/windows-${{ inputs.arch }} steps: - uses: actions/checkout@v6 with: submodules: recursive - uses: msys2/setup-msys2@v2 with: msystem: ${{ env.MSYS_SUBSYSTEM }} path-type: inherit # NOTE: deliberately no `update: true`. That flag runs # `pacman -Syuu` which pulls whatever's newest off the # mirrors, shifting installed package versions on nearly # every run. setup-msys2 keys its cache on installed # package state, so `update: true` invalidates the cache # almost every time — defeating the point. Without it, # we install whatever versions the base MSYS2 image ships # with, and the cache actually sticks across runs. # Restore MSYS2 install state between runs — first run # still pays the download. Dropping the ffmpeg package (and # with it x264/x265/SvtAv1Enc/lame/rav1e/libplacebo and the # rest of its ~200-package dependency closure) cut this from # ~6 GB / ~9 GB to the toolchain plus a handful of small # libraries; the codec chain is source-built instead and # cached separately below. cache: true # # MSYS-side (not msystem-prefixed) tools. These are the # POSIX utilities the build scripts shell out to, listed # explicitly rather than assumed present in the base image: # * make — the mingw `-make` package installs # mingw32-make.exe ONLY; opus's autotools # build, libvpx's Makefile and ffmpeg's all # invoke plain `make`. # * perl — libvpx generates its RTCD headers with # build/make/rtcd.pl on every target. # * xz — GNU tar shells out to it for `-J` # (ffmpeg's .tar.xz). # * bzip2 — likewise for `-j` (dav1d's .tar.bz2). # * curl — every build script fetches with it. # * zip — package-and-upload's archive format. # * git — fetch-notcurses-source.sh's fetch-by-SHA. # * jq — reads resources/third-party.json in # emit-third-party-kit.sh and # audit-third-party.sh. Listed explicitly # because, unlike GitHub's ubuntu-* and # macos-* images, MSYS2 does not ship it and # the release gate cannot run without it. # # msystem-side packages: # * toolchain — compiler + binutils (UCRT64) / clang + # llvm-tools (CLANGARM64). llvm-tools is # what provides objdump / nm / strings / # dlltool under their GNU names on # CLANGARM64; there is no GNU binutils # package in that repo. # * cmake/ninja — notcurses' build, and libdeflate's. # * meson — libdav1d's build system (pulls python). # * pkgconf — every dependency probe. # * zlib — ffmpeg's --enable-zlib is a hard # `require`, so a missing zlib fails # configure rather than silently dropping # PNG support. # * ncurses — notcurses' own non-codec dep. Still # package-managed: MIT-style X11, so no # source-conveyance duty, and its # terminfo-path configuration makes a # source build genuinely delicate. # * libdeflate and libunistring are NOT here — both are # source-built into the workspace prefix along with the # codec chain, so there is exactly one copy for cmake to # find. libunistring left this list when it joined the # self-built chain: it is an LGPL library in the pack # whose corresponding source we have to be able to # produce, which a pacman version can't guarantee. # libiconv is not here either, and cannot be: the # toolchain group depends on mingw-w64-*-libiconv, so it # is installed whatever this list says. We source-build # ours anyway (same LGPL argument) and win on path # priority instead — build-libiconv.sh's header explains # the mechanism, and the "Assert the pack's self-built # DLLs are ours" step below proves the right one landed. # # The last line pairs the two arch-specific packages so the # list never expands to an empty token (setup-msys2 splits # this string on whitespace and hands the pieces to pacman): # * x86_64 needs nasm — ffmpeg, dav1d and libvpx all # assemble x86 SIMD with it. # * arm64 needs gcc-compat — libvpx's setup_gnu_toolchain # hardcodes `CC=${CC:-gcc}` and ffmpeg's cc_default is # `gcc`; CLANGARM64 ships clang as `clang`/`cc` and only # gets a `gcc` shim from this package, which is not in # the toolchain group. install: >- make perl xz bzip2 curl zip git jq ${{ env.MSYS_PKG_PREFIX }}-cmake ${{ env.MSYS_PKG_PREFIX }}-ninja ${{ env.MSYS_PKG_PREFIX }}-meson ${{ env.MSYS_PKG_PREFIX }}-toolchain ${{ env.MSYS_PKG_PREFIX }}-pkgconf ${{ env.MSYS_PKG_PREFIX }}-zlib ${{ env.MSYS_PKG_PREFIX }}-ncurses ${{ inputs.arch == 'arm64' && format('{0}-gcc-compat', env.MSYS_PKG_PREFIX) || format('{0}-nasm', env.MSYS_PKG_PREFIX) }} - name: Fetch notcurses source (NOTCURSES_FORK pin) run: bash scripts/ci/fetch-notcurses-source.sh # Cache the whole source-built prefix. Keyed on the seven build # scripts, so any version bump or configure-flag change # invalidates it, and on the msystem name, so the UCRT64 and # CLANGARM64 prefixes can never be handed to each other. # # restore/save split rather than the combined actions/cache: the # combined action saves in a post step that only runs when the # job SUCCEEDS. The r10 dispatch proved the cost right here — # this lane built the entire libdeflate→dav1d→vpx→opus chain, # died at the ffmpeg download, and saved nothing, so the next # push re-paid the whole cold-cache build (~30 min UCRT64, more # on CLANGARM64's emulated msys2-runtime). Saving explicitly, # immediately after the source-build step and before "Verify # source-built prefix" / "Configure + build notcurses" (either # of which can still fail), keeps the cache whatever the rest of # the job goes on to do. - name: Restore source-built codec + ffmpeg chain id: win-deps uses: actions/cache/restore@v4 with: path: ${{ env.DEPS_PREFIX_REL }} key: ${{ env.MSYS_SUBSYSTEM }}-deps-${{ hashFiles('scripts/ci/build-libiconv.sh', 'scripts/ci/build-libunistring.sh', 'scripts/ci/build-libdeflate.sh', 'scripts/ci/build-libdav1d.sh', 'scripts/ci/build-libvpx.sh', 'scripts/ci/build-libopus.sh', 'scripts/ci/build-ffmpeg.sh') }} - name: Source-build codec + ffmpeg chain if: steps.win-deps.outputs.cache-hit != 'true' run: | set -euxo pipefail # $PWD inside the MSYS2 shell is the workspace in POSIX form # (/d/a//). Building $PREFIX from it keeps every # path POSIX end-to-end, which is what msys2-runtime's # argv/env conversion expects when it hands paths to the # native cmake / meson / pkgconf / compiler binaries. PREFIX="$PWD/$DEPS_PREFIX_REL" mkdir -p "$PREFIX" export PREFIX # Order matters twice over. # # libiconv goes first because it is the only entry anything # else here *detects* rather than requires: libunistring's # AM_ICONV and ffmpeg's iconv probe both link a GNU libiconv # when they can find one, and MSYS2's identically-named # pacman copy is permanently installed (the toolchain group # depends on it), so ours has to already exist in $PREFIX # for those two configures to prefer it. Each of them takes # the prefix explicitly — --with-libiconv-prefix and # --extra-cflags/--extra-ldflags respectively — but neither # can point at a directory that is still empty. # # Then the codec chain: ffmpeg probes libdav1d / libvpx / # libopus via pkg-config in $PREFIX/lib/pkgconfig at # configure time, so they have to land before it. Each # script re-exports PKG_CONFIG_PATH itself, so no env # plumbing is needed here. bash scripts/ci/build-libiconv.sh bash scripts/ci/build-libunistring.sh bash scripts/ci/build-libdeflate.sh bash scripts/ci/build-libdav1d.sh bash scripts/ci/build-libvpx.sh bash scripts/ci/build-libopus.sh bash scripts/ci/build-ffmpeg.sh - name: Save source-built codec + ffmpeg chain if: steps.win-deps.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: ${{ env.DEPS_PREFIX_REL }} key: ${{ steps.win-deps.outputs.cache-primary-key }} # Runs on cache hit AND fresh build. On a fresh build it is # nearly redundant (the scripts self-check), but on a cache hit # it is the only thing standing between a half-written cache # entry — a run cancelled mid-build still saves what it has — # and a notcurses configure that fails ten minutes later with a # confusing pkg-config error. It also catches the MSYS2 failure # mode this conversion is most exposed to: a path that did not # survive POSIX→Windows conversion, so the install went # somewhere other than $PREFIX. - name: Verify source-built prefix run: | set -euo pipefail PREFIX="$PWD/$DEPS_PREFIX_REL" export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" fail=0 # Every module must resolve, AND resolve to a libdir inside # our prefix. The second half matters: pkgconf's built-in # search path still covers /$MSYSTEM/lib/pkgconfig, so a # PKG_CONFIG_PATH that failed to take effect would silently # answer from a pacman package instead of erroring. for mod in libdeflate dav1d vpx opus \ libavcodec libavformat libavutil libavdevice \ libswscale libswresample; do if ! ver=$(pkg-config --modversion "$mod" 2>/dev/null); then echo "::error::pkg-config cannot resolve '$mod' from $PREFIX/lib/pkgconfig" fail=1 continue fi libdir=$(pkg-config --variable=libdir "$mod" 2>/dev/null || true) # pkgconf relocates .pc prefixes relative to the file's # own location and answers in Windows form; normalise # before comparing against our POSIX prefix. libdir=$(cygpath -u "$libdir" 2>/dev/null || printf '%s' "$libdir") case "$libdir" in "$PREFIX"|"$PREFIX"/*) echo "ok: $mod $ver ($libdir)" ;; *) echo "::error::$mod $ver resolved to libdir '$libdir', outside $PREFIX" fail=1 ;; esac done # Shape checks. ffmpeg's mingw32 target puts DLLs in bin/ # and import libs in lib/; libtool does the same split for # libunistring and libiconv. libdeflate's and libunistring's # headers are what notcurses' check_include_file() / # find_path() look for via CPATH and CMAKE_PREFIX_PATH; # libiconv's is what ffmpeg's and libunistring's own # configures probe for. # ffmpeg's two names are exact — configure pins SLIBPREF="" # and the `lib.dll.a` import-lib spelling — but the # dav1d/opus/libdeflate/libunistring/libiconv globs are # deliberately loose: their SONAME majors are theirs to # change, and this check exists to catch "nothing # installed", not to police naming. (The exact # libiconv-2.dll basename IS policed, at the two places it # matters: build-libiconv.sh asserts it after install, and # the post-bundle assertion below checks the file that # actually lands in the pack.) for glob in 'bin/avcodec-*.dll' 'bin/avutil-*.dll' \ 'bin/libdav1d*.dll' 'bin/libopus*.dll' \ 'bin/libdeflate*.dll' 'lib/libavcodec.dll.a' \ 'lib/libdeflate*.dll.a' 'include/libdeflate.h' \ 'bin/libunistring-*.dll' 'lib/libunistring.dll.a' \ 'include/unigbrk.h' \ 'bin/libiconv-*.dll' 'lib/libiconv.dll.a' \ 'include/iconv.h'; do # The glob is expanded by find's -path, not the shell — # hence the single quotes on the loop list above. if [[ $(find "$PREFIX" -path "$PREFIX/$glob" | wc -l) -eq 0 ]]; then echo "::error::expected artefact missing: \$PREFIX/$glob" fail=1 fi done # libvpx must be static-only here. A libvpx DLL appearing # would mean build-libvpx.sh's Windows branch stopped # firing, which in turn means the --target it also sets # stopped being passed — and on CLANGARM64 that silently # degrades to a NEON-less generic-gnu build. if [[ ! -f "$PREFIX/lib/libvpx.a" ]]; then echo "::error::\$PREFIX/lib/libvpx.a missing — libvpx did not build static" fail=1 fi if [[ $(find "$PREFIX/bin" -maxdepth 1 -name 'libvpx*.dll' 2>/dev/null | wc -l) -ne 0 ]]; then echo "::error::a libvpx DLL exists — build-libvpx.sh's Windows branch did not run" fail=1 fi echo "--- \$PREFIX/bin ---" # `|| true` so a missing bin/ doesn't abort before the # curated diagnostics below get a chance to print. ls -la "$PREFIX/bin" || true if (( fail != 0 )); then echo echo "❌ The source-built prefix at $PREFIX is incomplete or" echo " resolving to the wrong place. If this fired on a cache" echo " hit, delete the '${MSYS_SUBSYSTEM}-deps-*' cache entry" echo " and re-run; the build scripts themselves are verified" echo " on the cache-miss path." exit 1 fi echo "✅ Source-built prefix verified." - name: Configure + build notcurses run: | set -euxo pipefail PREFIX="$PWD/$DEPS_PREFIX_REL" # Wire the workspace prefix into all four discovery # mechanisms notcurses' CMakeLists actually uses: # * PKG_CONFIG_PATH — pkg_check_modules(AVCODEC …) and # friends, plus pkg_search_module(TERMINFO …) which # still answers from the msystem's own default path. # * CMAKE_PREFIX_PATH — find_library(DEFLATE deflate), # a raw find_library that ignores pkg-config entirely. # * CPATH — check_include_file("libdeflate.h"), which # compiles a probe with no -I beyond what the compiler # already searches. # * LIBRARY_PATH — the linker's bare `-l` lookup, for the # same reason CPATH is needed. # `${VAR:+:$VAR}` rather than `:${VAR:-}`: a trailing # separator would survive msys2-runtime's list conversion as # an empty entry, which gcc reads as ".". export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" export CMAKE_PREFIX_PATH="$PREFIX${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}" export CPATH="$PREFIX/include${CPATH:+:$CPATH}" export LIBRARY_PATH="$PREFIX/lib${LIBRARY_PATH:+:$LIBRARY_PATH}" # MSYS2 bash sees $NOTCURSES_SRC_DIR from $GITHUB_ENV in # POSIX form already — GHA normalises env values written # via `>> $GITHUB_ENV` per shell convention. cd "$NOTCURSES_SRC_DIR" mkdir -p build cmake -B build -S . -G Ninja $CMAKE_FLAGS # Fail loudly if cmake silently downgraded USE_MULTIMEDIA=ffmpeg # to none — that'd produce a stripped notcurses with no image # support, defeating half the point of shipping it. if ! grep -q 'USE_MULTIMEDIA:STRING=ffmpeg' build/CMakeCache.txt; then echo "❌ USE_MULTIMEDIA did not land as 'ffmpeg' in CMakeCache." echo "--- relevant cache entries ---" grep -E '(USE_MULTIMEDIA|FFMPEG|AVCODEC)' build/CMakeCache.txt || true exit 1 fi # find_library(DEFLATE deflate) is the one dependency that # can't be checked through pkg-config, and it is also the # one most likely to silently pick something else up: cmake # searches the msystem's lib dir by default. Assert it # resolved inside our prefix. Compared lowercased and in # cygpath -m form (`D:/a/...`), which is what cmake writes. # awk (not `sed … | head -1`): under `set -o pipefail`, head # closing the pipe after one line SIGPIPEs its producer and # the whole command substitution comes back 141, which `set # -e` then turns into a mystery step failure. prefix_cmake=$(cygpath -m "$PREFIX" | tr '[:upper:]' '[:lower:]') deflate_lib=$(awk '/^DEFLATE:FILEPATH=/{sub(/^DEFLATE:FILEPATH=/, ""); print; exit}' \ build/CMakeCache.txt | tr '[:upper:]' '[:lower:]') case "$deflate_lib" in "$prefix_cmake"/*) echo "ok: DEFLATE resolved to $deflate_lib" ;; *) echo "❌ find_library(DEFLATE) resolved to '$deflate_lib'," echo " which is outside the source-built prefix '$prefix_cmake'." echo " CMAKE_PREFIX_PATH did not reach cmake — the bundle would" echo " ship a libdeflate we never built or verified." exit 1 ;; esac # Same assertion for libunistring, which is discovered the # same pkg-config-less way: find_path(unigbrk.h) + # find_library(unistring unistring REQUIRED). It matters # more here than DEFLATE does, because this is the LGPL # library whose corresponding source we publish — shipping # a pacman build of it while pointing users at our pinned # tarball would be a false statement, not just an unpinned # dependency. cmake's cache variable is lowercase # `unistring` (notcurses' CMakeLists names it that). unistring_lib=$(awk '/^unistring:FILEPATH=/{sub(/^unistring:FILEPATH=/, ""); print; exit}' \ build/CMakeCache.txt | tr '[:upper:]' '[:lower:]') case "$unistring_lib" in "$prefix_cmake"/*) echo "ok: unistring resolved to $unistring_lib" ;; *) echo "❌ find_library(unistring) resolved to '$unistring_lib'," echo " which is outside the source-built prefix '$prefix_cmake'." echo " The .zip would ship a libunistring we never built," echo " pinned, or recorded a source tarball for." exit 1 ;; esac ninja -C build - name: Bundle + sweep sibling DLLs uses: ./.github/actions/bundle-dll with: # Where the source-built DLLs live. ffmpeg's mingw32 target # installs shared objects under bin/, not lib/. Without this # the ldd walk cannot resolve avcodec-.dll and the run # dies in bundle-dll's "ldd permanently wedged" path. extra-search-path: ${{ env.DEPS_PREFIX_REL }}/bin # Release gate: every DLL in the pack that we source-built is # the file we source-built, byte for byte. # # The find_library(unistring) assertion above covers what # notcurses LINKED. This covers what bundle-dll COPIED, which is # a different question with a different failure mode: the sweep # resolves DLLs by basename through DLL_SEARCH_DIRS, and every # library here has an identically-named twin sitting in # /$MSYSTEM/bin. Our prefix is first in that list and first on # PATH, but "first in a list" is not a guarantee — one reordered # search path, one ldd answer taken at face value, and the pack # ships a pacman build while THIRD-PARTY.md and the attached # source tarballs say we compiled it. For libiconv and # libunistring that is not an inaccuracy, it is a false # statement about corresponding source for an LGPL library, and # it is exactly the compliance gap r11 exists to close. # # Written as "for each bundled DLL that also exists in # $PREFIX/bin, the two must hash the same" rather than as a # hardcoded name list, so it keeps working across SONAME bumps # and picks up new self-built libraries for free. The two LGPL # ones are then required by name, because for those the # dangerous outcome is absence from the comparison, not a # mismatch inside it. - name: Assert the pack's self-built DLLs are ours run: | set -euo pipefail PREFIX="$PWD/$DEPS_PREFIX_REL" fail=0 checked=0 shopt -s nullglob bundled=(bundle/*.dll) if (( ${#bundled[@]} == 0 )); then echo "❌ bundle/ contains no DLLs — bundling did not run." exit 1 fi for dll in "${bundled[@]}"; do base=$(basename "$dll") staged="$PREFIX/bin/$base" # No staged twin: a package-managed library (ncursesw, the # toolchain runtimes, zlib) or one of notcurses' own DLLs. # Those are accounted for by the third-party audit, not # here. [[ -f "$staged" ]] || continue ours=$(sha256sum "$staged" | awk '{print $1}') shipped=$(sha256sum "$dll" | awk '{print $1}') if [[ "$ours" != "$shipped" ]]; then echo "::error file=$dll::is not the $base we built" echo " staged ($staged): $ours" echo " bundled ($dll): $shipped" fail=1 continue fi echo "ok: $base matches the staged build ($ours)" checked=$(( checked + 1 )) done for required in libiconv-2.dll libunistring-5.dll; do if [[ ! -f "$PREFIX/bin/$required" ]]; then echo "::error::\$PREFIX/bin/$required does not exist — the" echo " source build did not produce it under that name, so the" echo " comparison above never ran for it." fail=1 fi if [[ ! -f "bundle/$required" ]]; then echo "::error::bundle/$required is missing — the pack would ship" echo " without a library its own DLLs import." fail=1 fi done # libintl by name, for the one case the two general gates # word badly. If nothing imports it, bundle-dll's # over-collection gate has already failed the lane with the # right advice. If something DOES import it, that gate # passes and the third-party audit fails instead, saying # "not covered by any component" — whose stock advice is # "add a component entry", which is the wrong fix here. # libintl was dropped in r11 as LGPL with no pinnable # corresponding source, and the only way it comes back # imported is a configure that lost its --disable-nls # (build-libiconv.sh's, most likely). That is what to fix. if [[ -f bundle/libintl-8.dll ]]; then echo "::error file=bundle/libintl-8.dll::GNU gettext's libintl is" echo " back in the pack. Find what imports it (objdump -p on each" echo " bundled DLL) and stop that at the source, rather than" echo " re-adding a manifest entry for a library we cannot ship" echo " corresponding source for." fail=1 fi if (( fail != 0 )); then echo echo "❌ The pack does not match the libraries this lane built." echo "--- \$PREFIX/bin ---" ls -la "$PREFIX/bin" || true echo "--- bundle/ ---" ls -la bundle/ || true exit 1 fi echo "✅ $checked bundled DLL(s) are byte-identical to the ones" echo " this lane source-built, and libintl is absent." # Release gate: the shipped binaries carry no GPL codec code. # build-ffmpeg.sh already asserts configure reported LGPL-2.1, # but that only covers the ffmpeg we built — it says nothing # about what bundle-dll then swept in beside it. This checks the # artefact we actually publish, which is the thing the licence # claim is about. Two independent probes: # 1. No bundled DLL imports (or IS) a known GPL/nonfree codec # library. That is how the encoder tree arrived when this # lane used pacman's ffmpeg: avcodec imported libx264-165 # .dll, the sweep copied it, and the archive shipped it. # 2. avcodec's own compiled-in licence string is LGPL. ffmpeg # bakes FFMPEG_LICENSE into every library for # avcodec_license(); a --enable-gpl build says "GPL version # 2 or later" and a --enable-nonfree one says "nonfree and # unredistributable". Read with tr+grep rather than # `strings` so this does not depend on which binutils-alike # the msystem ships. - name: Assert bundle carries no GPL codec code run: | set -euo pipefail fail=0 # Substring patterns. Both bundled filenames and PE # import-table entries are lowercased before matching, so # e.g. libSvtAv1Enc-4.dll is caught by the `svtav1` entry. gpl_patterns='x264|x265|svtav1|svt-av1|mp3lame|lame|vmaf|rubberband|xvid|xavs|davs2|xeve|xevd|opencore|vo-amrwb|vidstab|zvbi|libzvbi|frei0r|libcdio|rav1e' # Every match below uses a here-string, never `printf … | # grep -q`. `grep -q` exits at the first match, SIGPIPEs the # producer, and `set -o pipefail` then reports the pipeline # as failed — so a pipeline that DID find GPL code would # evaluate false in an `if`. That is a false green on a # licensing gate, which is the one direction this step must # never fail in. shopt -s nullglob bundled=(bundle/*.dll) if (( ${#bundled[@]} == 0 )); then echo "❌ bundle/ contains no DLLs — bundling did not run." exit 1 fi for dll in "${bundled[@]}"; do base=$(basename "$dll" | tr '[:upper:]' '[:lower:]') if grep -qE "$gpl_patterns" <<< "$base"; then echo "::error file=$dll::GPL/nonfree codec library present in the bundle" fail=1 fi # Unlike bundle-dll's walks, an unreadable DLL is fatal # here rather than skipped: "objdump said nothing" and # "objdump found no GPL imports" must not look the same # to a licensing gate. if ! imports=$(objdump -p "$dll" 2>&1); then echo "::error file=$dll::objdump could not read this DLL — imports unverifiable" fail=1 continue fi while IFS= read -r imp; do [[ -z "$imp" ]] && continue imp_lc=$(tr '[:upper:]' '[:lower:]' <<< "$imp") if grep -qE "$gpl_patterns" <<< "$imp_lc"; then echo "::error file=$dll::imports GPL/nonfree codec library '$imp'" fail=1 fi done < <(awk '/DLL Name:/{print $3}' <<< "$imports") done # Licence string embedded by ffmpeg's configure. Array + # nullglob rather than `ls … | head -1`: with nullglob set, # an unmatched glob leaves `ls` with no arguments, which # cheerfully lists the current directory instead of failing. avcodecs=(bundle/avcodec-*.dll) if (( ${#avcodecs[@]} == 0 )); then echo "❌ No bundle/avcodec-*.dll to read a licence string from." exit 1 fi avcodec="${avcodecs[0]}" # tr turns the DLL into newline-delimited printable runs so # grep can find the licence text ffmpeg's configure baked in # (config.h's FFMPEG_LICENSE, returned by avcodec_license()). # Matched on the licence text itself rather than on the # "lib license: " prefix the accessor concatenates, so # a cosmetic change to that accessor can't turn this gate # into a no-op. The five possible values are, verbatim: # "nonfree and unredistributable", "GPL version {2,3} or # later", "LGPL version {2.1,3} or later". licences=$(tr -c '[:print:]' '\n' < "$avcodec" \ | grep -E '(GPL version [0-9]|nonfree and unredistributable)' \ || true) if [[ -z "$licences" ]]; then echo "::error file=$avcodec::no ffmpeg licence string found — cannot verify licensing" fail=1 else echo "--- licence strings in $(basename "$avcodec") ---" printf '%s\n' "$licences" # `[^L]` guard: "LGPL version 3 or later" contains "GPL # version 3 or later" as a substring, so an unanchored # match would flag every LGPLv3 build as GPL. if grep -qE '(^|[^L])GPL version [0-9]' <<< "$licences"; then echo "::error file=$avcodec::reports a GPL licence, not LGPL" fail=1 fi if grep -qF 'nonfree and unredistributable' <<< "$licences"; then echo "::error file=$avcodec::reports a nonfree, unredistributable licence" fail=1 fi if ! grep -qF 'LGPL version 2.1 or later' <<< "$licences"; then echo "::error file=$avcodec::does not report LGPL version 2.1 or later" fail=1 fi fi if (( fail != 0 )); then echo echo "❌ The archive this lane is about to publish contains" echo " GPL-licensed or nonfree codec code. Notcurses::Native" echo " is Artistic-2.0 and ships these DLLs inside its" echo " prebuilt, so this is a licensing decision, not a build" echo " flag — do not 'fix' it by widening the allowlist." exit 1 fi echo "✅ Bundle is free of GPL/nonfree codec libraries and avcodec reports LGPL-2.1." # Compile the perf shim into the bundle so users installing the # prebuilt archive don't need a C toolchain to get the fast path. # Windows is different from macOS/Linux: MinGW (and MSVC) require # every symbol resolved at link time — no equivalent of macOS's # -undefined dynamic_lookup or GNU ld's --unresolved-symbols. # We link against $NOTCURSES_SRC_DIR/build's libnotcurses-core.dll.a # import lib instead. At runtime the shim's import table resolves # to the libnotcurses-core.dll sibling in the same archive # directory (Windows DLL search order finds same-dir DLLs first). - name: Compile perf shim run: | set -euxo pipefail import_lib=$(find "$NOTCURSES_SRC_DIR/build" \ -name 'libnotcurses-core.dll.a' -type f | head -1) if [[ -z "$import_lib" ]]; then echo "❌ Couldn't find libnotcurses-core.dll.a import lib" find "$NOTCURSES_SRC_DIR/build" -name '*.dll.a' -type f exit 1 fi cc -O2 -shared \ -I "$NOTCURSES_SRC_DIR/include" \ -o bundle/libnotcurses_native_shim.dll \ src/notcurses_native_shim.c \ "$import_lib" # No `strip` on the shim — matches Vips-Native's Windows # pattern. `strip --strip-unneeded` removes PE's COFF # symbol table; `nm -g` reads from there too, not from # the export directory, so a stripped DLL reports "no # symbols" even though the exports are still in the # export directory and the DLL is fully functional. The # shim is a few KB so stripping saves nothing meaningful. echo "--- shim symbols ---" # Use `nm -g --defined-only` like Vips-Native does — its # output format is stable across MinGW/UCRT/CLANGARM64 # toolchains (every entry is " T " for # defined exported text symbols). The previous objdump # -p check parsed the export-table-name table format, # which differs across binutils versions and silently # failed to match even when the export was present. nm -g --defined-only bundle/libnotcurses_native_shim.dll \ | grep -E 'T (_)?notcurses_native_' \ || { echo "❌ shim DLL missing notcurses_native_* exports"; \ exit 1; } # Sidecar for Build.rakumod's content-based freshness # check: the SHA-256 of the shim source this shim was # compiled from. Prebuilt-only Windows installs can't # compile the shim at all (no import lib in the archive), # so without this sidecar every reinstall takes the mtime # fallback and hits the "skipping Windows shim compile" # warning path. sha256sum src/notcurses_native_shim.c | awk '{print $1}' \ > bundle/libnotcurses_native_shim.dll.srchash # Release gate: LoadLibrary avcodec-*.dll from bundle/, confirm # libdav1d / libvpx_vp8 / libvpx_vp9 / libopus are registered # decoders and that PNG / JPEG / BMP actually decode. Catches # the regression class where ffmpeg's configure dropped an # --enable-libfoo (its pkg-config probe failed) and silently # produced a bundle with internal-only decoders. Both Windows # lanes now run our source-built ffmpeg, so this checks that # build-ffmpeg.sh's configure stayed correct — and, on this # platform specifically, that statically absorbing libvpx into # avcodec still registers the libvpx_* decoders. - name: Codec capability probe (release gate) run: bash scripts/ci/run-codec-probe.sh - name: Package + upload uses: ./.github/actions/package-and-upload with: artifact-name: ${{ inputs.artifact-name }} format: zip platform: windows shell: 'msys2 {0}'