Say how to run a downloaded build on macOS, and why it warns #5
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: | |
| # 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 |