name: CI

on:
  pull_request:
    paths-ignore:
      - 'doc/**'
      - '**.md'
  push:
    branches: [main]
    paths-ignore:
      - 'doc/**'
      - '**.md'
  workflow_call:
    inputs:
      upload-artifact:
        description: Upload built kermit binaries as artifacts
        type: boolean
        default: false
      matrix:
        description: >-
          JSON array of {os, platform, arch, make-target, label,
          skip-tests, skip-c-tests} entries to build/test (skip-tests
          and skip-c-tests are optional, default false; skip-tests
          skips both the C and Python suites, skip-c-tests only the
          C one.  That's for the musl target, which has no musl build
          of libcheck to link its unit tests against).
          release.yml overrides this with the full
          release platform set. Empty here
          because workflow_call input defaults only
          apply when this workflow is actually invoked via workflow_call.
          For direct push/pull_request triggers, there's no caller
          supplying inputs, so inputs.matrix would just be '' either way.
          The real default lives in the || fallback below.
        type: string
        default: ''
      version:
        description: >-
          Release version string (tag with the leading "v" stripped,
          such as "11.0"), used only to label the BSD
          release binaries below. release.yml supplies this.  Direct
          push or pull request triggers never upload artifacts, so it is
          unused in that case.
        type: string
        default: ''

# Cancel a run in favor of a newer one for the same PR or branch, so
# an outdated push doesn't keep burning runner time once superseded.
# Release runs (triggered by a tag push via workflow_call) get their
# own group per tag, since github.ref differs from any branch/PR ref.
concurrency:
  group: >-
    ${{ github.workflow }}-${{ github.event.pull_request.number ||
    github.ref }}
  cancel-in-progress: true

jobs:
  build-and-test:
    strategy:
      fail-fast: false
      # On push to main (but not on pull requests, to keep those fast),
      # also build and test linux+ssl+musl on arm64. skip-c-tests is
      # set because there is no musl build of libcheck to link its
      # unit tests against.
      #
      # The first half of this list is the ones we run on push.  The second
      # half runs on PR.  The BSD builds aren't part of the matrix, so run
      # separately elsewhere.
      matrix:
        include: >-
          ${{ fromJson(inputs.matrix || (github.event_name == 'push' && '[
          {"os":"ubuntu-26.04","platform":"linux","arch":"amd64","make-target":"linux+ssl","label":"ubuntu26.04-amd64"},
          {"os":"ubuntu-24.04-arm","platform":"linux","arch":"arm64","make-target":"linux+ssl+musl","label":"ubuntu24.04-arm64-musl","skip-c-tests":true},
          {"os":"macos-latest","platform":"macos","arch":"arm64","make-target":"macos+ssl","label":"macos-arm64"}
          ]' || '[
          {"os":"ubuntu-26.04","platform":"linux","arch":"amd64","make-target":"linux+ssl","label":"ubuntu26.04-amd64"},
          {"os":"macos-latest","platform":"macos","arch":"arm64","make-target":"macos+ssl","label":"macos-arm64"}
          ]')) }}
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4

      - name: Install Linux dependencies
        if: matrix.platform == 'linux'
        run: |
          sudo apt-get update
          sudo apt-get install -y build-essential libssl-dev libpam0g-dev \
            zlib1g-dev libzstd-dev check python3-pytest \
            python3-pytest-xdist python3-pytest-timeout \
            python3-pyftpdlib lrzsz

      - name: Install macOS dependencies
        if: matrix.platform == 'macos'
        run: |
          brew install openssl@3 check lrzsz
          pip3 install --break-system-packages \
            pytest pytest-xdist pytest-timeout pyftpdlib
          # openssl@3 is keg-only, so brew install never links it onto
          # PATH. Plain "openssl" would otherwise resolve to macOS's own
          # /usr/bin/openssl (LibreSSL), whose CLI diverges too far from
          # real OpenSSL's for test_ssl.py's PKI fixture, which drives
          # both "openssl req"/"x509 -req" and "openssl ca" to build a
          # small CA and mint an already-expired certificate.
          echo "$(brew --prefix openssl@3)/bin" >> "$GITHUB_PATH"

      # linux+ssl+musl and linux+musl both link against a from-source,
      # musl-gcc-built OpenSSL (linux+ssl+musl only), zlib, and
      # ncurses. This runs for every matrix entry using either
      # target, including the release-time "static" amd64/arm64
      # binaries (release.yml) as well as the arm64 push-to-main test
      # below. Cache the built install prefix keyed on the build
      # script plus arch, since amd64 and arm64 need separate builds;
      # the key changes if the script (and so the pinned library
      # versions) changes, so a version bump always rebuilds rather
      # than risking a stale cache hit.
      - name: Cache musl OpenSSL/zlib/ncurses
        if: >-
          matrix.make-target == 'linux+ssl+musl' ||
          matrix.make-target == 'linux+musl'
        uses: actions/cache@v4
        with:
          path: .ci-cache/musl-libs
          key: >-
            musl-libs-${{ matrix.arch }}-${{
            hashFiles('tools/build-musl-libs.sh') }}

      - name: Install musl toolchain
        if: >-
          matrix.make-target == 'linux+ssl+musl' ||
          matrix.make-target == 'linux+musl'
        run: |
          sudo apt-get update
          sudo apt-get install -y musl-tools
          tools/build-musl-libs.sh .ci-cache/musl-libs

      - name: Build (Linux)
        if: >-
          matrix.platform == 'linux' &&
          matrix.make-target != 'linux+ssl+musl' &&
          matrix.make-target != 'linux+musl'
        run: make -j4 ${{ matrix.make-target }}

      # linux+ssl+musl and linux+musl take different override variable
      # names for the zlib/OpenSSL half (KSSLINC/KSSLLIB vs.
      # KZLIBINC/KZLIBLIB; see the comments on those two makefile
      # targets for why), but share KNCURSESINC for the ncurses half.
      - name: Build (Linux, musl)
        if: >-
          matrix.make-target == 'linux+ssl+musl' ||
          matrix.make-target == 'linux+musl'
        run: |
          if [ "${{ matrix.make-target }}" = "linux+ssl+musl" ]; then
            make -j4 ${{ matrix.make-target }} \
              KSSLINC="-I$PWD/.ci-cache/musl-libs/include" \
              KSSLLIB="-L$PWD/.ci-cache/musl-libs/lib" \
              KNCURSESINC="-I$PWD/.ci-cache/musl-libs/include/ncurses"
          else
            make -j4 ${{ matrix.make-target }} \
              KZLIBINC="-I$PWD/.ci-cache/musl-libs/include" \
              KZLIBLIB="-L$PWD/.ci-cache/musl-libs/lib" \
              KNCURSESINC="-I$PWD/.ci-cache/musl-libs/include/ncurses"
          fi

      - name: Build (macOS)
        if: matrix.platform == 'macos'
        run: |
          make -j4 ${{ matrix.make-target }} \
            KSSLINC="-I$(brew --prefix openssl@3)/include" \
            KSSLLIB="-L$(brew --prefix openssl@3)/lib"

      - name: Verify binary runs
        run: |
          ./wermit -H -Y -Q -C \
            "set command more-prompting off, show version, quit"

      - name: C unit tests
        if: matrix.skip-tests != true && matrix.skip-c-tests != true
        run: make check

      - name: Python test suite
        if: matrix.skip-tests != true
        env:
          # Capture kermit's internal "log debug" trace for
          # every wermit_loopback session, dumped into the pytest
          # failure report.
          KERMIT_TEST_DEBUG_LOOPBACK: 1
        run: pytest -rs --log-level=INFO

      - name: Upload binary
        if: inputs.upload-artifact
        uses: ./.github/actions/upload-binary
        with:
          label: ${{ matrix.label }}

  # FreeBSD/NetBSD/OpenBSD have no native GitHub-hosted runner, so these
  # boot a QEMU VM inside the ubuntu-latest runner via vmactions.
  freebsd-x86_64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Determine host CPU count
        id: cpus
        run: echo "count=$(nproc)" >> "$GITHUB_OUTPUT"

      - name: Build and test on FreeBSD
        uses: vmactions/freebsd-vm@v1
        with:
          cpu: ${{ steps.cpus.outputs.count }}
          usesh: true
          prepare: |
            pkg install -y openssl check pkgconf lrzsz python3 py312-pip
            pip install pytest pytest-xdist pytest-timeout pyftpdlib
            # FreeBSD's lrzsz port installs binaries named lrz and lsz, unlike
            # Debian's lrzsz package, which also provides traditionally-named
            # sz and rz. test_zmodem.py invokes
            # "sz" and "rz" by name, so add those as symlinks.  In production,
            # a local administrator can configure this apporpriately.
            #
            # Similar issue with NetBSD below.
            ln -sf /usr/local/bin/lrz /usr/local/bin/rz
            ln -sf /usr/local/bin/lsz /usr/local/bin/sz
          run: |
            make -j4 freebsd+ssl
            make check
            pytest -rs --log-level=INFO

      - name: Upload binary
        if: inputs.upload-artifact
        uses: ./.github/actions/upload-binary
        with:
          label: kermit-freebsd-amd64-${{ inputs.version }}

  netbsd-x86_64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Determine host CPU count
        id: cpus
        run: echo "count=$(nproc)" >> "$GITHUB_OUTPUT"

      - name: Build and test on NetBSD
        uses: vmactions/netbsd-vm@v1
        with:
          cpu: ${{ steps.cpus.outputs.count }}
          usesh: true
          prepare: |
            /usr/sbin/pkg_add pkgin
            pkgin -y update
            pkgin -y install openssl check lrzsz py313-pip pkgconf
            python3.13 -m pip install \
              pytest pytest-xdist pytest-timeout pyftpdlib
            # NetBSD's pkgsrc lrzsz has the same issue as FreeBSD's; see
            # FreeBSD comment above.
            ln -sf /usr/pkg/bin/lrz /usr/pkg/bin/rz
            ln -sf /usr/pkg/bin/lsz /usr/pkg/bin/sz
            # NetBSD's default pty queue (kern.tty.qsize, 1024 bytes) is
            # too small for the large writes the test suite's jumbo-packet
            # transfers do over a pty loopback. When the reader falls
            # behind under load, the kernel can silently drop a handful of
            # bytes from a write larger than the queue instead of properly
            # blocking or returning a short count, corrupting or hanging
            # transfers with no error on either side. Raising the queue
            # size avoids ever hitting that path.
            sysctl -w kern.tty.qsize=65536
          run: |
            # pkgin's openssl installs to /usr/pkg/bin, but NetBSD's
            # PATH puts /usr/bin ahead of it, so plain
            # "openssl" resolves to the older base-system binary
            # instead. That one doesn't support the "x509 -req
            # -not_before/-not_after" options that test_ssl
            # relies on to create an expired
            # certificate, so put /usr/pkg/bin first.
            export PATH="/usr/pkg/bin:/usr/pkg/sbin:$PATH"
            make -j4 netbsd+ssl-des
            make check
            pytest -rs --log-level=INFO

      - name: Upload binary
        if: inputs.upload-artifact
        uses: ./.github/actions/upload-binary
        with:
          label: kermit-netbsd-amd64-${{ inputs.version }}

  openbsd-x86_64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Determine host CPU count
        id: cpus
        run: echo "count=$(nproc)" >> "$GITHUB_OUTPUT"

      - name: Build and test on OpenBSD
        uses: vmactions/openbsd-vm@v1
        with:
          cpu: ${{ steps.cpus.outputs.count }}
          usesh: true
          prepare: |
            # OpenBSD's package repo keeps more than one openssl major
            # version installable side by side, so a plain
            # "pkg_add openssl" is ambiguous
            # and fails non-interactively. Pick the newest available
            # version explicitly instead of hardcoding one, since pinning
            # an exact version string here would break once that specific
            # package build ages out of the mirrors.
            OPENSSL_PKG=$(pkg_info -Q openssl | \
              grep -E '^openssl-[0-9]' | sort -V | tail -1)
            pkg_add -I "$OPENSSL_PKG" check lrzsz py3-pip
            # The openssl package does not install a binary literally
            # named "openssl": that name is reserved for the base
            # system's own LibreSSL install at /usr/bin/openssl, which
            # is too old for test_ssl.py's expired-certificate fixture.
            # It installs
            # /usr/local/bin/eopensslNN instead. Only the package the
            # ports tree treats as the "default" major version also
            # installs a version-independent /usr/local/bin/eopenssl
            # alias to that; picking the newest version above can land
            # on a non-default major that has no such alias, so derive
            # the actual eopensslNN path from the specific package's own
            # file list instead of assuming the alias exists. Put
            # /usr/local/bin ahead of /usr/bin on PATH (below) so this
            # symlink is actually found.
            OPENSSL_BIN=$(pkg_info -L "$OPENSSL_PKG" | \
              grep -E '/bin/eopenssl[0-9]*$')
            ln -sf "$OPENSSL_BIN" /usr/local/bin/openssl
            # OpenBSD's lrzsz port has the same lrz/lsz-not-sz/rz naming
            # issue as FreeBSD's and NetBSD's; see FreeBSD comment above.
            ln -sf /usr/local/bin/lrz /usr/local/bin/rz
            ln -sf /usr/local/bin/lsz /usr/local/bin/sz
            # OpenBSD's packaged Python is externally managed (PEP 668)
            # and refuses a bare pip install; there's no packaged pytest
            # either. This VM is a disposable CI guest, not a shared
            # system, so install with --break-system-packages instead of
            # setting up a venv.
            pip3 install --break-system-packages \
              pytest pytest-xdist pytest-timeout pyftpdlib
          run: |
            # /usr/local/bin must come before /usr/bin for the "openssl"
            # symlink above to be found ahead of base's own.
            export PATH="/usr/local/bin:$PATH"
            make -j4 openbsd+ssl
            make check
            pytest -rs --log-level=INFO

      - name: Upload binary
        if: inputs.upload-artifact
        uses: ./.github/actions/upload-binary
        with:
          label: kermit-openbsd-amd64-${{ inputs.version }}
