Build on Linux and macOS in CI, with a smoke test and per-commit bina… #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # Unfiltered `push` as well as `pull_request` on purpose. The artifacts below exist so a change | |
| # can be tried on a Pi or a Mac without building it there, and that only works if every branch | |
| # push produces them -- not just the ones that have a pull request open. The cost is that a PR | |
| # raised from a branch in this repo builds twice. | |
| on: | |
| push: | |
| pull_request: | |
| # A superseded push has nothing left to say. This does not collapse the push/PR pair above: | |
| # those carry different refs, and so land in different groups. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| # Named rather than left to default, because the default is `bash -e` with no pipefail -- | |
| # which would let the `| tee` in the configure step below swallow a configure failure. | |
| shell: bash | |
| env: | |
| # Both hosted runner sizes used here have at least three cores. | |
| CMAKE_BUILD_PARALLEL_LEVEL: 3 | |
| jobs: | |
| build: | |
| name: ${{ matrix.name }} | |
| runs-on: ${{ matrix.runner }} | |
| strategy: | |
| # Every leg's result is wanted rather than only the first failure's: a break that shows | |
| # on one architecture and not another is precisely what the matrix is here to tell apart. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: linux-x86_64 | |
| runner: ubuntu-24.04 | |
| with_mdns: 'ON' | |
| expect_backends: '^-- sendspin-cli audio backends: null, stdout, alsa, portaudio$' | |
| expect_mdns: '^-- sendspin-cli mDNS: dns_sd \(.*libdns_sd\.so.*\)$' | |
| runtime_packages: 'libasound2t64 libportaudio2 libavahi-compat-libdnssd1' | |
| publish: true | |
| avahi: true | |
| - name: linux-arm64 | |
| runner: ubuntu-24.04-arm | |
| with_mdns: 'ON' | |
| expect_backends: '^-- sendspin-cli audio backends: null, stdout, alsa, portaudio$' | |
| expect_mdns: '^-- sendspin-cli mDNS: dns_sd \(.*libdns_sd\.so.*\)$' | |
| runtime_packages: 'libasound2t64 libportaudio2 libavahi-compat-libdnssd1' | |
| publish: true | |
| avahi: false | |
| - name: macos-arm64 | |
| runner: macos-14 | |
| with_mdns: 'ON' | |
| expect_backends: '^-- sendspin-cli audio backends: null, stdout, portaudio$' | |
| expect_mdns: '^-- sendspin-cli mDNS: dns_sd \(Bonjour, built in\)$' | |
| runtime_packages: 'portaudio (brew). Bonjour is part of macOS.' | |
| publish: true | |
| avahi: false | |
| # Compile coverage only, and the reason it is a leg of its own: turning mDNS off | |
| # swaps which translation unit is built -- src/mdns_null.cpp instead of | |
| # src/mdns_dnssd.cpp -- so nothing else in the matrix compiles it. It publishes | |
| # nothing; a binary that cannot be discovered is not one to hand anybody. | |
| - name: linux-x86_64-nomdns | |
| runner: ubuntu-24.04 | |
| with_mdns: 'OFF' | |
| expect_backends: '^-- sendspin-cli audio backends: null, stdout, alsa, portaudio$' | |
| expect_mdns: '^-- sendspin-cli mDNS: none$' | |
| publish: false | |
| avahi: false | |
| steps: | |
| # Third-party actions are pinned to a commit rather than a tag: a tag is mutable, and | |
| # these run with a checkout of the repository already in hand. | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install build dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| # pkg-config is named even though the image already has it: it is what finds | |
| # PortAudio, so a change in the image should fail here rather than silently produce | |
| # a player with no audio backend. | |
| sudo apt-get install --no-install-recommends -y \ | |
| pkg-config \ | |
| libasound2-dev \ | |
| portaudio19-dev \ | |
| libavahi-compat-libdnssd-dev | |
| - name: Install build dependencies (macOS) | |
| if: runner.os == 'macOS' | |
| run: brew install portaudio pkgconf | |
| # Only the fetched *sources* are cached, never anything compiled from them: a cache that | |
| # carried object files would keep serving them across a compiler upgrade on the runner | |
| # image, which is the classic way a green CI run stops meaning anything. Restoring the | |
| # downloads alone is where the time goes anyway -- sendspin-cpp pulls its own | |
| # dependencies (ixwebsocket, opus, FLAC) through FetchContent in turn. | |
| # | |
| # CMakeLists.txt holds both pinned tags, so its hash is what changes the key when either | |
| # one moves. | |
| - name: Cache the fetched sources | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| .deps/*-src | |
| .deps/*-subbuild | |
| key: deps-${{ matrix.name }}-${{ hashFiles('CMakeLists.txt') }} | |
| - name: Configure | |
| env: | |
| WITH_MDNS: ${{ matrix.with_mdns }} | |
| DEPS_DIR: ${{ github.workspace }}/.deps | |
| run: | | |
| cmake -B build \ | |
| -DCMAKE_BUILD_TYPE=Release \ | |
| -DSENDSPIN_CLI_WERROR=ON \ | |
| -DSENDSPIN_CLI_WITH_MDNS="$WITH_MDNS" \ | |
| -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" \ | |
| 2>&1 | tee configure.log | |
| # The check that stops a leg passing while quietly building something else. A missing | |
| # -dev package does not fail the configure -- every backend here is optional and | |
| # auto-detected by design -- so without this the matrix would happily ship a | |
| # null-sink-only, mDNS-less binary and call it green. | |
| # | |
| # Whole-line matches, anchored: `audio backends:` on its own is also satisfied by the | |
| # degraded `null, stdout` line, which is the exact failure being guarded against. | |
| - name: Assert the configure output found what this leg expects | |
| env: | |
| EXPECT_BACKENDS: ${{ matrix.expect_backends }} | |
| EXPECT_MDNS: ${{ matrix.expect_mdns }} | |
| run: | | |
| status=0 | |
| for expected in "$EXPECT_BACKENDS" "$EXPECT_MDNS"; do | |
| if ! grep -Eq -e "$expected" configure.log; then | |
| echo "::error::configure output has no line matching: $expected" | |
| status=1 | |
| fi | |
| done | |
| echo 'What configure actually reported:' | |
| grep -F -- '-- sendspin-cli' configure.log || | |
| echo '(configure.log has no sendspin-cli lines at all)' | |
| exit "$status" | |
| - name: Build | |
| run: cmake --build build | |
| # Run on every leg, the no-mDNS one included: the suite links sendspin-cli-core, so | |
| # discovery_test.cpp is compiled against whichever MdnsService went in. That | |
| # configuration has no other coverage in this matrix. | |
| - name: Test | |
| run: ctest --test-dir build --output-on-failure | |
| # Deliberately before avahi-daemon is installed below. With no daemon to register with, | |
| # this is what exercises the non-fatal advertise-failure path in src/main.cpp -- the | |
| # player has to come up and warn rather than exit. | |
| - name: Smoke test | |
| if: matrix.publish | |
| run: scripts/smoke_test.sh build/sendspin-cli | |
| # The one *runtime* claim this matrix owes roadmap item 5, which was built and exercised | |
| # against Bonjour only: that libavahi-compat-libdnssd really implements the calls | |
| # src/mdns_dnssd.cpp makes of it. Reading its sources said so; this runs it. | |
| # | |
| # Two halves, because they cover different calls. Browsing our own advertisement back | |
| # proves DNSServiceRegister reached the daemon. Discovering a server proves the rest of | |
| # the chain -- Browse, Resolve, and above all DNSServiceQueryRecord for A and AAAA, which | |
| # the compat layer implements in place of the DNSServiceGetAddrInfo it lacks entirely: a | |
| # ws:// URL cannot be built at all without an address that query returned. | |
| - name: Advertise and discover through a real Avahi daemon | |
| if: matrix.avahi | |
| run: | | |
| sudo apt-get install --no-install-recommends -y avahi-daemon avahi-utils | |
| sudo systemctl start avahi-daemon | |
| instance="sendspin-ci-$GITHUB_RUN_ID" | |
| # Half one: our advertisement, browsed back and resolved to an address. | |
| ./build/sendspin-cli -o null --port 39301 --mdns-name "$instance" >advertise.log 2>&1 & | |
| player=$! | |
| trap 'kill -TERM "$player" 2>/dev/null || true' EXIT | |
| for _ in $(seq 1 200); do | |
| if grep -q 'advertising _sendspin\._tcp' advertise.log; then break; fi | |
| sleep 0.1 | |
| done | |
| grep -q 'advertising _sendspin\._tcp' advertise.log || { | |
| echo '::error::the player never registered its advertisement' | |
| cat advertise.log | |
| exit 1 | |
| } | |
| # -p is the parseable form: resolved records come back as `=;iface;proto;instance;...` | |
| # with the address in field 8 and the port in field 9. | |
| avahi-browse -rpt _sendspin._tcp >browse.txt | |
| echo 'What avahi-browse resolved:' | |
| cat browse.txt | |
| awk -F';' -v want="$instance" ' | |
| $1 == "=" && $4 == want && $8 != "" && $9 == "39301" { found = 1 } | |
| END { exit found ? 0 : 1 } | |
| ' browse.txt || { | |
| echo "::error::$instance was not browsable with a resolved address on port 39301" | |
| exit 1 | |
| } | |
| kill -TERM "$player" | |
| wait "$player" | |
| trap - EXIT | |
| # Half two: a server published into the same daemon, discovered and resolved by our | |
| # own dns_sd code. Nothing is listening on 8927, so the dial that follows will fail; | |
| # what is being asserted is the URL, which only exists if QueryRecord answered. | |
| avahi-publish -s "$instance-server" _sendspin-server._tcp 8927 'path=/sendspin' & | |
| publisher=$! | |
| trap 'kill -TERM "$publisher" 2>/dev/null || true' EXIT | |
| ./build/sendspin-cli -o null --port 39302 -s "mdns:$instance-server" \ | |
| >discover.log 2>&1 & | |
| client=$! | |
| trap 'kill -TERM "$client" "$publisher" 2>/dev/null || true' EXIT | |
| for _ in $(seq 1 300); do | |
| if grep -q "Connecting to ws://.*(server \"$instance-server\")" discover.log; then | |
| break | |
| fi | |
| sleep 0.1 | |
| done | |
| echo 'What the discovering client logged:' | |
| cat discover.log | |
| grep -q "Connecting to ws://.*(server \"$instance-server\")" discover.log || { | |
| echo '::error::the server was published but never discovered and resolved to a URL' | |
| exit 1 | |
| } | |
| kill -TERM "$client" "$publisher" | |
| # A build output for trying a commit on real hardware, not an installation. `install()` | |
| # rules, a systemd unit and distribution packages are roadmap item 10, and this tar is | |
| # what that task replaces with a staged `cmake --install` payload. | |
| # | |
| # Tarred rather than handed to upload-artifact loose because the artifact is served as a | |
| # zip, and zip does not carry the executable bit -- an untarred binary would arrive | |
| # chmod-less. | |
| - name: Package | |
| if: matrix.publish | |
| id: package | |
| env: | |
| RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} | |
| LEG: ${{ matrix.name }} | |
| run: | | |
| version="$(./build/sendspin-cli --version | awk 'NR == 1 { print $2 }')" | |
| lib_tag="$(./build/sendspin-cli --version | awk 'NR == 2 { print $2 }')" | |
| name="sendspin-cli-$version-$LEG" | |
| mkdir -p "stage/$name" | |
| cp build/sendspin-cli README.md LICENSE "stage/$name/" | |
| cat >"stage/$name/BUILD-INFO.txt" <<INFO | |
| sendspin-cli $version for $LEG | |
| Built from commit $GITHUB_SHA on $RUNNER_OS/$RUNNER_ARCH | |
| Linked against sendspin-cpp $lib_tag | |
| Runtime packages this binary needs: | |
| $RUNTIME_PACKAGES | |
| Unpack anywhere and run ./sendspin-cli --help. This is a build output rather than an | |
| installation: install rules, a systemd unit and distribution packages are roadmap | |
| item 10, and are not part of this archive. | |
| INFO | |
| tar -czf "$name.tar.gz" -C stage "$name" | |
| echo "name=$name" >>"$GITHUB_OUTPUT" | |
| - name: Upload | |
| if: matrix.publish | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: ${{ steps.package.outputs.name }} | |
| path: ${{ steps.package.outputs.name }}.tar.gz | |
| # Long enough to bisect a regression against, short enough that per-commit builds of | |
| # every branch do not accumulate. Nothing here is a release. | |
| retention-days: 14 | |
| if-no-files-found: error |