# 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}'