Skip to content

Merge pull request #8 from chrisuthe/chrisuthe/task/add-a-linux-macos… #7

Merge pull request #8 from chrisuthe/chrisuthe/task/add-a-linux-macos…

Merge pull request #8 from chrisuthe/chrisuthe/task/add-a-linux-macos… #7

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:
# A gate rather than a claim: scripts/smoke_test.sh is asserted to be shellcheck-clean, and
# an assertion nothing enforces is one that stops being true. shellcheck ships on the Ubuntu
# images, so this costs a runner-minute.
shellcheck:
name: shellcheck
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Lint the shell scripts
run: shellcheck scripts/*.sh
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
# Well clear of a cold cache on the slowest leg, and far under the six-hour default: the
# point is that a player which ignores SIGTERM fails rather than holds a runner all day.
timeout-minutes: 30
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
# `publish` and `avahi` are spelled out on every leg, false included: an `if:` reads a
# missing key as false, so a leg that simply omitted one would quietly stop publishing
# and still go green. A new leg owes both.
matrix:
include:
- name: linux-x86_64
runner: ubuntu-24.04
with_mdns: 'ON'
expect_probe: 'Failed'
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_probe: 'Failed'
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_probe: 'Success'
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:
# Every third-party action here is pinned to a commit rather than a tag, because a tag
# can be repointed at any time and these run with write access to the workspace. The
# trailing comment is the version that commit was, for a reader bumping it.
- 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
# The fetched sources and the populate stamps that record they were fetched -- the
# stamps live in `-subbuild`, which is a generated CMake tree rather than a download, and
# without them every run refetches. What is deliberately *not* in here is anything
# compiled: `-build` is excluded and `build/` is outside this path entirely, so no object
# file ever survives a compiler upgrade on the runner image. Restoring the downloads is
# where the time goes anyway -- sendspin-cpp pulls its own dependencies (ixwebsocket,
# opus, FLAC) through FetchContent in turn.
#
# Keyed per leg, and by CMakeLists.txt's hash because it holds both pinned tags. A
# subbuild left stale by a cmake upgrade on the image is the residual risk, and dropping
# the cache is its remedy.
- 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 }}
EXPECT_PROBE: ${{ matrix.expect_probe }}
run: |
patterns=("$EXPECT_BACKENDS" "$EXPECT_MDNS")
# The dns_sd probes are pinned to the answer this host owes, because they fail open:
# one that reported Failed for an unrelated reason would silently cost two case
# labels in describe_error(), and nothing else would complain. Empty on the no-mDNS
# leg, where the probes do not run at all -- and skipped rather than matched, since
# an empty ERE matches any non-empty file and would fail open in turn.
if [ -n "$EXPECT_PROBE" ]; then
for symbol in SERVICE_NOT_RUNNING TIMEOUT; do
patterns+=("^-- Performing Test SENDSPIN_CLI_HAVE_ERR_$symbol - $EXPECT_PROBE\$")
done
fi
status=0
for expected in "${patterns[@]}"; 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 -E -- '^-- (sendspin-cli|Performing Test 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.
#
# Serial deliberately: daemon_test.cpp exercises flock paths and last_server_test writes
# real files, neither of which is obviously safe to run against itself in parallel.
- 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
# Waited for rather than assumed: the socket is what the compat layer connects to,
# and starting the player before it exists would leave the whole check riding on the
# player's own retry ramp landing inside the polls below.
for _ in $(seq 1 200); do
if [ -S /run/avahi-daemon/socket ]; then break; fi
sleep 0.1
done
[ -S /run/avahi-daemon/socket ] || {
echo '::error::avahi-daemon was started but its socket never appeared'
systemctl status avahi-daemon --no-pager || true
exit 1
}
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
}
# Guarded like the traps are: everything this half set out to prove is already
# proven, so a player that has since exited on its own must not turn the leg red
# here with nothing said about why.
kill -TERM "$player" 2>/dev/null || true
wait "$player" 2>/dev/null || true
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 and no dial is asserted -- what is
# asserted is that a ws:// URL was built at all, which takes an address, which takes
# a QueryRecord that answered.
#
# TXT `name` as well as the REQUIRED `path`, because -s mdns:<name> filters on the
# former: without it the instance resolves but matches nothing, and the filter goes
# untested.
avahi-publish -s "$instance-server" _sendspin-server._tcp 8927 \
'path=/sendspin' "name=$instance-server" &
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
resolved="found server \"$instance-server\" .* at ws://"
for _ in $(seq 1 300); do
if grep -q "$resolved" discover.log; then break; fi
sleep 0.1
done
echo 'What the discovering client logged:'
cat discover.log
grep -q "$resolved" discover.log || {
echo '::error::the server was published but never resolved to a ws:// URL'
exit 1
}
kill -TERM "$client" "$publisher" 2>/dev/null || true
# 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: |
reported="$(./build/sendspin-cli --version)"
version="$(printf '%s\n' "$reported" | awk 'NR == 1 { print $2 }')"
lib_tag="$(printf '%s\n' "$reported" | 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
# Appended only where it applies, and carried inside the archive rather than left in
# README alone: someone who unpacked this on a Mac and got told the developer cannot
# be verified is holding the tarball, not the repository.
if [ "$RUNNER_OS" = "macOS" ]; then
cat >>"stage/$name/BUILD-INFO.txt" <<'MACOS'
This binary is ad-hoc signed -- the minimum arm64 needs to run at all -- so it
carries no developer identity and Gatekeeper has nothing to check it against.
Unpack with `tar -xzf` from a terminal, which does not propagate the quarantine
flag where Finder's Archive Utility does. If macOS refuses it anyway:
xattr -d com.apple.quarantine ./sendspin-cli
A Developer ID signature and notarization are roadmap item 10, along with the .pkg
that lets the notarization be stapled.
MACOS
fi
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