Skip to content

Build on Linux and macOS in CI, with a smoke test and per-commit binaries - #8

Merged
chrisuthe merged 4 commits into
mainfrom
chrisuthe/task/add-a-linux-macos-ci-matrix-with-a-smoke-test-and
Aug 11, 2026
Merged

chrisuthe merged 4 commits into
mainfrom
chrisuthe/task/add-a-linux-macos-ci-matrix-with-a-smoke-test-and

Conversation

@chrisuthe

@chrisuthe chrisuthe commented Aug 10, 2026

Copy link
Copy Markdown
Member

Roadmap item 12's build matrix and smoke test. Item 10 (packaging) is
deliberately untouched and stays a follow-up.

What lands

Four legs on every push and pull request — ubuntu-24.04, ubuntu-24.04-arm,
macos-14, and a fourth ubuntu-24.04 configured -DSENDSPIN_CLI_WITH_MDNS=OFF.
That fourth earns its place by swapping which translation unit compiles
(src/mdns_null.cpp for src/mdns_dnssd.cpp), so nothing else in the matrix
builds it. ctest runs on all four, that one included: the suite links
sendspin-cli-core, so discovery_test.cpp is compiled against whichever
MdnsService went in.

Each leg asserts, against its own configure output and by anchored whole-line
match, that it found the backends it expects and that the two dns_sd probes
answered the way that host owes
(Failed on Linux, Success on macOS). Both
of those fail open: a missing -dev package does not fail a configure — every
backend here is optional and auto-detected by design — and a probe that reported
Failed for an unrelated reason would silently cost two case labels. Without
the assertions the matrix would go green while shipping a null-sink-only,
mDNS-less binary, which is the exact failure worth having CI for.

A small shellcheck job joins them, because the smoke script's cleanliness was
otherwise asserted in its own header and enforced by nothing.

scripts/smoke_test.sh covers what a gtest process cannot: --version/--help,
a foreground run reaching its ready log and exiting 0 on SIGTERM, -z forking
with -P writing a live pidfile and refusing a second instance on the same lock,
and a default mDNS-on run surviving a daemon it cannot reach. It is deliberately
not a CTest test — nothing in tests/ opens a device, a socket or the mDNS
daemon, and that is what keeps the suite runnable anywhere. It is runnable by hand
against any build, and README says so.

The two prerequisite fixes

Neither is a drive-by; the matrix cannot go green without them, and item 12's own
entry already assigned both to it.

src/mdns_dnssd.cpp did not compile on Linux at all.
libavahi-compat-libdnssd's dns_sd.h declares neither
kDNSServiceErr_ServiceNotRunning nor kDNSServiceErr_Timeout.

The roadmap said #ifdef guards would be enough. That is wrong, and would have
made things worse:
both are enumerators of an anonymous enum rather than
macros, so the test is false on every platform — it would have silently dropped
the two cases on macOS as well. check_cxx_symbol_exists() cannot answer it
either; it probes by taking the symbol's address, which an enumerator has none of.
CMake now compiles a use of each against the header the build will really use, and
each leg asserts which way its probes went — so the mechanism is pinned by CI
rather than by me having read one run's log.

src/portaudio_sink.cpp:495 called .c_str() on the temporary std::string
returned by name(). It was the only -Wdangling-gsl our own sources produced and
the only thing between the tree and -Werror. Nothing else in that file is
touched — the mid-stream device recovery around it is item 14's subject, and item
14's entry is amended down to just that.

-Werror arrives as SENDSPIN_CLI_WERROR, off by default and applied to
sendspin-cli-core, sendspin-cli and sendspin-cli-tests alone, never to the
fetched sendspin or googletest trees (ixwebsocket alone emits ~15 warnings that
would fail every build). Off by default so a fresh diagnostic from a newer compiler
cannot block a contributor who did not cause it; CI passes it ON.

Item 5's runtime debt, discharged

The linux-x86_64 leg starts a real avahi-daemon and does two things. It browses
our own _sendspin._tcp advertisement back to resolved A and AAAA records. Then it
publishes a _sendspin-server._tcp instance and has the player discover it:

I discovery: found server "sendspin-ci-…-server" (name: sendspin-ci-…-server) at ws://10.1.0.55:8927/sendspin

That line is the proof. discovered_server_url() cannot build a ws:// URL with an
empty address list, and only DNSServiceQueryRecord fills it — the call
libavahi-compat-libdnssd provides in place of the DNSServiceGetAddrInfo it lacks
entirely, and which until now had only been read out of avahi's sources.

Artifacts

The three platform legs upload the binary they built, tarred with README.md,
LICENSE and a BUILD-INFO.txt naming the platform, commit, pinned sendspin-cpp
tag and runtime packages. Tarred rather than uploaded loose because the artifact is
served as a zip, and zip does not carry the executable bit.

CMakeLists.txt gains no install() rule and the tree gains no unit file. The
tar is a deliberate placeholder that item 10 replaces with a staged cmake --install
payload; BUILD-INFO.txt says as much in the archive itself, and retention is 14 days
with nothing attached to a release.

The source hunks, and why they are here

A src/portaudio_sink.cpp hunk inside a CI pull request is the sort of thing the
small-and-single-purpose rule fires on, so to be explicit: these two are the two
defects item 12's own entry named as blockers for this work. The matrix cannot go
green without them, and -Werror cannot hold at all while the dangling .c_str()
is there. Neither is a drive-by, and neither file is touched beyond its one defect.

Verification

CI is green on all five jobs — the four legs plus shellcheck. Beyond that:

  • 149 tests pass on Linux, 147 on macOS (ALSA changes what gtest_discover_tests
    finds).
  • The smoke test takes its no-daemon retry branch on both Linux legs and its
    advertise branch on macOS — the script detects the host rather than being told
    by the matrix, so a leg cannot quietly start testing the other branch.
  • Locally on macOS: a default cmake -B build && cmake --build build produces no
    warnings from our own sources, and forcing both dns_sd probes off yields a clean
    -Werror build with the two strings verifiably absent from the archive — the Linux
    shape, proven without Linux.
  • The linux-arm64 artifact unpacks to a genuine ELF 64-bit … ARM aarch64 binary with
    its executable bit intact. I have no arm64 Linux host to run it on, so per the
    brief the arm64 leg's own smoke test — which ran that very binary on the arm64
    runner — stands as the evidence.

Two places I went against the sage's advice

Both were settled in the task brief beforehand; flagging them so they get a second
look rather than passing silently.

  1. The dns_sd probe. The sage preferred declaring -65563/-65568 as local
    constants over a configure-time probe: fewer moving parts, and one message set on
    both platforms. I kept the probe — it is a named acceptance criterion, and
    hardcoding Apple's numbers would add two case labels that avahi-compat can never
    return, since it maps its errors onto the subset it does declare.
  2. The tarball. The sage read it as item-10 packaging leaking in early, and worth
    dropping or reducing to a bare binary. I kept it per the brief, and took the
    compatible mitigations (short retention, no release, and an archive that states
    what it is not).

Two suggestions I left for you rather than taking:

  • .github/dependabot.yml. SHA-pinned actions are correct today and stale in
    six months; Dependabot is the counterpart to the pin. Three lines, but it is
    dependency-update policy rather than part of "add a CI matrix", so it should be
    your call.
  • A fifth leg with mDNS on but no audio -dev packages, exercising the
    null/stdout fallback README advertises at build level. Genuinely uncovered, and
    it is a configure-assertion difference rather than a new kind of job — but the
    brief specifies four legs.

macOS Gatekeeper

Added after the first review pass, because a downloaded macOS build gets refused.
Nothing is wrong with it: codesign reports adhoc, linker-signed — the minimum
an arm64 Mach-O needs to execute at all, applied by the linker — so it carries no
developer identity and spctl -a -t exec rejects it.

What decides whether anyone notices is the quarantine flag, and the current
packaging is quietly lucky there: tar -xzf does not propagate
com.apple.quarantine (only com.apple.provenance), where Finder's Archive
Utility does. So README says to unpack from a terminal, offers
xattr -d com.apple.quarantine for when that is too late, and explains which of
the two is happening rather than handing over an incantation. The same note is
appended to BUILD-INFO.txt on the macOS leg only — someone who has just been
told the developer cannot be verified is holding the tarball, not the repo.

No signing, notarization or Apple credentials are added here. Item 10's entry
now records what the real fix needs and why it belongs there: xcrun stapler
takes .app, .dmg and .pkg and refuses a bare Mach-O, so notarizing the loose
binary would still leave Gatekeeper asking Apple on first run — no good for an
offline Pi or Mac. The .pkg item 10 owes is what makes the ticket stapleable,
and so what makes a Developer ID enrolment worth doing once rather than twice. It
also notes that a public repo hands no secrets to fork PRs, so any signing step
has to be conditional.

Verified against the artifact this branch actually published: the note ships, a
tar extraction leaves no quarantine flag, and the xattr -d fallback clears a
simulated Finder download.

Roadmap entries amended

Item 12 records what shipped and what is still owed (the
NullAudioSink/AlsaAudioSink/PortAudioSink contract suite, armv7/32-bit Pi, macOS
x86_64). Item 14 loses the dangling-.c_str() bug and is reduced to the mid-stream
device recovery it is actually about. Items 4, 5 and 6 are corrected too, because this
diff makes claims in them false — that the Linux dns_sd path had only been read, that
main does not compile on Linux, and that the Linux dual-backend configuration was
unbuilt. Item 4's is narrowed rather than dropped: the matrix builds that configuration
and asserts the backend list, but nothing has run -l or heard ALSA on Linux.

chrisuthe and others added 4 commits August 10, 2026 18:16
…ries

Closes roadmap item 12's matrix and smoke test. The sink contract suite it
also names stays owed, and item 10's packaging is deliberately untouched.

Every push and pull request now builds four legs: ubuntu-24.04,
ubuntu-24.04-arm, macos-14, and a fourth ubuntu-24.04 configured
-DSENDSPIN_CLI_WITH_MDNS=OFF. That last one earns its place by swapping
which translation unit compiles -- src/mdns_null.cpp instead of
src/mdns_dnssd.cpp -- so nothing else in the matrix builds it. ctest runs
on all four, that leg included: the suite links sendspin-cli-core, so
discovery_test.cpp is compiled against whichever MdnsService went in.

Each leg asserts, against its own configure output and by whole-line
match, that it found the backends it expects. Every backend here is
optional and auto-detected by design, so a missing -dev package does not
fail a configure -- without that assertion the matrix would go green
while shipping a null-sink-only, mDNS-less binary, which is exactly the
failure it is worth having CI for.

Two breaks had to go first, both of which the roadmap already recorded.

src/mdns_dnssd.cpp did not compile on Linux at all:
libavahi-compat-libdnssd's dns_sd.h declares neither
kDNSServiceErr_ServiceNotRunning nor kDNSServiceErr_Timeout. The roadmap
proposed #ifdef guards; that is wrong and would have made things worse.
Both are enumerators of an anonymous enum rather than macros, so the test
is false on every platform and would have silently dropped the two cases
on macOS too. check_cxx_symbol_exists() cannot answer it either -- it
probes by taking the symbol's address, which an enumerator has none of.
CMake now compiles a use of each against the header the build will
actually use and defines SENDSPIN_CLI_HAVE_ERR_* only where it is really
there. Verified both ways on macOS: the probes pass and both strings
compile in, and forcing them off produces a clean build with the strings
gone.

src/portaudio_sink.cpp:495 called .c_str() on the temporary std::string
returned by name(), so device_name could dangle before the logging below
read it. It was the only -Wdangling-gsl our own sources produced, and the
only thing between the tree and -Werror. Nothing else in that file is
touched: the mid-stream device recovery around it is item 14's subject,
and item 14's entry is amended to say only that.

-Werror arrives as SENDSPIN_CLI_WERROR, off by default and applied to
sendspin-cli-core, sendspin-cli and sendspin-cli-tests alone -- never to
the fetched sendspin or googletest trees, which produce enough warnings
of their own to fail every build. Off by default so a fresh diagnostic
from a newer compiler cannot block a contributor who did not cause it;
CI passes it ON, which is where the line is actually held.

scripts/smoke_test.sh covers what a gtest process cannot: --version and
--help, a foreground run reaching its ready log and exiting 0 on SIGTERM,
-z forking with -P writing a live pidfile and refusing a second instance
holding the same lock, and a default mDNS-on run surviving a daemon it
cannot reach. It is deliberately not a CTest test -- nothing in tests/
opens a device, a socket or the mDNS daemon, and that is what keeps the
suite runnable anywhere. It polls with deadlines rather than sleeping,
and avoids timeout(1), which macOS does not have.

The linux-x86_64 leg then makes the two runtime claims this owed item 5,
which was built against Bonjour only. It starts a real avahi-daemon,
browses our own _sendspin._tcp advertisement back to a resolved address,
then publishes a _sendspin-server._tcp instance and has the player
discover it. The second half is the one that matters: a ws:// URL cannot
be built without an address, so it cannot pass unless
libavahi-compat-libdnssd genuinely implements DNSServiceQueryRecord for A
and AAAA -- the call it provides in place of the DNSServiceGetAddrInfo it
lacks entirely, which until now had only been read.

The three platform legs upload the binary they built, tarred with README,
LICENSE and a BUILD-INFO.txt naming the runtime packages it needs. It is
a build output rather than an installation, and says so: install() rules,
a systemd unit and distribution packages are item 10, whose task replaces
this tar with a staged cmake --install payload. No install() rule is
added here and no unit file is in the tree.

Third-party actions are pinned to commit SHAs, and only the fetched
sources are cached -- never anything compiled from them, which would keep
serving object files across a compiler upgrade on the runner image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first CI run showed the discovery half working and the check failing
anyway. `-s mdns:<name>` filters on the TXT `name` record rather than the
instance label, and avahi-publish was given only the REQUIRED `path`, so
select_server() matched nothing and no dial was ever logged -- while the
line that matters was there all along:

  I discovery: found server "..." (name: <unnamed>) at ws://10.1.0.147:8927/sendspin

That line is the better assertion as well as the passing one. It is
emitted the moment discovered_server_url() succeeds, which cannot happen
with an empty address list, which only DNSServiceQueryRecord fills -- so
it pins the compat-layer claim directly, where the dial only pinned it via
RetryPacer's schedule. The published service now carries a `name` record
too, so the -s filter is exercised rather than silently matching nothing.

The tree also stops claiming three things this makes untrue: that the
Linux dns_sd path has only been read (item 5 and the mdns_dnssd.cpp
header), that `main` does not compile on Linux (item 6), and that the
Linux dual-backend configuration is unbuilt (item 4). Item 4's is narrowed
rather than dropped -- the matrix builds that configuration and asserts
the backend list, but nothing has run `-l` or heard ALSA on Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four of these would have gone red having already proved what they set out
to prove, and one would have gone green having proved nothing.

The green one is the important one. The two dns_sd probes fail *open*: a
check_cxx_source_compiles() that reported Failed for a reason unrelated to
the header -- a broken probe environment, a stale cache -- silently costs
two case labels in describe_error(), and the build carries on. That was
being verified by reading a run's log by eye. Each leg now pins the answer
its host owes, Failed on Linux and Success on macOS, through the same
assertion loop as the backend lines. The no-mDNS leg asserts neither,
because the probes do not run under -DSENDSPIN_CLI_WITH_MDNS=OFF, and the
empty pattern is skipped rather than matched -- grep -Eq "" matches any
non-empty file, so treating it as a pattern would fail open in turn.

The red ones are in the Avahi step, which runs with -e. `wait "$player"`
propagated the player's exit status, and the closing `kill -TERM "$client"
"$publisher"` returned 1 if either had already gone -- both after every
assertion had passed. They are guarded now, like the traps beside them
always were. The player also no longer starts the instant systemctl
returns: the step waits for /run/avahi-daemon/socket, rather than resting
on MdnsService's retry ramp landing inside the poll that follows.

Jobs get timeout-minutes. Every wait in scripts/smoke_test.sh was already
bounded, but `wait "$player"` was not, so a player ignoring SIGTERM would
have held a runner for the six-hour default instead of failing.

The cache comment claimed only fetched sources were cached. `-subbuild` is
a generated CMake tree, not a download; it has to be there because the
populate stamps are what stop every run refetching. The comment now says
that, and says what the actual guarantee is -- no object file is cached,
since `-build` is excluded and `build/` is outside the path entirely.

A shellcheck job joins the matrix. The script's cleanliness was asserted in
its own header and enforced by nothing, which is how an asserted property
stops being true.

Also: the action-pinning comment sat on the checkout step claiming those
actions "run with a checkout already in hand", which is not true of the
step that creates it; the matrix now says out loud why publish and avahi
are spelled out on every leg including the false ones; ctest says why it
runs serially; --version is read once instead of twice; and three fail()
calls in the smoke script passed two arguments where $* then joined them
with a doubled space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS artifacts trip Gatekeeper. Not because anything is wrong with
them: codesign reports `adhoc, linker-signed`, which is the minimum an
arm64 Mach-O needs to execute at all and is applied by the linker, so the
binary carries no developer identity and `spctl -a -t exec` rejects it.

What decides whether anyone notices is the quarantine flag, and there the
current packaging is quietly lucky -- `tar -xzf` does not propagate
com.apple.quarantine (only com.apple.provenance), where Finder's Archive
Utility does. So README now says to unpack from a terminal, gives
`xattr -d com.apple.quarantine` for when that is too late, and says which
of the two is happening rather than offering an incantation.

The same note is appended to BUILD-INFO.txt on the macOS leg only.
Somebody who has just been told the developer cannot be verified is
holding the tarball, not the repository, so the answer travels with it.

No signing, notarization or Apple credentials here. Item 10's entry
records what the real fix needs and why it is item 10's rather than this
matrix's: `xcrun stapler` takes .app, .dmg and .pkg and refuses a bare
Mach-O, so notarizing the loose binary would still leave Gatekeeper
asking Apple on first run -- useless to an offline Pi or Mac. The .pkg
that item owes is what makes the ticket stapleable, and so what makes the
Developer ID enrolment worth doing once rather than twice. It also notes
that a public repo hands no secrets to fork pull requests, so any signing
step has to be conditional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chrisuthe
chrisuthe marked this pull request as ready for review August 11, 2026 03:26
@chrisuthe
chrisuthe merged commit e7c9dba into main Aug 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant