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


# Reusable workflow: build the macos-{arm64,x86_64} prebuilt # notcurses archive on a `macos-14` arm64 GHA runner. # # BOTH lanes source-build the ffmpeg chain (libdav1d, libvpx, # libopus, ffmpeg) into a workspace cache dir. They differ only in # how the build is dispatched (native vs Rosetta) and in how much # else they source-build. # # Why arm64 stopped using `brew install ffmpeg`: brew's ffmpeg # formula is a GPL build — --enable-gpl plus x264, x265, SvtAv1Enc, # lame, rubberband and friends — and dylibbundler faithfully pulled # that entire encoder chain into our archive. That made the # macos-arm64 prebuilt both GPL-encumbered (for a library that only # ever decodes) and far larger than it needed to be. Source-building # the same LGPL-2.1, decoder-only ffmpeg every other lane already # used puts all four platforms on identical codec surface and keeps # the archive's licensing story simple. build-ffmpeg.sh asserts the # LGPL license line at configure time so this can't silently regress. # # arm64 lane: native build. Deployment target = 11.0 (Big Sur, the # earliest macOS supporting Apple Silicon). Tools + the one library # we do NOT source-build (ncurses) still come from brew: arm64 # bottles target 11.0+, which matches our floor. libunistring and # libdeflate left that list when they joined the source-built chain. # libunistring is LGPL and ships inside the pack, so we need a pinned # source tarball we can hand a user, which a moving brew bottle can't # be. libdeflate is MIT (no source duty), but it left for a build # reason instead: the workspace cache dir this lane's env block # points every lookup mechanism at (PKG_CONFIG_PATH, CMAKE_PREFIX_PATH, # CPATH — see the "Configure + build notcurses" step) never included # brew's include path, so notcurses' find_path(libdeflate.h) came up # empty even with the brew keg installed. Source-building it into the # same cache prefix as everything else fixes the lookup and makes # resources/third-party.json's "ours everywhere" libdeflate claim # actually true on this lane. # # x86_64 lane: Rosetta build on the same arm64 runner. Two complications: # # 1. Toolchain PATH. /opt/homebrew/bin (arm64) wins PATH search by # default; `arch -x86_64 cmake` then tries to run the arm64 # cmake under Rosetta and fails ("Bad CPU type in executable"). # Fix: install x86_64 brew under /usr/local + prepend to # $GITHUB_PATH so x86_64 toolchain binaries win. # # 2. Library deployment target. brew x86_64 bottles currently # target macOS 14+ (brew's Intel CI runners are Sonoma+), so a # brew-installed ncurses would stamp LC_BUILD_VERSION minos=14.0 # and fail our 10.15 audit. Fix: install brew x86_64 for *tools # only* (cmake, pkg-config, nasm, dylibbundler — they don't end # up in the bundle) and source-build that library (plus # libdeflate, which both lanes now source-build regardless — see # the arm64 note above) into the same workspace cache dir with # MACOSX_DEPLOYMENT_TARGET=10.15 in env. # # The `host-arch` helper emitted below is the single seam between # the two: `exec "$@"` on arm64, `exec arch -x86_64 "$@"` on x86_64. # Every compile-something step goes through it. # # Both lanes share the bundle-macos composite action + the perf- # shim compile step. name: _build-macos on: workflow_call: inputs: arch: description: '`arm64` or `x86_64`.' type: string required: true deployment-target: description: >- MACOSX_DEPLOYMENT_TARGET passed to clang and CMake. Defaults to 11.0 for arm64, 10.15 for x86_64. type: string required: true artifact-name: description: 'Archive basename, e.g. notcurses-macos-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 MACOSX_DEPLOYMENT_TARGET: ${{ inputs.deployment-target }} jobs: build: name: build-macos-${{ inputs.arch }} runs-on: macos-14 steps: - uses: actions/checkout@v6 with: submodules: recursive - name: Fetch notcurses source (NOTCURSES_FORK pin) run: bash scripts/ci/fetch-notcurses-source.sh - name: Emit arch dispatch helper run: | set -euxo pipefail mkdir -p "$GITHUB_WORKSPACE/bin" if [[ "${{ inputs.arch }}" == "x86_64" ]]; then printf '#!/bin/bash\nexec arch -x86_64 "$@"\n' > "$GITHUB_WORKSPACE/bin/host-arch" # x86_64 brew installs under /usr/local. Prepend so # x86_64 toolchain binaries (cmake, pkg-config, nasm, # dylibbundler) win PATH search over the runner's # default arm64 brew at /opt/homebrew. Without this, # `host-arch cmake` → `arch -x86_64 cmake` tries to # run the arm64 cmake binary under Rosetta and fails # with "arch: posix_spawnp: cmake: Bad CPU type in # executable". echo "/usr/local/bin" >> "$GITHUB_PATH" else printf '#!/bin/bash\nexec "$@"\n' > "$GITHUB_WORKSPACE/bin/host-arch" fi chmod +x "$GITHUB_WORKSPACE/bin/host-arch" echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH" - name: Install Rosetta + x86_64 Homebrew (x86_64 lane only) if: inputs.arch == 'x86_64' run: | set -euxo pipefail # Rosetta may already be installed on the runner image — # softwareupdate is idempotent and exits 0 if so. softwareupdate --install-rosetta --agree-to-license || true # x86_64 brew installs under /usr/local (arm64 brew is at # /opt/homebrew). The two coexist on the same machine. if [[ ! -x /usr/local/bin/brew ]]; then arch -x86_64 /bin/bash -c \ "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \ < /dev/null fi /usr/local/bin/brew --version - name: Install build deps run: | set -euxo pipefail if [[ "${{ inputs.arch }}" == "x86_64" ]]; then # x86_64 lane: install TOOLS ONLY via brew (they don't # end up in the bundle, so their bottle's minos target # doesn't matter). The library deps — ffmpeg + accelerated # codecs (libdav1d, libvpx, libopus), libunistring, # ncurses, libdeflate — are source-built into a workspace cache dir # below with MACOSX_DEPLOYMENT_TARGET baked in via env, so # the produced dylibs stamp minos=10.15 and pass the audit. # meson + ninja are needed by libdav1d's build system. # # Two-step install: # 1. python@3.14 with --overwrite. meson depends on # python@3.14, and brew's auto-link step on that # dep would otherwise fail because the runner ships # with Apple's Python framework already populating # /usr/local/bin/{idle3,pip3,python3,...} as # symlinks into /Library/Frameworks. --overwrite # tells brew to clobber them — but --overwrite only # applies to the formula being installed, NOT to # its auto-installed dependencies. So we must # install python@3.14 EXPLICITLY first. # 2. The rest of the tools. python@3.14 is now # already installed, so meson's resolution finds # it satisfied and proceeds without re-linking. arch -x86_64 /usr/local/bin/brew install --overwrite python@3.14 arch -x86_64 /usr/local/bin/brew install \ cmake pkg-config nasm dylibbundler meson ninja echo "BREW_PREFIX=/usr/local" >> "$GITHUB_ENV" else # arm64 lane: brew arm64 bottles target 11.0+, which # matches our deployment-target floor, so ncurses can # stay package-managed. libdeflate is NOT in this list — # see the "Source-build libraries" step below for why it # now source-builds on arm64 too. # # ffmpeg is deliberately NOT in this list any more (nor # x264/x265/etc, which only ever arrived as its # dependencies): brew's ffmpeg is a GPL build and this # lane now source-builds the LGPL decoder-only ffmpeg # chain below, same as x86_64. meson + ninja are here # for libdav1d's build system; nasm is NOT needed — # dav1d/libvpx/ffmpeg all use ARM-native assembly on # arm64 and build-ffmpeg.sh's nasm gate is x86-only. # # libunistring is NOT here either, and for a different # reason than ffmpeg: it is LGPL and it ships inside the # pack, so the licence obliges us to be able to hand over # the corresponding source. A brew bottle version moves # under us and is garbage-collected; a pinned tarball with # a recorded SHA-256 in resources/third-party.json does # not. Both lanes source-build it below. brew install cmake pkg-config ncurses \ dylibbundler meson ninja echo "BREW_PREFIX=$(brew --prefix)" >> "$GITHUB_ENV" fi # Both lanes: cache + source-build libraries into a per-arch # workspace prefix dir with MACOSX_DEPLOYMENT_TARGET in env, so # every produced dylib carries the right LC_BUILD_VERSION # minos. First run pays ~15-20 min wallclock (mostly ffmpeg); # subsequent runs hit cache and skip the source builds # entirely. # # 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 on the Windows # lane's equivalent cache — it built the entire libdeflate→ # dav1d→vpx→opus chain, died at the ffmpeg download, and saved # nothing, so the next push re-paid the whole ~15-20 min build. # Saving explicitly, immediately after the source-build step and # before "Configure + build notcurses" (which is what can still # fail afterwards), keeps the cache whatever the rest of the job # goes on to do. - name: Restore source-built libraries id: macos-libs uses: actions/cache/restore@v4 with: path: _ci-cache/macos-${{ inputs.arch }} # Hash all 7 source-build scripts — any version bump or # config change invalidates the cache. arm64 only runs 6 of # them (it keeps ncurses from brew), so a change to # build-ncurses.sh invalidates its cache needlessly; that # costs one rebuild and is far cheaper than the alternative # failure mode of a per-lane script list drifting out of # sync with the step below. key: macos-${{ inputs.arch }}-libs-${{ inputs.deployment-target }}-${{ hashFiles('scripts/ci/build-ffmpeg.sh', 'scripts/ci/build-libdeflate.sh', 'scripts/ci/build-libdav1d.sh', 'scripts/ci/build-libvpx.sh', 'scripts/ci/build-libopus.sh', 'scripts/ci/build-ncurses.sh', 'scripts/ci/build-libunistring.sh') }} - name: Source-build libraries if: steps.macos-libs.outputs.cache-hit != 'true' env: PREFIX: ${{ github.workspace }}/_ci-cache/macos-${{ inputs.arch }} MACOSX_DEPLOYMENT_TARGET: ${{ inputs.deployment-target }} run: | set -euxo pipefail mkdir -p "$PREFIX" # Every build goes through `host-arch`: a no-op on arm64, # `arch -x86_64` on the Rosetta lane so the produced # binaries are x86_64 Mach-O. clang (native or under # Rosetta) reads MACOSX_DEPLOYMENT_TARGET from env and # stamps every dylib's LC_BUILD_VERSION minos accordingly. if [[ "${{ inputs.arch }}" == "x86_64" ]]; then # x86_64 only: brew's x86_64 bottle of ncurses targets # macOS 14+, so it'd fail the 10.15 audit. arm64 takes it # from brew instead. host-arch bash scripts/ci/build-ncurses.sh fi # libdeflate — BOTH lanes now, but for two different # reasons. x86_64 needs it for the same deployment-target # reason as ncurses above. arm64 needs it because the cache- # rooted env block below (PKG_CONFIG_PATH / CMAKE_PREFIX_PATH # / CPATH, all pointed at $PREFIX = the workspace cache) no # longer exposes brew's include path at all, so notcurses' # `find_path(DEFLATE_INCLUDE_DIR libdeflate.h)` came up empty # ("Couldn't find libdeflate.h") even with libdeflate # installed via brew. Source-building it into the same # prefix everything else lives in keeps every lookup # mechanism consistent — and makes resources/third-party.json's # libdeflate entry (which already claims "ours everywhere") # actually true on arm64 rather than silently shipping # brew's copy under our own manifest claim. host-arch bash scripts/ci/build-libdeflate.sh # libunistring — BOTH lanes, for licensing rather than # deployment-target reasons: it is LGPL, it ships in the # pack, and a pinned tarball is the only way to still be # able to produce the corresponding source months later. host-arch bash scripts/ci/build-libunistring.sh # The ffmpeg chain — both lanes. Order matters: ffmpeg # probes for libdav1d / libvpx / libopus via pkg-config in # $PREFIX/lib/pkgconfig at configure time, so they need to # land first. host-arch bash scripts/ci/build-libdav1d.sh host-arch bash scripts/ci/build-libvpx.sh host-arch bash scripts/ci/build-libopus.sh host-arch bash scripts/ci/build-ffmpeg.sh - name: Save source-built libraries if: steps.macos-libs.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: _ci-cache/macos-${{ inputs.arch }} key: ${{ steps.macos-libs.outputs.cache-primary-key }} - name: Configure + build notcurses run: | set -euxo pipefail cd "$NOTCURSES_SRC_DIR" mkdir -p build # Both lanes: the source-built libs live in a per-arch # workspace cache. ffmpeg's + libdeflate's .pc/include files # are under $CACHE (plus ncurses' too, on x86_64, which # source-builds that as well). CACHE="${GITHUB_WORKSPACE}/_ci-cache/macos-${{ inputs.arch }}" export PKG_CONFIG_PATH="$CACHE/lib/pkgconfig:${PKG_CONFIG_PATH:-}" export DYLD_LIBRARY_PATH="$CACHE/lib:${DYLD_LIBRARY_PATH:-}" export LIBRARY_PATH="$CACHE/lib:${LIBRARY_PATH:-}" # CMAKE_PREFIX_PATH for find_path / find_library / # find_package — notcurses' CMakeLists.txt locates # libdeflate via raw find_path (not pkg-config like it uses # for ffmpeg), so without this BOTH lanes error with # "Couldn't find libdeflate.h" despite libdeflate being at # $CACHE/include/. This bit arm64 once already: the lane # kept libdeflate on brew while this env block only ever # pointed at $CACHE, and brew's own include path was never # in it — CMAKE_PREFIX_PATH is what has to carry it, and now # does, because libdeflate lives in $CACHE on both lanes. export CMAKE_PREFIX_PATH="$CACHE:${CMAKE_PREFIX_PATH:-}" # CPATH so direct cc -I resolution finds headers when # notcurses' build invokes the compiler outside CMake's # find_X flag set (unigbrk.h on both lanes; ncurses.h on # x86_64; the ffmpeg headers on both). export CPATH="$CACHE/include:${CPATH:-}" if [[ "${{ inputs.arch }}" == "arm64" ]]; then # arm64 lane: ncurses and libdeflate still # come from brew, and Homebrew's ncurses is keg-only so # it isn't in the default pkg-config path. $BREW_PREFIX # is /opt/homebrew (set by the "Install build deps" # step). APPENDED after $CACHE so our source-built # ffmpeg .pc files always win over anything brew may # have pulled in transitively. export PKG_CONFIG_PATH="${PKG_CONFIG_PATH}:${BREW_PREFIX}/opt/ncurses/lib/pkgconfig" fi host-arch cmake -B build -S . \ -DCMAKE_OSX_DEPLOYMENT_TARGET=${{ inputs.deployment-target }} \ -DCMAKE_OSX_ARCHITECTURES=${{ inputs.arch }} \ $CMAKE_FLAGS # Assert notcurses linked OUR libunistring rather than a # brew one. This matters most on arm64, where brew is fully # populated and libunistring arrives as a transitive # dependency of half a dozen formulae the runner image # already has — `find_library(unistring unistring REQUIRED)` # would find /opt/homebrew/lib/libunistring.dylib perfectly # happily, and dylibbundler would then ship a binary we # never pinned or recorded a source tarball for. Same class # of check as the Windows lane's DEFLATE:FILEPATH assertion. # awk-with-exit rather than `grep | head -1`: head closing # the pipe SIGPIPEs its producer and `set -o pipefail` # turns that into a mystery step failure. unistring_lib=$(awk -F= '/^unistring:FILEPATH=/{print $2; exit}' \ build/CMakeCache.txt) case "$unistring_lib" in "$CACHE"/*) echo "ok: unistring resolved to $unistring_lib" ;; *) echo "❌ find_library(unistring) resolved to '$unistring_lib'," echo " which is outside the source-built prefix '$CACHE'." echo " The pack would ship a libunistring we never built," echo " pinned, or recorded a source tarball for." exit 1 ;; esac host-arch cmake --build build -j"$(sysctl -n hw.ncpu)" - name: Bundle + relocate + codesign + audit dylibs uses: ./.github/actions/bundle-macos with: # Only audit on the x86_64 lane. arm64's source-built # ffmpeg + libunistring + libdeflate chain stamps minos=11.0 # and would pass, but the one lib it still takes from brew # (ncurses) is a bottle built on whatever macOS brew's arm64 # CI runs, so a strict 11.0 audit here would fail on a dep # this chunk isn't moving. Revisit if/when that one moves to # source-build as well. audit-min-macos: ${{ inputs.arch == 'x86_64' && '10.15' || '' }} # Point dylibbundler at the source-built lib dir so it can # resolve the @rpath references for libavcodec etc. into # actual files on disk. Needed on both lanes now that # arm64 source-builds the ffmpeg chain too — without it # dylibbundler dies with "Cannot resolve path # @rpath/libavcodec..dylib". extra-search-path: ${{ format('{0}/_ci-cache/macos-{1}/lib', github.workspace, inputs.arch) }} # Release gate: every dylib in the pack that we source-built is # the file we source-built. # # The find_library(unistring) assertion in the configure step # covers what notcurses LINKED. This covers what dylibbundler # COPIED, which is a different question: dylibbundler resolves # @rpath references through its own -s search list plus the # destination directory, and every library here has an # identically-named twin somewhere under brew on this runner # (libunistring in particular arrives as a transitive dependency # of several formulae the macos-14 image ships with). One # reordered search path and the pack ships a bottle while # THIRD-PARTY.md and the attached libunistring-1.4.2.tar.gz say # we compiled it — which, for the LGPL library in this pack, is # a false statement about corresponding source rather than a # cosmetic inaccuracy. brew's bottle happens to be 1.4.2 today, # so the claim would even be true; that is precisely the kind of # coincidence that stops being true silently. # # Compared by LC_UUID rather than by hash, unlike the Windows # lane's equivalent: bundle-macos deliberately rewrites install # names, strips and re-signs every dylib it stages, so the bytes # cannot match by design. install_name_tool, strip -x and # `codesign --force --sign -` all leave LC_UUID alone (the # linker emits it and it is what pairs a binary with its dSYM), # so it survives exactly the transformations that change the # hash and nothing else. - name: Assert the pack's self-built dylibs are ours run: | set -euo pipefail CACHE="${GITHUB_WORKSPACE}/_ci-cache/macos-${{ inputs.arch }}" fail=0 checked=0 uuid_of() { otool -l "$1" \ | awk '/^[[:space:]]*uuid[[:space:]]/{print $2; exit}' } for dylib in bundle/*.dylib; do [[ -L "$dylib" ]] && continue base=$(basename "$dylib") staged="$CACHE/lib/$base" # No staged twin: brew's ncurses on the arm64 lane, or one # of notcurses' own libraries. Those are accounted for by # the third-party audit, not here. [[ -f "$staged" ]] || continue ours=$(uuid_of "$staged") shipped=$(uuid_of "$dylib") if [[ -z "$ours" || -z "$shipped" ]]; then echo "::error file=$dylib::no LC_UUID on one of the two copies" echo " staged ($staged): '${ours:-}'" echo " bundled ($dylib): '${shipped:-}'" fail=1 continue fi if [[ "$ours" != "$shipped" ]]; then echo "::error file=$dylib::is not the $base we built" echo " staged ($staged): $ours" echo " bundled ($dylib): $shipped" fail=1 continue fi echo "ok: $base matches the staged build ($ours)" checked=$(( checked + 1 )) done # libunistring by name, because for the copyleft library the # dangerous outcome is absence from the comparison above # rather than a mismatch inside it — a bundled dylib with no # staged twin is silently skipped, and "no staged twin" # is exactly what taking brew's copy would look like. for required in libunistring.5.dylib; do if [[ ! -f "$CACHE/lib/$required" ]]; then echo "::error::\$CACHE/lib/$required does not exist — the source" echo " 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 notcurses links directly." fail=1 fi done if (( fail != 0 )); then echo echo "❌ The pack does not match the libraries this lane built." echo "--- \$CACHE/lib ---" ls -la "$CACHE/lib" || true echo "--- bundle/ ---" ls -la bundle/ || true exit 1 fi echo "✅ $checked bundled dylib(s) carry the LC_UUID of the ones" echo " this lane source-built." # Compile the perf shim into the bundle so users installing the # prebuilt archive don't need a C toolchain to get the fast path. # See src/notcurses_native_shim.c for what's in it; mirrors # Build.rakumod's !try-compile-shim flags. -undefined dynamic_lookup # defers the notcurses symbols to runtime (resolved against the # libnotcurses-core.dylib already in this same bundle, loaded by # Notcurses::Native at startup). - name: Compile perf shim run: | set -euxo pipefail # -Wl,-headerpad_max_install_names: reserve generous load- # command padding so a downstream install_name_tool call # (e.g. !rewrite-macos-install-names in Build.rakumod, were # it to ever touch the shim) doesn't fail with "larger # updated load commands do not fit". Standard practice for # any dylib that may be relocated post-link. host-arch cc -O2 -dynamiclib -fPIC \ -mmacosx-version-min=${{ inputs.deployment-target }} \ -Wl,-headerpad_max_install_names \ -install_name '@loader_path/libnotcurses_native_shim.dylib' \ -undefined dynamic_lookup \ -I "$NOTCURSES_SRC_DIR/include" \ -o bundle/libnotcurses_native_shim.dylib \ src/notcurses_native_shim.c # Sequoia hard-rejects unsigned dylibs at first page access — # ad-hoc sign matches what we do for the notcurses libs above. codesign --force --sign - --timestamp=none \ bundle/libnotcurses_native_shim.dylib codesign --verify bundle/libnotcurses_native_shim.dylib echo "--- shim symbols ---" nm -gU bundle/libnotcurses_native_shim.dylib \ | grep notcurses_native_copy_cells # Sidecar for Build.rakumod's content-based freshness # check: the SHA-256 of the shim source this shim was # compiled from. Without it, installs fall back to a # cross-machine mtime comparison that always thinks the # dist's source is newer than the packed shim and # recompiles (or, toolchain-less, warns and drops to the # slow per-cell path). shasum -a 256 src/notcurses_native_shim.c | awk '{print $1}' \ > bundle/libnotcurses_native_shim.dylib.srchash # Release gate: dlopen bundle/libavcodec.dylib and 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 # --enable-libfoo (its pkg-config probe failed) and silently # produced a bundle with internal-only decoders. Both lanes now # run our source-built ffmpeg, so on both this is a check that # build-ffmpeg.sh's configure stayed correct. - name: Codec capability probe (release gate) run: host-arch bash scripts/ci/run-codec-probe.sh - name: Package + upload uses: ./.github/actions/package-and-upload with: artifact-name: ${{ inputs.artifact-name }} format: tar.gz platform: macos