Skip to content

Build on Linux and macOS in CI, with a smoke test and per-commit bina… #1

Build on Linux and macOS in CI, with a smoke test and per-commit bina…

Build on Linux and macOS in CI, with a smoke test and per-commit bina… #1

Workflow file for this run

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