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


# Reusable workflow: build the linux-<arch>-musl prebuilt notcurses
# archive in an alpine:3.20 Docker container. musl 1.2.5 build
# headers; practical runtime floor is musl 1.20 per notcurses'
# declared support level (Alpine 3.13+, Postmarket OS, Void, Adelie).
#
# Why docker-run from native host instead of GHA's `container:`
# directive: GHA's Node 24 JS-action runtime needs glibc — alpine
# ships musl, so JS actions can't run inside the container. On
# aarch64 GHA explicitly errors with "JavaScript Actions in Alpine
# containers are only supported on x64 Linux runners". Workaround
# is to run JS actions on the native ubuntu-22.04 / -arm runner and
# only invoke the alpine container for the build itself.
#
# The toolchain and the leaf libraries (ncurses, zlib) come from apk,
# but the codec stack — libdeflate, libdav1d, libvpx, libopus,
# ffmpeg — plus libunistring is source-built inside the container
# exactly as the glibc lane does it. (libunistring for a licensing
# reason rather than a codec one: it is LGPL, it ships in the pack,
# and an apk version that moves under us isn't a corresponding source
# we can still produce later.) Alpine's `ffmpeg-dev` is a GPL build
# (libavcodec DT_NEEDEDs x264, x265, SvtAv1Enc, mp3lame, xvidcore),
# and bundle-elf.sh's ldd walk pulled that whole encoder chain into
# the published archive. See the header of build-linux-musl.sh.

name: _build-linux-musl

on:
  workflow_call:
    inputs:
      arch:
        description: '`x86_64` or `aarch64`.'
        type: string
        required: true
      artifact-name:
        description: 'Archive basename, e.g. notcurses-linux-x86_64-musl.'
        type: string
        required: true

jobs:
  build:
    name: build-linux-${{ inputs.arch }}-musl
    runs-on: ${{ inputs.arch == 'aarch64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }}
    steps:
      - uses: actions/checkout@v6
        with:
          submodules: recursive

      - name: Read pinned notcurses SHA from NOTCURSES_FORK
        id: ncpin
        run: |
          # tr -d '\r' defends against a stray CRLF checkout.
          sha=$(awk -F= '/^sha=/{print $2}' NOTCURSES_FORK | tr -d '\r')
          echo "sha=$sha" >> "$GITHUB_OUTPUT"

      - name: Cache notcurses source checkout (per-SHA)
        uses: actions/cache@v4
        with:
          # The checkout only — NOT the build tree cmake creates
          # inside it. This key is shared by all four Linux lanes
          # (glibc/musl x x86_64/aarch64): whichever finishes first
          # saves the cache and the rest restore it on the next run.
          # A `build/` in there would hand a manylinux-x86_64
          # CMakeCache, and its object files, to an alpine-aarch64
          # configure — same /work path, so cmake would not even
          # notice the source dir had moved. Reproduced locally by
          # accident while proving out this chunk, which is how it
          # was found; in CI it would have surfaced as an
          # inexplicable cross-lane build failure long after the
          # commit that caused it.
          path: |
            _ci-cache/notcurses-source
            !_ci-cache/notcurses-source/*/build
          # Pin-keyed so bumping NOTCURSES_FORK invalidates the cache
          # automatically. No fallback restore-keys — a stale half-
          # checkout is worse than re-fetching (~5s shallow clone).
          key: notcurses-source-${{ steps.ncpin.outputs.sha }}

      # 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 — 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,
      # right after the docker run that builds the chain and BEFORE
      # the docker run that builds notcurses (which is what can still
      # fail afterwards), keeps the cache whatever that second run
      # goes on to do. See scripts/ci/build-linux-musl.sh's
      # DEPS_ONLY branch, which is what lets this be its own docker
      # invocation instead of a step buried inside the full build.
      - name: Restore source-built codec stack
        id: musl-deps
        uses: actions/cache/restore@v4
        with:
          # Cached path is workspace-relative so the docker bind
          # mount picks it up automatically. The build script reads
          # it via $CACHE_DIR.
          path: _ci-cache/alpine-3.20-${{ inputs.arch }}
          # Key on:
          #   * the alpine tag + arch (image baseline — musl version,
          #     gcc version, and the arch the .so files are built for)
          #   * hashes of every source-build script so a version bump
          #     or config change invalidates the cache.
          # No fallback restore-keys — partial caches would corrupt
          # the install tree mid-pkgconfig.
          key: vendored-deps-alpine-3.20-${{ inputs.arch }}-${{ 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-libunistring.sh') }}

      - name: Pull build image
        run: docker pull alpine:3.20

      - name: Build codec stack (cache miss only)
        if: steps.musl-deps.outputs.cache-hit != 'true'
        run: |
          # $PWD on the runner is bind-mounted to /work in the
          # container — so checkout's output AND the cache dir are
          # visible to the build script. DEPS_ONLY=1 makes the script
          # stop right after the codec chain is built and chown
          # $CACHE_DIR back to the runner user before exiting, so the
          # save step below can read it.
          docker run --rm \
            -v "$PWD:/work" \
            -w /work \
            -e CACHE_DIR=/work/_ci-cache/alpine-3.20-${{ inputs.arch }} \
            -e DEPS_ONLY=1 \
            alpine:3.20 \
            sh -c 'apk add --no-cache bash >/dev/null && bash scripts/ci/build-linux-musl.sh'

      - name: Save source-built codec stack
        if: steps.musl-deps.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with:
          path: _ci-cache/alpine-3.20-${{ inputs.arch }}
          key: ${{ steps.musl-deps.outputs.cache-primary-key }}

      - name: Build + bundle in alpine:3.20 container
        run: |
          # $PWD on the runner is bind-mounted to /work in the
          # container — so checkout's output AND both cache dirs are
          # visible to the build script. $CACHE_DIR is warm by now
          # either way (restored above, or just built + saved), so
          # this run's own internal cache-hit check skips straight to
          # the notcurses build.
          docker run --rm \
            -v "$PWD:/work" \
            -w /work \
            -e CACHE_DIR=/work/_ci-cache/alpine-3.20-${{ inputs.arch }} \
            alpine:3.20 \
            sh -c 'apk add --no-cache bash >/dev/null && bash scripts/ci/build-linux-musl.sh'

      - name: Package + upload
        uses: ./.github/actions/package-and-upload
        with:
          artifact-name: ${{ inputs.artifact-name }}
          format: tar.gz
          platform: linux