Build on Linux and macOS in CI, with a smoke test and per-commit binaries - #8
Merged
chrisuthe merged 4 commits intoAug 11, 2026
Conversation
…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
marked this pull request as ready for review
August 11, 2026 03:26
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 fourthubuntu-24.04configured-DSENDSPIN_CLI_WITH_MDNS=OFF.That fourth earns its place by swapping which translation unit compiles
(
src/mdns_null.cppforsrc/mdns_dnssd.cpp), so nothing else in the matrixbuilds it.
ctestruns on all four, that one included: the suite linkssendspin-cli-core, sodiscovery_test.cppis compiled against whicheverMdnsServicewent 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_sdprobesanswered the way that host owes (
Failedon Linux,Successon macOS). Bothof those fail open: a missing
-devpackage does not fail a configure — everybackend here is optional and auto-detected by design — and a probe that reported
Failedfor an unrelated reason would silently cost twocaselabels. Withoutthe 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
shellcheckjob joins them, because the smoke script's cleanliness wasotherwise asserted in its own header and enforced by nothing.
scripts/smoke_test.shcovers what a gtest process cannot:--version/--help,a foreground run reaching its ready log and exiting 0 on
SIGTERM,-zforkingwith
-Pwriting 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 mDNSdaemon, 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.cppdid not compile on Linux at all.libavahi-compat-libdnssd'sdns_sd.hdeclares neitherkDNSServiceErr_ServiceNotRunningnorkDNSServiceErr_Timeout.The roadmap said
#ifdefguards would be enough. That is wrong, and would havemade things worse: both are enumerators of an anonymous
enumrather thanmacros, 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 iteither; 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:495called.c_str()on the temporarystd::stringreturned by
name(). It was the only-Wdangling-gslour own sources produced andthe only thing between the tree and
-Werror. Nothing else in that file istouched — the mid-stream device recovery around it is item 14's subject, and item
14's entry is amended down to just that.
-Werrorarrives asSENDSPIN_CLI_WERROR, off by default and applied tosendspin-cli-core,sendspin-cliandsendspin-cli-testsalone, never to thefetched
sendspinorgoogletesttrees (ixwebsocket alone emits ~15 warnings thatwould 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-daemonand does two things. It browsesour own
_sendspin._tcpadvertisement back to resolved A and AAAA records. Then itpublishes a
_sendspin-server._tcpinstance and has the player discover it:That line is the proof.
discovered_server_url()cannot build aws://URL with anempty address list, and only
DNSServiceQueryRecordfills it — the calllibavahi-compat-libdnssdprovides in place of theDNSServiceGetAddrInfoit lacksentirely, 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,LICENSEand aBUILD-INFO.txtnaming the platform, commit, pinnedsendspin-cpptag 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.txtgains noinstall()rule and the tree gains no unit file. Thetar is a deliberate placeholder that item 10 replaces with a staged
cmake --installpayload;
BUILD-INFO.txtsays as much in the archive itself, and retention is 14 dayswith nothing attached to a release.
The source hunks, and why they are here
A
src/portaudio_sink.cpphunk inside a CI pull request is the sort of thing thesmall-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
-Werrorcannot 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:gtest_discover_testsfinds).
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.
cmake -B build && cmake --build buildproduces nowarnings from our own sources, and forcing both dns_sd probes off yields a clean
-Werrorbuild with the two strings verifiably absent from the archive — the Linuxshape, proven without Linux.
ELF 64-bit … ARM aarch64binary withits 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.
-65563/-65568as localconstants 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
caselabels that avahi-compat can neverreturn, since it maps its errors onto the subset it does declare.
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 insix 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.
-devpackages, exercising thenull/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:
codesignreportsadhoc, linker-signed— the minimuman arm64 Mach-O needs to execute at all, applied by the linker — so it carries no
developer identity and
spctl -a -t execrejects it.What decides whether anyone notices is the quarantine flag, and the current
packaging is quietly lucky there:
tar -xzfdoes not propagatecom.apple.quarantine(onlycom.apple.provenance), where Finder's ArchiveUtility does. So README says to unpack from a terminal, offers
xattr -d com.apple.quarantinefor when that is too late, and explains which ofthe two is happening rather than handing over an incantation. The same note is
appended to
BUILD-INFO.txton the macOS leg only — someone who has just beentold 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 staplertakes
.app,.dmgand.pkgand refuses a bare Mach-O, so notarizing the loosebinary would still leave Gatekeeper asking Apple on first run — no good for an
offline Pi or Mac. The
.pkgitem 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
tarextraction leaves no quarantine flag, and thexattr -dfallback clears asimulated Finder download.
Roadmap entries amended
Item 12 records what shipped and what is still owed (the
NullAudioSink/AlsaAudioSink/PortAudioSinkcontract suite, armv7/32-bit Pi, macOSx86_64). Item 14 loses the dangling-
.c_str()bug and is reduced to the mid-streamdevice 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
maindoes not compile on Linux, and that the Linux dual-backend configuration wasunbuilt. Item 4's is narrowed rather than dropped: the matrix builds that configuration
and asserts the backend list, but nothing has run
-lor heard ALSA on Linux.