# 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