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


# 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.<N>.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:-<none>}'"
              echo "  bundled ($dylib): '${shipped:-<none>}'"
              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