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


# 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-<N>.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/<repo>/<repo>). 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<name>.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-<N>.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<name> 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 "<addr> T <symbol>" 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}'