From 3452f41d7f29710b79060771d4c42945ba5d3ecd Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 15:22:58 -0500 Subject: [PATCH 1/6] Add a linux-armv6 release leg built in an emulated Raspbian container A Pi Zero, a Pi Zero W and an original Pi are ARM1176, and until now they had no archive at all: scripts/get_started_linux.sh refused them and the docs told them to build from source. ARMv6 cannot be reached the way linux-armv7 is. Debian and Ubuntu armhf are an ARMv7-A port, so a cross toolchain's own crt1.o, crtbegin.o and every member of libgcc.a are ARMv7 and are linked into the binary whatever -march said -- which is why scripts/build_arm32.sh refuses armv6 by name, and why that refusal stands. Raspbian's really are ARMv6, its gcc being configured --with-arch=armv6 --with-float=hard, and Raspbian publishes no cross toolchain. So this leg is an ordinary native build run inside a digest-pinned Raspbian bookworm container under qemu-user, owned by scripts/build_armv6_container.sh for the reason build_arm32.sh is a script: the archive is the only build of this project a Pi Zero owner will ever run, and it has to be reproducible without a runner. Three things about that container are load-bearing and none of them are about the instruction set. Everything runs under QEMU_CPU=arm1176, because qemu-arm otherwise emulates a Cortex-A15-class core and would be more permissive than the hardware -- at configure time as well as in the suite, a cmake try_run probe asking what the CPU can do being answered for the wrong CPU. The container runs --init and builds as a non-root user, without which the smoke test reads an unreaped zombie as a player that outlived SIGTERM and three StateStore cases fail on a permission bit root ignores. And the link needs -latomic, ARMv6 having no LDREXD; it goes in CMAKE_CXX_STANDARD_LIBRARIES rather than CMAKE_EXE_LINKER_FLAGS, which places it ahead of the objects that reference it and leaves the linker to discard it. The leg is the one in the matrix without -DSENDSPIN_CLI_WERROR=ON, for the reason the pipewire-minimum job already documents: gcc 12.2.0's -Wrestrict false positive, at src/control_common.cpp:391 and :401 and additionally at tests/cli_test.cpp:1133. -Wno-restrict is not the narrower fix it looks like -- CMAKE_CXX_FLAGS lands before the -Wall that target_compile_options adds, which turns the warning back on. The archive's glibc floor improves for free. It needs no more than GLIBC_2.34, where the others need 2.38, so it loads on Raspberry Pi OS bookworm as well as trixie -- which the linux-armv7 archive does not. Its BUILD-INFO.txt says so rather than inheriting the ARMv7 leg's bookworm caveat, which is inverted here. Verified by building: configure reports the same backend and mDNS lines the linux-x86_64 leg expects, the linked binary reads Tag_CPU_arch v6 with VFP registers and hard-float EABI and NEEDED libatomic.so.1, 426/426 ctest cases pass under arm1176, and scripts/smoke_test.sh reports every check passed. --- .github/workflows/build.yml | 247 +++++++++++++--- .github/workflows/release.yml | 4 +- README.md | 28 +- docs/ROADMAP.md | 62 +++- docs/wiki/Getting-Started-on-Linux.md | 7 +- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 30 +- docs/wiki/Home.md | 10 +- docs/wiki/Installation.md | 19 +- docs/wiki/Troubleshooting.md | 12 +- scripts/build_arm32.sh | 12 +- scripts/build_armv6_container.sh | 264 ++++++++++++++++++ scripts/get_started_linux.sh | 36 ++- 12 files changed, 618 insertions(+), 113 deletions(-) create mode 100755 scripts/build_armv6_container.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df7f780..46b59d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,8 +32,10 @@ jobs: 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. + # Well clear of a cold cache on the slowest leg -- which is linux-armv6, the one that + # emulates every compile in the FetchContent tree where the others compile at native speed + # -- 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 @@ -41,20 +43,31 @@ jobs: fail-fast: false # `publish`, `avahi` and `systemd` 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, or stop proving the unit it installs, and still go green. `with_pulse` and - # `with_pipewire` are spelled out for the sibling reason: an omitted key reaches cmake as - # `-DSENDSPIN_CLI_WITH_PULSE=`, which is falsy, so a leg meaning to build the backend - # would silently not. `arm32` names the architecture a leg cross-compiles for and is - # empty on every leg that builds for the runner it is on. A new leg owes all six -- and a - # `publish: true` leg owes a seventh thing in another file: an entry in each of the two - # asset sets release.yml diffs before it publishes, which otherwise fails the release - # with a diff rather than a reason. A publishing *macOS* leg owes two entries in each, - # its tarball and the installer .pkg built further down. + # publishing, or stop proving the unit it installs, and still go green. `with_pulse`, + # `with_pipewire` and `werror` are spelled out for the sibling reason: an omitted key + # reaches cmake as `-DSENDSPIN_CLI_WITH_PULSE=`, which is falsy, so a leg meaning to + # build the backend -- or meaning to hold the warning line -- would silently not. + # + # `arm32` names the 32-bit ARM architecture a leg targets, and `container` says a leg + # builds inside one rather than on the runner: what and how, and both empty on every leg + # that builds for the runner it is on. `container` earns its place by the same test as + # the rest and more sharply than any of them -- omit it on the ARMv6 leg and the build + # happens on the Ubuntu host, against the ARMv7 `libgcc` and `crt1.o` that + # scripts/build_arm32.sh refuses by name, and publishes an archive that traps on an + # ARM1176. + # + # A new leg owes all eight -- and a `publish: true` leg owes a ninth thing in another + # file: an entry in each of the two asset sets release.yml diffs before it publishes, + # which otherwise fails the release with a diff rather than a reason. A publishing + # *macOS* leg owes two entries in each, its tarball and the installer .pkg built further + # down. matrix: include: - name: linux-x86_64 runner: ubuntu-24.04 arm32: '' + container: '' + werror: 'ON' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -69,6 +82,8 @@ jobs: - name: linux-arm64 runner: ubuntu-24.04-arm arm32: '' + container: '' + werror: 'ON' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -88,6 +103,10 @@ jobs: # emulating only what has to be executed puts configure, build and suite inside a # minute of runner time. # + # That trade is available here and nowhere else in this matrix. The ARMv6 leg below + # has no cross toolchain to take the other side of it, which is what its own comment + # is about -- so it pays the emulation this leg declines. + # # `expect_mdns` is anchored on the multiarch library path rather than left as the # `.*libdns_sd\.so.*` the native legs use, and that is the leg's own cross-check: a # find_library that answered with the runner's x86_64 copy would configure cleanly, @@ -101,6 +120,8 @@ jobs: - name: linux-armv7 runner: ubuntu-24.04 arm32: 'armv7' + container: '' + werror: 'ON' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -112,6 +133,54 @@ jobs: avahi: false systemd: false + # The ARMv6 Raspberry Pi leg -- a Pi Zero, a Pi Zero W, an original Pi -- and the one + # leg here that is not built the way its neighbour above is. The cross route is not + # slower for this target, it is unavailable: Debian and Ubuntu armhf are an ARMv7-A + # port, so a cross toolchain's own crt1.o, crtbegin.o and every member of libgcc.a + # are ARMv7 and are linked into the binary whatever `-march` said. Raspbian's really + # are ARMv6 and Raspbian publishes no cross toolchain, so the only way to an ARMv6 + # archive is an ordinary native build run inside an emulated Raspbian container. + # scripts/build_armv6_container.sh owns it, for the reason build_arm32.sh is a script. + # + # balenalib's rather than an official Raspberry Pi image, because there is no official + # one: Raspberry Pi publishes OS images, not container images. What matters is that + # this one installs from archive.raspbian.org -- the ARMv6 port -- rather than from + # Debian's ARMv7-A armhf, and its gcc is configured `--with-arch=armv6 + # --with-float=hard`, which is why nothing on this leg passes `-march` at all. Pinned + # by digest for the reason every action in this file is pinned to a commit, and + # bookworm rather than trixie deliberately: it is what fixes the archive's glibc floor + # at 2.34, low enough to load on both. + # + # `werror` is OFF here alone among the matrix, for the reason the `pipewire-minimum` + # job below states: Raspbian's gcc 12.2.0 has a -Wrestrict false positive on + # `line += " " + std::to_string(...)`, which has nothing to do with this architecture + # and would fail the leg for a question it is not here to ask. It fires at + # src/control_common.cpp:391 and :401 as it does there, and at + # tests/cli_test.cpp:1133 as well -- that third site being 32-bit-specific, the + # warning turning on a memcpy size the compiler folds differently where size_t is + # `unsigned int`. `-Wno-restrict` is not the narrower fix it looks like: it can only + # be passed through CMAKE_CXX_FLAGS, and the `-Wall` CMakeLists.txt adds with + # target_compile_options lands after it and turns the warning back on. + # + # `runtime_packages` names bookworm's spellings, where the ARMv7 leg above names + # trixie's, and adds libatomic1: ARMv6 has no LDREXD, so the 64-bit atomics become + # libatomic calls and the binary carries NEEDED libatomic.so.1. + - name: linux-armv6 + runner: ubuntu-24.04 + arm32: 'armv6' + container: 'balenalib/rpi-raspbian:bookworm@sha256:0f3c33faa9d7b1dac778e8afb5854080dcfbddeac7437265598c6076bb0ffc82' + werror: 'OFF' + with_mdns: 'ON' + with_pulse: 'ON' + with_pipewire: 'ON' + expect_probe: 'Failed' + expect_backends: '^-- sendspin-cli audio backends: null, stdout, alsa, portaudio, pulse, pipewire$' + expect_mdns: '^-- sendspin-cli mDNS: dns_sd \(/usr/lib/arm-linux-gnueabihf/libdns_sd\.so\)$' + runtime_packages: 'libasound2 libportaudio2 libpulse0 libpipewire-0.3-0 libavahi-compat-libdnssd1 libatomic1' + publish: true + avahi: false + systemd: false + # Both sound-server backends are pinned OFF here rather than left to auto-detection. # libpulse is brew-installable on macOS and could arrive on the image as some other # formula's dependency, which would change this line without a commit -- and there is no @@ -119,6 +188,8 @@ jobs: - name: macos-arm64 runner: macos-14 arm32: '' + container: '' + werror: 'ON' with_mdns: 'ON' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -143,6 +214,8 @@ jobs: - name: linux-x86_64-nomdns runner: ubuntu-24.04 arm32: '' + container: '' + werror: 'ON' with_mdns: 'OFF' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -178,8 +251,13 @@ jobs: # the runner's own -dev packages installed there is no libdns_sd.so or libasound.so of # the wrong architecture for find_library to answer with at all. The anchored expect_mdns # below is the check that it did not; this is what leaves it nothing to catch. - - name: Install the cross toolchain and armhf dependencies (32-bit ARM) - if: matrix.arm32 + # + # `!matrix.container` as well as `matrix.arm32`: the ARMv6 leg targets 32-bit ARM too and + # wants none of this. Its toolchain and its :armhf libraries are the container's own, and + # installing this runner's cross toolchain beside them would put an ARMv7 libgcc within + # reach of a build whose whole point is not having one. + - name: Install the cross toolchain and armhf dependencies (cross-compiled 32-bit ARM) + if: matrix.arm32 && !matrix.container run: | sudo dpkg --add-architecture armhf @@ -279,6 +357,23 @@ jobs: echo 'A 32-bit ARM binary runs on this host.' rm -f binfmt-probe binfmt-probe.c + # What puts qemu-arm into the kernel's binfmt_misc, so that the ARM processes in the + # container below -- gcc and cmake among them, this being a native build rather than a + # cross one -- can run at all. The cross leg above registers its own handler by hand from + # qemu-user-static, which is the same mechanism arrived at from the other direction. + - name: Register the ARM emulator (containerised 32-bit ARM) + if: matrix.container + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + # Everything from Configure down runs in here. The container is started once and kept, + # rather than a fresh `docker run` per step: cmake records the directory it was configured + # in, and the build tree, the suite and the staged payload all have to be the same one. + - name: Start the Raspbian build container (containerised 32-bit ARM) + if: matrix.container + env: + CONTAINER: ${{ matrix.container }} + run: scripts/build_armv6_container.sh start "$CONTAINER" + - name: Install build dependencies (macOS) if: runner.os == 'macOS' run: brew install portaudio pkgconf @@ -307,23 +402,34 @@ jobs: WITH_MDNS: ${{ matrix.with_mdns }} WITH_PULSE: ${{ matrix.with_pulse }} WITH_PIPEWIRE: ${{ matrix.with_pipewire }} + WERROR: ${{ matrix.werror }} DEPS_DIR: ${{ github.workspace }}/.deps ARM32: ${{ matrix.arm32 }} + CONTAINER: ${{ matrix.container }} run: | - # Held in one array rather than written out on both sides of the branch below. What a + # Held in one array rather than written out on each side of the branch below. What a # cross leg changes is the compiler and the flags naming the target, which - # scripts/build_arm32.sh owns; not one of these options, and a second copy of them is - # a second place for the two paths to stop agreeing about how this project builds. + # scripts/build_arm32.sh owns, and what a containerised leg changes is where cmake + # runs and the one library that target's atomics need, which + # scripts/build_armv6_container.sh owns; not one of these options, and a second copy + # of them is a second place for the paths to stop agreeing about how this project + # builds. options=( -DCMAKE_BUILD_TYPE=Release - -DSENDSPIN_CLI_WERROR=ON + -DSENDSPIN_CLI_WERROR="$WERROR" -DSENDSPIN_CLI_WITH_MDNS="$WITH_MDNS" -DSENDSPIN_CLI_WITH_PULSE="$WITH_PULSE" -DSENDSPIN_CLI_WITH_PIPEWIRE="$WITH_PIPEWIRE" -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" ) - if [ -n "$ARM32" ]; then + # `$CONTAINER` is asked about first, and the order is load-bearing rather than + # stylistic: the ARMv6 leg carries both keys, and scripts/build_arm32.sh refuses + # `armv6` by design -- so an `$ARM32` test reached first would fail the leg with a + # message about a toolchain it never meant to use. + if [ -n "$CONTAINER" ]; then + scripts/build_armv6_container.sh configure build "${options[@]}" 2>&1 | tee configure.log + elif [ -n "$ARM32" ]; then scripts/build_arm32.sh "$ARM32" build "${options[@]}" 2>&1 | tee configure.log else cmake -B build "${options[@]}" 2>&1 | tee configure.log @@ -368,9 +474,16 @@ jobs: exit "$status" - name: Build - run: cmake --build build + env: + CONTAINER: ${{ matrix.container }} + run: | + if [ -n "$CONTAINER" ]; then + scripts/build_armv6_container.sh run cmake --build build + else + cmake --build build + fi - # What makes this leg's name mean something. Every dependency arrives through + # What makes these legs' names mean something. Every dependency arrives through # FetchContent and so through add_subdirectory, in the one cmake invocation, which is why # scripts/build_arm32.sh puts the target flags in CMAKE_C_FLAGS/CMAKE_CXX_FLAGS rather # than on our own targets -- and this is the check that says it worked. A flag that @@ -379,6 +492,11 @@ jobs: # archive is built for. The linker merges build attributes across every object in the # link and reports the highest, so one read covers the whole tree. # + # The ARMv6 leg passes no such flag -- its compiler targets ARMv6 by configuration -- and + # that is why it needs this read just as much: what it is claiming is a property of a + # container's whole toolchain, and a dependency that found some other compiler would + # answer here rather than on a Pi Zero. + # # It covers what is *in* the file and nothing further. The shared libraries the loader # pulls in on a Pi are that machine's own, built by whoever built its distribution, and # are not this build's to make claims about. @@ -395,6 +513,7 @@ jobs: case "$ARM32" in armv7) want_arch='v7' ;; + armv6) want_arch='v6' ;; *) echo "::error::no expected CPU architecture is recorded here for '$ARM32'" exit 1 @@ -453,14 +572,28 @@ jobs: # 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 + env: + CONTAINER: ${{ matrix.container }} + run: | + if [ -n "$CONTAINER" ]; then + scripts/build_armv6_container.sh run ctest --test-dir build --output-on-failure + else + ctest --test-dir build --output-on-failure + fi # 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 + env: + CONTAINER: ${{ matrix.container }} + run: | + if [ -n "$CONTAINER" ]; then + scripts/build_armv6_container.sh run scripts/smoke_test.sh build/sendspin-cli + else + scripts/smoke_test.sh build/sendspin-cli + fi # 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 @@ -578,14 +711,29 @@ jobs: RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} LEG: ${{ matrix.name }} ARM32: ${{ matrix.arm32 }} + CONTAINER: ${{ matrix.container }} run: | - reported="$(./build/sendspin-cli --version)" + # Both the version read and the install below go through the container on the leg that + # has one. The binary is the obvious half; the install is the less obvious one, and it + # is not about executing anything -- cmake_install.cmake was written by the container's + # cmake 3.25 and is read back by whichever cmake runs it, and the files it lays down + # have to arrive owned the way everything else in this workspace is. + if [ -n "$CONTAINER" ]; then + reported="$(scripts/build_armv6_container.sh run ./build/sendspin-cli --version)" + else + reported="$(./build/sendspin-cli --version)" + fi 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" - DESTDIR="$PWD/stage/$name" cmake --install build --component sendspin-cli + if [ -n "$CONTAINER" ]; then + scripts/build_armv6_container.sh run \ + env DESTDIR="$PWD/stage/$name" cmake --install build --component sendspin-cli + else + DESTDIR="$PWD/stage/$name" cmake --install build --component sendspin-cli + fi cat >"stage/$name/BUILD-INFO.txt" <>"stage/$name/BUILD-INFO.txt" <<'EMULATED' + + This was built for an ARM1176 -- a Pi Zero, a Pi Zero W or an original Pi -- inside a + Raspbian container on the runner named above, and not on a Pi. It is a native build + rather than a cross build: Raspbian's own gcc, libgcc and startup objects are ARMv6, + which is what makes this archive ARMv6, and the build attributes of the binary are + asserted to say ARMv6, hard-float EABI before the archive is made. Its unit suite and + smoke test ran there under qemu-user pinned to an ARM1176, so the emulator was no more + permissive than your hardware -- but what is proven is that the code builds and + behaves, not that it was tried on a Pi. + + The runtime packages above are spelled the way Raspberry Pi OS bookworm spells them; + on trixie they are libasound2t64 and libpipewire-0.3-0t64. This binary needs no more + than glibc 2.34, which is below what either release carries, so it loads on both -- + which the linux-armv7 archive does not. + EMULATED + elif [ -n "$ARM32" ]; then cat >>"stage/$name/BUILD-INFO.txt" <&1 | tee configure.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b9fe54..c5bb4fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,7 @@ jobs: # `…-macos-arm64-installer` and merge-multiple lays its one file out flat here. expected=$(mktemp) { - for leg in linux-x86_64 linux-arm64 linux-armv7 macos-arm64; do + for leg in linux-x86_64 linux-arm64 linux-armv7 linux-armv6 macos-arm64; do echo "sendspin-cli-$VERSION-$leg.tar.gz" done echo "sendspin-cli-$VERSION-macos-arm64.pkg" @@ -387,7 +387,7 @@ jobs: expected=$(mktemp) { echo SHA256SUMS - for leg in linux-x86_64 linux-arm64 linux-armv7 macos-arm64; do + for leg in linux-x86_64 linux-arm64 linux-armv7 linux-armv6 macos-arm64; do echo "sendspin-cli-$VERSION-$leg.tar.gz" done echo "sendspin-cli-$VERSION-macos-arm64.pkg" diff --git a/README.md b/README.md index 1d9e72e..4682d0b 100644 --- a/README.md +++ b/README.md @@ -1326,22 +1326,34 @@ path. CI runs it on every platform leg; run it yourself against any build. ## CI Every branch push and pull request builds on `ubuntu-24.04`, `ubuntu-24.04-arm` -and `macos-14`, a fourth leg cross-compiled for 32-bit ARM on `ubuntu-24.04`, and a -fifth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles +and `macos-14`, a fourth leg cross-compiled for ARMv7 on `ubuntu-24.04`, a fifth +built for ARMv6 inside an emulated Raspbian container on the same runner, and a +sixth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles `src/mdns_null.cpp` in place of `src/mdns_dnssd.cpp`, so that configuration is -built rather than assumed. Every leg builds with `-DSENDSPIN_CLI_WERROR=ON` and -runs the unit suite, and each asserts from its own configure output that it found -the backends it expects: a missing `-dev` package does not fail a configure, so -without that check the matrix would happily go green on a deaf, undiscoverable -binary. +built rather than assumed. Every leg but the ARMv6 one builds with +`-DSENDSPIN_CLI_WERROR=ON`, every leg runs the unit suite, and each asserts from +its own configure output that it found the backends it expects: a missing `-dev` +package does not fail a configure, so without that check the matrix would happily +go green on a deaf, undiscoverable binary. -The 32-bit ARM leg is cross-compiled because nothing else can build it: GitHub has +The ARMv7 leg is cross-compiled because nothing else can build it: GitHub has no armv7 runner, and its arm64 runners cannot execute 32-bit ARM at all. `scripts/build_arm32.sh` owns the cross configure, the suite and the smoke test run under `qemu-user`, and the linked binary's own ELF build attributes are asserted to say ARMv7, hard-float EABI before an archive is made — which is what catches a dependency quietly compiled for something else. +The ARMv6 leg cannot be that, and the difference is the toolchain rather than the +runner: Debian and Ubuntu `armhf` are an ARMv7-A port, so a cross toolchain's own +`crt1.o` and `libgcc.a` are ARMv7 and end up in the binary whatever `-march` said. +Raspbian's are genuinely ARMv6, so that leg runs an ordinary native build inside a +digest-pinned Raspbian container under `qemu-user` — with its suite and smoke test +under `QEMU_CPU=arm1176`, so the emulator is no more permissive than an ARM1176 — +and asserts ARMv6, hard-float EABI off the finished binary the same way. It is the +one leg without `-Werror`: Raspbian's gcc 12 has the same `-Wrestrict` false +positive on `src/control_common.cpp` that the `pipewire-minimum` job already +documents. + The matrix lives in `.github/workflows/build.yml`, which both `ci.yml` and `release.yml` call, so a release is built and gated exactly the way a push is. `ci.yml` ignores tags for that reason — otherwise a tag would build twice. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a4cdaa8..4f478d3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1494,19 +1494,21 @@ unproven is what the paragraph above already says only a real tag can answer. Optional, later. Upstream's `examples/tui_client` shows the shape. -### 12. CI and tests — *shipped (matrix and smoke test; ARMv6 and the sink contract still owed)* +### 12. CI and tests — *shipped (matrix and smoke test; the sink contract still owed)* `.github/workflows/ci.yml` builds and tests every branch push and pull request on -`ubuntu-24.04`, `ubuntu-24.04-arm` and `macos-14`, a fourth cross-compiled for 32-bit ARM on -`ubuntu-24.04`, and a fifth configured +`ubuntu-24.04`, `ubuntu-24.04-arm` and `macos-14`, a fourth cross-compiled for ARMv7 on +`ubuntu-24.04`, a fifth built natively for ARMv6 inside an emulated Raspbian container on that +same runner, and a sixth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles `src/mdns_null.cpp` instead of -`src/mdns_dnssd.cpp`, so that translation unit is built rather than assumed. Every leg -configures `-DSENDSPIN_CLI_WERROR=ON` and runs the CTest suite, the no-mDNS leg included: +`src/mdns_dnssd.cpp`, so that translation unit is built rather than assumed. Every leg but the +ARMv6 one configures `-DSENDSPIN_CLI_WERROR=ON`, for the reason recorded below, and every leg +runs the CTest suite, the no-mDNS leg included: `discovery_test.cpp` links whichever `MdnsService` went in, and that configuration has no other coverage. Each leg also asserts against its own configure output that it found the backends it expects — a missing `-dev` package does not fail a configure, since every backend is optional and auto-detected, so without that assertion the matrix would go green -on a null-sink-only, mDNS-less binary. The four platform legs additionally run the smoke +on a null-sink-only, mDNS-less binary. The five platform legs additionally run the smoke test and upload the binary they built, kept 14 days. The matrix itself lives in `.github/workflows/build.yml`, called by `ci.yml` and by item 10's @@ -1569,14 +1571,48 @@ binary: the linker merges build attributes across every object in the link and r highest, so one read covers the FetchContent tree as well as our own sources, and a dependency compiled for the wrong architecture fails the leg instead of shipping. -**ARMv6 is still owed, and it is not the same job.** A Pi Zero, a Pi Zero W and an original Pi -are ARM1176 cores, and Debian and Ubuntu armhf are an ARMv7-A port — which is where a cross -toolchain's own `crt1.o`, `crtbegin.o` and `libgcc.a` come from. Compiling our sources +**The ARMv6 leg is there as well, and it is not the same job.** A Pi Zero, a Pi Zero W and an +original Pi are ARM1176 cores, and Debian and Ubuntu armhf are an ARMv7-A port — which is where +a cross toolchain's own `crt1.o`, `crtbegin.o` and `libgcc.a` come from. Compiling our sources `-march=armv6` therefore does not produce an ARMv6 archive: the startup and helper objects -linked in beside them are ARMv7, and the merged `Tag_CPU_arch` says so. It needs a Raspberry Pi -OS armhf sysroot, whose libgcc and startup objects really are ARMv6, and that sysroot is the -work. `scripts/build_arm32.sh` refuses `armv6` outright rather than building something that -would trap, and `scripts/get_started_linux.sh` refuses an `armv6l` host for the same reason. +linked in beside them are ARMv7, and the merged `Tag_CPU_arch` says so. What an ARMv6 build +needs is an ARMv6 `libgcc` and ARMv6 startup objects, which is a *distribution* rather than a +flag — and Raspbian is one. Its gcc is configured `--with-arch=armv6 --with-float=hard`, and +every one of `libgcc.a`'s members reads `Tag_CPU_arch: v6`, so the leg passes no `-march` at +all. + +So `linux-armv6` is not a cross build at all: it is an ordinary native build run inside a +digest-pinned Raspbian bookworm container under `qemu-user`, which is why it is the one leg +`scripts/build_arm32.sh` has nothing to do with. That script still refuses `armv6`, and its +reasoning is untouched — it cross-compiles against the *host* distribution's armhf tree, which +is still ARMv7. `scripts/get_started_linux.sh` now maps an `armv6l` host onto the new archive +instead of refusing it; ARMv5 and a bare `arm` are still refused, the ARMv6 archive being the +oldest one built. + +Three things about that leg are load-bearing and none of them are about ARMv6 the instruction +set. The unit suite and the smoke test run under `QEMU_CPU=arm1176`, because `qemu-arm` defaults +to a Cortex-A15-class core and would happily execute the ARMv7 instructions the hardware cannot +— an emulator more permissive than the target proves nothing. The container runs `--init` and +builds as a non-root user, because without a reaping PID 1 the smoke test reads an exited +daemon's zombie as a player that outlived `SIGTERM`, and because three `StateStore` cases assert +that an unwritable directory is refused, which root is refused nothing by. And the link needs `-latomic`: ARMv6 +has no `LDREXD`, so the 64-bit atomics in `src/pulse_sink.cpp`, `src/player_listener.cpp` and +sendspin-cpp's own `connection_manager.cpp` become libatomic calls. It goes in +`CMAKE_CXX_STANDARD_LIBRARIES` rather than `CMAKE_EXE_LINKER_FLAGS`, which places it ahead of +the objects that need it and leaves the linker to discard it. + +`-DSENDSPIN_CLI_WERROR=ON` is absent on that leg alone among the matrix, for the same reason the +`pipewire-minimum` job drops it: Raspbian's gcc 12.2.0 has a `-Wrestrict` false positive on +`line += " " + std::to_string(...)` in `src/control_common.cpp`. The narrower fix does not +exist here — `-Wno-restrict` can only be passed through `CMAKE_CXX_FLAGS`, and the `-Wall` that +`target_compile_options` adds lands after it on the command line and turns the warning back on. + +One thing improves for free. The published archives need `GLIBC_2.38`, which Raspberry Pi OS +bookworm's 2.36 refuses at load; the ARMv6 binary tops out at `GLIBC_2.34`, lower than +Raspbian bookworm's own, so it loads on bookworm and trixie alike. The bookworm caveat the +ARMv7 archive's `BUILD-INFO.txt` carries therefore does not apply to this one, and it does not +say it. Raising the floor for `linux-x86_64`, `linux-arm64` and `linux-armv7` is a separate +piece of work and still owed. There is no macOS x86_64 leg. Artifacts are per-commit workflow artifacts only; the tagged release that does not expire is item 10's, and shipped. The hand-rolled tar this entry used to diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index 101db2b..f34c941 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -41,9 +41,10 @@ instead of the newest. 1. **Checks the architecture** — the userland's, read from `dpkg --print-architecture` rather than from `uname -m`, which names the kernel and disagrees with the userland on a - 32-bit Raspberry Pi OS. `amd64`, `arm64` and `armhf` have builds. ARMv6 — a Pi Zero, a Pi - Zero W, an original Pi — does not, and is refused with the reason rather than an - "unsupported" shrug. + 32-bit Raspberry Pi OS. `amd64`, `arm64` and `armhf` have builds. `armhf` then splits on + `uname -m`, which is the one question the userland cannot answer: `armv6l` — a Pi Zero, a + Pi Zero W, an original Pi — takes `linux-armv6`, and everything else `linux-armv7`. ARMv5 + and older is refused with the reason rather than an "unsupported" shrug. 2. **Finds the newest release** and downloads that archive plus `SHA256SUMS`. 3. **Verifies the checksum**, and stops without installing anything if it does not match. 4. **Unpacks it into `/`** with the member-selected `tar` form, so `BUILD-INFO.txt` stays in diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index 6bb8bd2..f607d9a 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -7,7 +7,7 @@ whichever archive its architecture names — plus the five things on this page. ## 1. Check which build your OS wants -Two archives serve a Pi, and it is the **userland** that picks between them — not +Three archives serve a Pi, and it is the **userland** that picks first between them — not `uname -m`, which names the kernel: ```console @@ -18,7 +18,7 @@ arm64 | It says | What installs | |---|---| | `arm64` | `linux-arm64`, the 64-bit build. | -| `armhf` | `linux-armv7`, the 32-bit build — unless the board is ARMv6; see below. | +| `armhf` | `linux-armv7`, the 32-bit build — or `linux-armv6` on an ARMv6 board; see below. | **Do not use `uname -m` for this.** `arm_64bit` defaults to on for a Pi 4, a Pi 400 and a CM4, so a **32-bit** Raspberry Pi OS install on one of those boots a 64-bit kernel and @@ -27,12 +27,13 @@ installs the arm64 archive, whose loader is not there, and the binary fails with file or directory" naming a file that plainly exists. `uname -m` is still the right question for one thing, because ARMv6 boards cannot run a -64-bit kernel at all: if it says `armv6l`, that is the CPU speaking, and there is no build. +64-bit kernel at all: on an `armhf` userland, `armv6l` is the CPU speaking, and it picks +`linux-armv6` over `linux-armv7`. -Either archive gets you a working player, and the getting-started script chooses for you. A +Any of them gets you a working player, and the getting-started script chooses for you. A 64-bit OS is still the better answer on hardware that can run one: `linux-arm64` is built and -run on a real arm64 machine, systemd unit and all, where `linux-armv7` is cross-compiled and -its suite run under emulation. In Raspberry Pi Imager the 64-bit builds are under **Raspberry +run on a real arm64 machine, systemd unit and all, where the two 32-bit archives have their +suites run under emulation. In Raspberry Pi Imager the 64-bit builds are under **Raspberry Pi OS (other)**. | Model | 64-bit capable | @@ -40,13 +41,16 @@ Pi OS (other)**. | Pi 5, Pi 4, Pi 400, Pi 3, Pi Zero 2 W, CM3/CM4/CM5 | Yes | | Pi 1, Pi Zero, Pi Zero W, and Pi 2 boards before v1.2 | **No** | -**`armv6l` has no build.** A Pi Zero, a Pi Zero W and an original Pi are ARMv6, and the -32-bit archive is compiled for ARMv7 — its instructions are illegal on those cores, so there -is nothing to install and the getting-started script says so rather than handing you a binary -that traps. -[`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) -item 12 records what an ARMv6 leg would take. Building from source on the Pi itself works in -the meantime. +**`armv6l` takes `linux-armv6`, not `linux-armv7`.** A Pi Zero, a Pi Zero W and an original +Pi are ARM1176 cores, and the ARMv7 archive's instructions are illegal on them — so they get +an archive of their own, and the getting-started script picks it. It is built inside an +emulated Raspbian container rather than cross-compiled, because Debian and Ubuntu `armhf` are +an ARMv7-A port whose startup objects and `libgcc` would end up in the binary and make it +ARMv7 whatever the compiler was told. + +One practical consequence, in your favour: `linux-armv6` needs only `GLIBC_2.34`, where the +other archives need `2.38`. So it loads on Raspberry Pi OS **bookworm** as well as trixie, +which the 32-bit ARMv7 archive does not. ## 2. Install diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 4ec19b0..f81fb16 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -51,17 +51,17 @@ $ sendspin-cli vol 40 | Platform | Architecture | How | |---|---|---| -| Linux | `x86_64`, `arm64`, `armv7` | Release tarball, or `scripts/get_started_linux.sh` | +| Linux | `x86_64`, `arm64`, `armv7`, `armv6` | Release tarball, or `scripts/get_started_linux.sh` | | macOS | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | -| Raspberry Pi | `arm64`, or `armv7` on a 32-bit OS | The Linux tarball, same as any other Linux host | +| Raspberry Pi | `arm64`, or `armv7`/`armv6` on a 32-bit OS | The Linux tarball, same as any other Linux host | The macOS builds are made on the `macos-14` CI runner and declare no minimum OS version; what the installer `.pkg` does check is the architecture, read off the binary with `lipo` at build time, so it turns an Intel Mac away rather than reporting success. -There is no ARMv6 build and no Intel-Mac build. ARMv6 — a Pi Zero, a Pi Zero W, an original -Pi — needs a Raspberry Pi OS sysroot the matrix does not have, and there is no macOS `x86_64` -leg; both are recorded in +`armv6` covers the ARM1176 boards — a Pi Zero, a Pi Zero W, an original Pi — and is built +inside an emulated Raspbian container rather than cross-compiled, because Debian and Ubuntu +armhf are an ARMv7-A port. There is no Intel-Mac build; that one is recorded in [`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), item 12. Anything else builds from source. diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index f194a16..d48fef0 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -5,7 +5,7 @@ Four ways in, depending on what you have. If you are on Linux and want the short | You have | Take | |---|---| -| A Linux box or a Raspberry Pi | The `linux-x86_64`, `linux-arm64` or `linux-armv7` tarball | +| A Linux box or a Raspberry Pi | The `linux-x86_64`, `linux-arm64`, `linux-armv7` or `linux-armv6` tarball | | An Apple-silicon Mac | The `macos-arm64` installer `.pkg`, or the tarball | | Anything else | [Build from source](#build-from-source) | @@ -47,11 +47,9 @@ case "$(dpkg --print-architecture)" in amd64) LEG=linux-x86_64 ;; arm64) LEG=linux-arm64 ;; armhf) - # An ARMv6 board reports armhf too, and the archive is built for ARMv7. - if [ "$(uname -m)" = armv6l ]; then - echo 'ARMv6 (Pi Zero, Pi Zero W, Pi 1) has no build -- build from source'; exit 1 - fi - LEG=linux-armv7 ;; + # An ARMv6 board reports armhf too, and it takes an archive of its own: the ARMv7 one's + # instructions are illegal on an ARM1176. + if [ "$(uname -m)" = armv6l ]; then LEG=linux-armv6; else LEG=linux-armv7; fi ;; *) echo 'no release is built for this architecture'; exit 1 ;; esac BASE=https://github.com/Sendspin/sendspin-cpp-cli/releases/download/v$VERSION @@ -142,14 +140,15 @@ launch agent you write. ## Raspberry Pi The Pi takes the `linux-arm64` archive on a 64-bit OS and the `linux-armv7` one on a 32-bit -OS, like any other Linux host. An ARMv6 board — a Pi Zero, a Pi Zero W, an original Pi — has -neither and builds from source. See +OS, like any other Linux host. An ARMv6 board — a Pi Zero, a Pi Zero W, an original Pi — takes +`linux-armv6`, which is built for an ARM1176 and, unlike the others, loads on bookworm as well +as trixie. See [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi). ## Build from source -For an architecture with no release — ARMv6, an Intel Mac, anything not in the matrix — or -to build against a different version of the library. +For an architecture with no release — an Intel Mac, anything not in the matrix — or to build +against a different version of the library. ```bash sudo apt install pkg-config libasound2-dev portaudio19-dev libavahi-compat-libdnssd-dev # Debian / Ubuntu diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index fba4a10..35df7d6 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -313,14 +313,14 @@ Two answers, and they are different problems. **`version 'GLIBC_2.38' not found`, from the loader.** The archive is built on a newer distribution than the one you are running. Raspberry Pi OS **trixie** carries glibc 2.41 and takes it; **bookworm** carries 2.36 and refuses it before a line of the player runs. Move that -Pi to trixie, or build from source on it. +Pi to trixie, or build from source on it. The one exception is `linux-armv6`, which is built on +Raspbian bookworm and needs no more than `GLIBC_2.34`. **`Illegal instruction`.** The `linux-armv7` archive is compiled for ARMv7, and a Pi Zero, a -Pi Zero W or an original Pi is ARMv6. `uname -m` says `armv6l` on those, and -`scripts/get_started_linux.sh` refuses them rather than installing something that traps — so -seeing this means the archive was fetched by hand. Build from source instead; -[`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) -item 12 records what an ARMv6 build would take. +Pi Zero W or an original Pi is ARMv6. `uname -m` says `armv6l` on those, and both +`scripts/get_started_linux.sh` and the snippet in +[Installation](Installation) pick `linux-armv6` for them — so seeing this means the ARMv7 +archive was fetched by hand. Take `linux-armv6` instead. Neither applies to `E: Unable to locate package libasound2t64`: that is `apt` on bookworm, where the same libraries are spelled `libasound2` and `libpipewire-0.3-0`. The names in diff --git a/scripts/build_arm32.sh b/scripts/build_arm32.sh index 7171f54..dbe88a3 100755 --- a/scripts/build_arm32.sh +++ b/scripts/build_arm32.sh @@ -41,7 +41,7 @@ # Usage: scripts/build_arm32.sh [cmake option ...] # # armv6|armv7 the target architecture. armv7 is what builds; armv6 is refused, and the case -# label below says why rather than leaving it to look unsupported +# label below says why and where it is built instead # build-dir the directory to configure into, as `cmake -B` takes it # cmake option every remaining argument, passed through to cmake verbatim -- which is how the # caller keeps owning the options that have nothing to do with cross-compiling @@ -90,11 +90,15 @@ case "$TARGET" in # them linked into the binary. Our objects would be armv6 and the archive would not be, # and the merged Tag_CPU_arch build.yml reads back says so. # - # An armv6 build needs a Raspberry Pi OS armhf sysroot, which carries an armv6 libgcc - # and armv6 startup objects. docs/ROADMAP.md item 12 records it as owed. + # An armv6 build needs an armv6 libgcc and armv6 startup objects, which is Raspbian + # rather than a flag. The linux-armv6 leg of .github/workflows/build.yml has them: it + # builds natively inside an emulated Raspbian container instead of cross-compiling, so + # this script is not on that path at all and there is nothing here to extend. fail "armv6 cannot be built against a Debian/Ubuntu armhf toolchain: its libgcc and startup objects are armv7-a, so the result would trap on an ARM1176 (a Pi Zero, a Pi Zero W - or an original Pi). That target needs a Raspberry Pi OS sysroot; see docs/ROADMAP.md item 12" + or an original Pi). armv6 is built a different way -- natively inside an emulated Raspbian + container, which has an armv6 libgcc; see the linux-armv6 leg of + .github/workflows/build.yml" ;; *) fail "unknown target '$TARGET' -- this builds armv7" diff --git a/scripts/build_armv6_container.sh b/scripts/build_armv6_container.sh new file mode 100755 index 0000000..16ccf26 --- /dev/null +++ b/scripts/build_armv6_container.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Builds sendspin-cli for ARMv6 inside an emulated Raspbian container, for the linux-armv6 leg +# of .github/workflows/build.yml. A Pi Zero, a Pi Zero W and an original Pi are ARM1176. +# +# Not a cross build, and that is not a preference. Debian and Ubuntu armhf are an ARMv7-A port, +# so a cross toolchain's own crt1.o, crtbegin.o and every member of libgcc.a are ARMv7 and are +# linked into the binary whatever -march said -- which is why scripts/build_arm32.sh refuses +# armv6 by name. Raspbian's really are ARMv6, its gcc being configured --with-arch=armv6 +# --with-float=hard, so this passes no -march at all; and Raspbian publishes no cross toolchain. +# Emulating the whole build is therefore the only route to an ARMv6 archive rather than the +# slower of two, which is the opposite of the trade the linux-armv7 leg makes. +# +# A script rather than lines of YAML for the reason scripts/build_arm32.sh and +# scripts/build_macos_pkg.sh are: the archive this leg publishes is the only build of this +# project a Pi Zero owner will ever run, and a developer has to be able to reproduce it without +# a runner -- which takes the image digest, --init, the uid and QEMU_CPU, none of which YAML can +# hand anybody. It is also what puts them under ci.yml's `shellcheck scripts/*.sh` job. +# +# Four verbs where build_arm32.sh has none, and the difference is what each owns. A cross build +# is a *configuration*: once cmake has the flags, `cmake --build` over the directory is an +# ordinary build and there is nothing left to remember. A container is a *location*, so it +# colours every step after configure too -- the suite, the smoke test, the install that stages +# the archive -- and each of those needs a way in. +# +# What this needs on the host: +# +# docker +# a registered binfmt handler for 32-bit ARM, which .github/workflows/build.yml installs with +# docker/setup-qemu-action -- without it the container starts and nothing in it can run +# +# Usage: scripts/build_armv6_container.sh start +# scripts/build_armv6_container.sh configure [cmake option ...] +# scripts/build_armv6_container.sh run [argument ...] +# scripts/build_armv6_container.sh stop +# +# start pull , start the container over the current directory, and prove it runs +# ARMv6. is pinned by digest by the caller +# configure configure in the container; remaining arguments reach cmake verbatim, +# which is how the caller keeps owning the options that are not about this target +# run run one command in the container, in the same directory, as the same user +# stop remove the container. The runner is discarded either way; this is for a developer +# who ran `start` on a machine that is not + +set -euo pipefail + +fail() { + printf 'build_armv6_container: FAIL: %s\n' "$*" >&2 + exit 1 +} + +# One container per checkout is enough -- the legs of a matrix run on runners of their own, and a +# developer builds one thing at a time -- so the name is a constant rather than an argument to +# thread through every verb. +readonly CONTAINER='sendspin-cli-armv6' + +# Written rather than left to the image's own environment, which happens to set it too. The +# emulator defaults to a Cortex-A15-class core and will execute ARMv7 instructions perfectly +# happily, which would make it *more* permissive than the hardware this archive is for: an +# illegal instruction would surface on a Pi Zero rather than in the suite. Everything this +# script runs gets it, not just the tests, because a cmake try_run probe asking what the CPU can +# do runs at configure time and would otherwise be answered for the wrong CPU. +readonly QEMU_CPU_MODEL='arm1176' + +# A home the build owns, so that anything writing under $HOME does not meet the image's root-owned +# one and fail on EACCES for a reason that has nothing to do with this build. +readonly BUILD_HOME='/home/build' + +command -v docker >/dev/null 2>&1 || + fail "docker is not on \$PATH, and this runs the build inside a container" + +[ "$#" -ge 1 ] || + fail "usage: $0 [argument ...]" + +VERB=$1 +shift +readonly VERB + +running() { + [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" = 'true' ] +} + +# The workspace is bind-mounted at the path it already has rather than at some /work of its own, +# which is what lets every absolute path survive the boundary: FETCHCONTENT_BASE_DIR and DESTDIR +# are handed in by the caller, and CMakeCache.txt records the build directory it was configured +# in. A container mounting it elsewhere would configure at one path and build at another. +in_container() { + running || fail "no running $CONTAINER container -- run '$0 start ' first" + + local env_args=( + --env "HOME=$BUILD_HOME" + --env "QEMU_CPU=$QEMU_CPU_MODEL" + ) + + # Forwarded explicitly because `docker exec` inherits nothing from the caller. This one is + # workflow-level `env:` in build.yml, so losing it silently would leave an already emulated + # build compiling one translation unit at a time. + if [ -n "${CMAKE_BUILD_PARALLEL_LEVEL:-}" ]; then + env_args+=(--env "CMAKE_BUILD_PARALLEL_LEVEL=$CMAKE_BUILD_PARALLEL_LEVEL") + fi + + # As the invoking user rather than as root, and both halves matter. Three StateStore cases + # assert that an unwritable directory is refused, which root is refused nothing by -- they + # fail as root for a reason that is not about the code. And everything written into the + # bind-mounted workspace has to be owned by the user the runner's own steps read it back as, + # the cache save among them. + docker exec \ + --user "$(id -u):$(id -g)" \ + --workdir "$PWD" \ + "${env_args[@]}" \ + "$CONTAINER" \ + "$@" +} + +case "$VERB" in + start) + [ "$#" -eq 1 ] || fail "usage: $0 start " + IMAGE=$1 + readonly IMAGE + + # Pulled as its own command so that an unreachable registry or a digest that no longer + # resolves says so, rather than surfacing as a failure to start a container. + # + # --platform is spelled out even though the digest names a single-architecture manifest, + # because without it docker reports the mismatch against the host as a warning on every + # command and a warning nobody can act on is noise in a log that is read when something + # is wrong. + docker pull --platform linux/arm/v6 "$IMAGE" + + # Removed rather than reused: a container left by an earlier run holds that run's + # packages and that run's user, and a build that quietly inherits them is not the build + # this describes. `|| true` is safe here because the run below fails on a name clash. + docker rm --force "$CONTAINER" >/dev/null 2>&1 || true + + # --init is load-bearing, not hygiene. Without a reaping PID 1 a daemon that has already + # exited stays a zombie, `kill -0` keeps answering for it, and scripts/smoke_test.sh + # reports a player that outlived SIGTERM -- a failure that looks like ARMv6 and is not. + # + # --entrypoint because the image's own is balena's device-provisioning script, which has + # nothing to answer for in a build container. `docker exec` bypasses an entrypoint + # anyway, so this only settles what the container holds open -- but settling it is worth + # a flag when the alternative is a build that depends on what that script does next. + docker run \ + --detach \ + --init \ + --platform linux/arm/v6 \ + --name "$CONTAINER" \ + --volume "$PWD:$PWD" \ + --workdir "$PWD" \ + --entrypoint sleep \ + "$IMAGE" \ + infinity >/dev/null + + # A passwd entry at the invoking user's own id, so that in_container() above has a user + # to be. Reused where the image already carries one at that id rather than replaced: + # what this needs is an entry and a home, not a particular name. + docker exec "$CONTAINER" sh -c ' + set -eu + getent group "$2" >/dev/null || groupadd --gid "$2" build + getent passwd "$1" >/dev/null || + useradd --uid "$1" --gid "$2" --home-dir "$3" --no-create-home build + install -d -o "$1" -g "$2" -m 0755 "$3" + ' sh "$(id -u)" "$(id -g)" "$BUILD_HOME" + + # git and ca-certificates for FetchContent, which clones what the cache did not restore; + # pkg-config because it is what finds PortAudio, PipeWire and PulseAudio, so a change in + # the image should fail here rather than produce a player with no audio backend. + docker exec "$CONTAINER" sh -c ' + set -eu + apt-get update + apt-get install --no-install-recommends -y \ + build-essential \ + cmake \ + git \ + ca-certificates \ + pkg-config \ + libasound2-dev \ + portaudio19-dev \ + libpulse-dev \ + libpipewire-0.3-dev \ + libavahi-compat-libdnssd-dev + ' + + # Proven here, where the message can name the cause, rather than left to surface further + # down wearing cmake's name. Two facts carry this whole leg, and neither is a flag: that + # the container really executes ARM under the emulator, and that its compiler targets + # ARMv6 by configuration -- which is why nothing here passes -march. + machine="$(in_container uname -m)" + echo 'What the container reports:' + printf '%s\n' "$machine" + in_container gcc --version + in_container cmake --version + + [ "$machine" = 'armv6l' ] || + fail "the container reports '$machine', not armv6l -- either the emulator is not + registered for 32-bit ARM or this image is not the Raspbian armhf one" + + # gcc -v writes its configuration to stderr. + gcc_config="$(in_container gcc -v 2>&1)" + case "$gcc_config" in + *--with-arch=armv6*) ;; + *) + printf '%s\n' "$gcc_config" >&2 + fail "this image's gcc is not configured --with-arch=armv6, so it would build + ARMv7 objects with an ARMv7 libgcc beside them -- which is the whole reason this leg is not + a cross build" + ;; + esac + + printf 'build_armv6_container: %s is up, building as %s:%s\n' \ + "$CONTAINER" "$(id -u)" "$(id -g)" + ;; + + configure) + [ "$#" -ge 1 ] || fail "usage: $0 configure [cmake option ...]" + BUILD_DIR=$1 + shift + readonly BUILD_DIR + + # -latomic is this target's one link-line fact, and it belongs here for the reason + # build_arm32.sh owns the armv7 -march: it is a property of the machine rather than of + # this project. ARMv6 has no LDREXD, so every 64-bit atomic becomes a libatomic call -- + # from src/pulse_sink.cpp, src/player_listener.cpp and sendspin-cpp's own + # connection_manager.cpp -- and the link fails on __atomic_load_8 without it. The ARMv7 + # leg needs nothing of the kind because ARMv7 has LDREXD. + # + # CMAKE_CXX_STANDARD_LIBRARIES rather than CMAKE_EXE_LINKER_FLAGS, which places it ahead + # of the objects that reference it and leaves the linker to discard it as unused. + in_container cmake -B "$BUILD_DIR" \ + -DCMAKE_CXX_STANDARD_LIBRARIES=-latomic \ + "$@" + + printf 'build_armv6_container: configured %s for armv6 in %s\n' "$BUILD_DIR" "$CONTAINER" + ;; + + run) + [ "$#" -ge 1 ] || fail "usage: $0 run [argument ...]" + in_container "$@" + ;; + + stop) + [ "$#" -eq 0 ] || fail "usage: $0 stop" + docker rm --force "$CONTAINER" >/dev/null + printf 'build_armv6_container: removed %s\n' "$CONTAINER" + ;; + + *) + fail "unknown verb '$VERB' -- this takes start, configure, run or stop" + ;; +esac diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index b1e47ba..6e46466 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -18,7 +18,8 @@ # # One script for every Linux host, a Raspberry Pi included, because a Pi *is* an ordinary # Linux box here: it takes whichever archive its architecture names -- `linux-arm64` on a -# 64-bit OS, `linux-armv7` on a 32-bit one -- and installs it the way a server takes +# 64-bit OS, `linux-armv7` or `linux-armv6` on a 32-bit one -- and installs it the way a +# server takes # `linux-x86_64`. What is genuinely Pi-specific is advice -- the `audio` group, # and that the headphone jack and HDMI are separate cards -- and that is printed at the end # when a Pi is what this is running on. A second script would have been this one with two @@ -194,20 +195,28 @@ case "$USERLAND" in # 64-bit kernel at all, so `armv6l` here is the CPU speaking rather than a 32-bit kernel # on newer hardware. # - # A Pi Zero, a Pi Zero W or an original Pi. The 32-bit archive is built for ARMv7 and - # would trap here, so this is a refusal rather than a near-enough match -- and it gets - # the whole answer, because "unsupported architecture" on a Pi sends people looking for - # a download that does not exist. docs/ROADMAP.md item 12 records what it would take. + # A Pi Zero, a Pi Zero W or an original Pi is an ARM1176, and the ARMv7 archive's + # instructions would be illegal there, so those boards take an archive of their own + # rather than the nearest one. + # + # Below ARMv6 there is still nothing, and that stays a refusal with the whole answer in + # it: "unsupported architecture" on a Pi sends people looking for a download that does + # not exist. Bare `arm` is refused with them because it names no instruction set at all, + # and an ARMv5 board answering to it would take a binary that traps. case "$MACHINE" in - armv6l | armv5* | arm) - fail "this is an ARMv6 or older machine ($MACHINE) -- a Pi Zero, a Pi Zero W or - an original Pi. The 32-bit archive is built for ARMv7 and its instructions would be illegal - here, so there is nothing to install: docs/ROADMAP.md item 12 records that an ARMv6 leg - needs a Raspberry Pi OS sysroot the CI matrix does not have. Build from source instead: - https://github.com/$REPO#build" + armv6*) + LEG='linux-armv6' + ;; + armv5* | arm) + fail "'$MACHINE' is ARMv5 or names no ARM architecture at all, and the oldest + archive built is linux-armv6 -- an ARM1176, which is a Pi Zero, a Pi Zero W or an original + Pi. Its instructions would be illegal on an ARMv5 machine, so there is nothing here to + install. Build from source instead: https://github.com/$REPO#build" + ;; + *) + LEG='linux-armv7' ;; esac - LEG='linux-armv7' ;; '') fail "this host's userland could not be identified from a $MACHINE kernel alone, and @@ -216,7 +225,8 @@ case "$USERLAND" in ;; *) fail "no release is built for a '$USERLAND' userland -- the archives are linux-x86_64, - linux-arm64 and linux-armv7. Build from source instead: https://github.com/$REPO#build" + linux-arm64, linux-armv7 and linux-armv6. Build from source instead: + https://github.com/$REPO#build" ;; esac readonly MACHINE USERLAND LEG From bc1778850627bac4799e728f911c6086ef73b9d6 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 15:39:38 -0500 Subject: [PATCH 2/6] Say the ARMv6 archive exists everywhere the release speaks, and assert its glibc floor The release notes still told every downloader that a Pi Zero, a Pi Zero W and an original Pi had no build and pointed them at a roadmap item that is now closed, which is the widest-audience place the claim appears. Five file counts across release.yml and README.md were left at the pre-armv6 arithmetic, and Getting-Started-on-Linux listed the architectures without armv6 in the line a Pi Zero owner reads first. The one claim the archive shipped that nothing checked was its glibc floor. BUILD-INFO.txt tells a Pi owner the binary loads on Raspberry Pi OS bookworm, and a container digest moved off bookworm would raise the floor silently and leave that text promising something the loader would refuse. The architecture assertion now reads the highest GLIBC_ symbol version off the binary and refuses anything above the 2.36 bookworm carries, and BUILD-INFO.txt states the measured value rather than a written-down one. build_armv6_container.sh was fail-open on its likeliest failure: `docker run` succeeds whether or not the kernel can execute what it started, so with no binfmt handler the container is gone before anything looks at it. It is proven now, where the message can name the cause. `stop` reports rather than refuses when there is nothing to remove, which is what lets build.yml run it with `if: always()` -- the step that makes the script's fourth verb reachable from the file documenting it. Three claims contradicted their own neighbours: build.yml, README.md and ROADMAP.md each said the ARMv7 leg is cross-compiled because "nothing else can build it" within a paragraph of describing the leg that does. The claim is that no runner builds it natively. The pipewire-minimum comment said -Werror was absent on the ARMv6 leg, where the matrix insists every leg spell the key out. ci.yml and get_started_linux.sh both still counted three scripts run on a build. --- .github/workflows/build.yml | 104 ++++++++++++++++++-------- .github/workflows/ci.yml | 9 ++- .github/workflows/release.yml | 16 ++-- README.md | 12 +-- docs/ROADMAP.md | 22 +++--- docs/wiki/Getting-Started-on-Linux.md | 4 +- scripts/build_armv6_container.sh | 26 ++++++- scripts/get_started_linux.sh | 14 ++-- 8 files changed, 140 insertions(+), 67 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46b59d4..2fa0b52 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,9 +56,10 @@ jobs: # scripts/build_arm32.sh refuses by name, and publishes an archive that traps on an # ARM1176. # - # A new leg owes all eight -- and a `publish: true` leg owes a ninth thing in another - # file: an entry in each of the two asset sets release.yml diffs before it publishes, - # which otherwise fails the release with a diff rather than a reason. A publishing + # A new leg owes all nine, `with_mdns` included -- it has the same failure mode as its + # two siblings -- and a `publish: true` leg owes a tenth thing in another file: an entry + # in each of the two asset sets release.yml diffs before it publishes, which otherwise + # fails the release with a diff rather than a reason. A publishing # *macOS* leg owes two entries in each, its tarball and the installer .pkg built further # down. matrix: @@ -95,8 +96,8 @@ jobs: avahi: false systemd: true - # The 32-bit Raspberry Pi leg, cross-compiled because no runner can build it any other - # way: GitHub offers no armv7 image, and its arm64 images are Neoverse N1, which has no + # The 32-bit Raspberry Pi leg, cross-compiled because no runner can build it natively: + # GitHub offers no armv7 image, and its arm64 images are Neoverse N1, which has no # AArch32 at EL0 -- so there is no `runs-on` value that reaches this target. Emulating # the whole build is the other route, and it would pay for every compile in the # FetchContent tree to arrive somewhere this already is: compiling at native speed and @@ -357,6 +358,10 @@ jobs: echo 'A 32-bit ARM binary runs on this host.' rm -f binfmt-probe binfmt-probe.c + - name: Install build dependencies (macOS) + if: runner.os == 'macOS' + run: brew install portaudio pkgconf + # What puts qemu-arm into the kernel's binfmt_misc, so that the ARM processes in the # container below -- gcc and cmake among them, this being a native build rather than a # cross one -- can run at all. The cross leg above registers its own handler by hand from @@ -371,12 +376,8 @@ jobs: - name: Start the Raspbian build container (containerised 32-bit ARM) if: matrix.container env: - CONTAINER: ${{ matrix.container }} - run: scripts/build_armv6_container.sh start "$CONTAINER" - - - name: Install build dependencies (macOS) - if: runner.os == 'macOS' - run: brew install portaudio pkgconf + IMAGE: ${{ matrix.container }} + run: scripts/build_armv6_container.sh start "$IMAGE" # 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 @@ -405,7 +406,7 @@ jobs: WERROR: ${{ matrix.werror }} DEPS_DIR: ${{ github.workspace }}/.deps ARM32: ${{ matrix.arm32 }} - CONTAINER: ${{ matrix.container }} + IMAGE: ${{ matrix.container }} run: | # Held in one array rather than written out on each side of the branch below. What a # cross leg changes is the compiler and the flags naming the target, which @@ -423,11 +424,11 @@ jobs: -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" ) - # `$CONTAINER` is asked about first, and the order is load-bearing rather than + # `$IMAGE` is asked about first, and the order is load-bearing rather than # stylistic: the ARMv6 leg carries both keys, and scripts/build_arm32.sh refuses # `armv6` by design -- so an `$ARM32` test reached first would fail the leg with a # message about a toolchain it never meant to use. - if [ -n "$CONTAINER" ]; then + if [ -n "$IMAGE" ]; then scripts/build_armv6_container.sh configure build "${options[@]}" 2>&1 | tee configure.log elif [ -n "$ARM32" ]; then scripts/build_arm32.sh "$ARM32" build "${options[@]}" 2>&1 | tee configure.log @@ -475,9 +476,9 @@ jobs: - name: Build env: - CONTAINER: ${{ matrix.container }} + IMAGE: ${{ matrix.container }} run: | - if [ -n "$CONTAINER" ]; then + if [ -n "$IMAGE" ]; then scripts/build_armv6_container.sh run cmake --build build else cmake --build build @@ -508,6 +509,7 @@ jobs: if: matrix.arm32 env: ARM32: ${{ matrix.arm32 }} + IMAGE: ${{ matrix.container }} run: | binary=build/sendspin-cli @@ -562,6 +564,32 @@ jobs: ;; esac + # The one thing BUILD-INFO.txt claims that is not about the instruction set: that + # this archive loads on Raspberry Pi OS bookworm as well as trixie. It rests on the + # highest glibc symbol version the binary references, so it is read off the file the + # way everything else here is -- and a container digest that moved off bookworm would + # raise it silently, leaving the text inside the archive promising a Pi Zero owner + # something their loader would refuse. Bookworm's own 2.36 is the bound, rather than + # the lower number this happens to come out at, because that is the claim being made. + if [ -n "$IMAGE" ]; then + bookworm_glibc=GLIBC_2.36 + readelf --version-info "$binary" >elf-versions.txt + floor="$(grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' elf-versions.txt | sort -uV | tail -1 || true)" + rm -f elf-versions.txt + + if [ -z "$floor" ]; then + echo '::error::no GLIBC_ symbol version could be read off the binary, so the claim BUILD-INFO.txt makes about bookworm cannot be checked' + status=1 + else + echo "The highest glibc symbol version this binary references: $floor" + # The higher of the two, compared against the bound: equal is fine, above is not. + if [ "$(printf '%s\n%s\n' "$floor" "$bookworm_glibc" | sort -V | tail -1)" != "$bookworm_glibc" ]; then + echo "::error::this binary references $floor, above the $bookworm_glibc Raspberry Pi OS bookworm carries -- its loader would refuse the archive this leg is about to publish" + status=1 + fi + fi + fi + rm -f elf-header.txt elf-attributes.txt exit "$status" @@ -573,9 +601,9 @@ jobs: # real files, neither of which is obviously safe to run against itself in parallel. - name: Test env: - CONTAINER: ${{ matrix.container }} + IMAGE: ${{ matrix.container }} run: | - if [ -n "$CONTAINER" ]; then + if [ -n "$IMAGE" ]; then scripts/build_armv6_container.sh run ctest --test-dir build --output-on-failure else ctest --test-dir build --output-on-failure @@ -587,9 +615,9 @@ jobs: - name: Smoke test if: matrix.publish env: - CONTAINER: ${{ matrix.container }} + IMAGE: ${{ matrix.container }} run: | - if [ -n "$CONTAINER" ]; then + if [ -n "$IMAGE" ]; then scripts/build_armv6_container.sh run scripts/smoke_test.sh build/sendspin-cli else scripts/smoke_test.sh build/sendspin-cli @@ -711,14 +739,14 @@ jobs: RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} LEG: ${{ matrix.name }} ARM32: ${{ matrix.arm32 }} - CONTAINER: ${{ matrix.container }} + IMAGE: ${{ matrix.container }} run: | # Both the version read and the install below go through the container on the leg that # has one. The binary is the obvious half; the install is the less obvious one, and it # is not about executing anything -- cmake_install.cmake was written by the container's # cmake 3.25 and is read back by whichever cmake runs it, and the files it lays down # have to arrive owned the way everything else in this workspace is. - if [ -n "$CONTAINER" ]; then + if [ -n "$IMAGE" ]; then reported="$(scripts/build_armv6_container.sh run ./build/sendspin-cli --version)" else reported="$(./build/sendspin-cli --version)" @@ -728,7 +756,7 @@ jobs: name="sendspin-cli-$version-$LEG" mkdir -p "stage/$name" - if [ -n "$CONTAINER" ]; then + if [ -n "$IMAGE" ]; then scripts/build_armv6_container.sh run \ env DESTDIR="$PWD/stage/$name" cmake --install build --component sendspin-cli else @@ -811,8 +839,14 @@ jobs: # is exactly inverted, bookworm being what this one is *built on* rather than what it # refuses to load on. This text is read on a Pi, which is the whole reason it is # carried inside the archive. - if [ -n "$CONTAINER" ]; then - cat >>"stage/$name/BUILD-INFO.txt" <<'EMULATED' + if [ -n "$IMAGE" ]; then + # Read off the binary rather than written down, so the line below cannot drift from + # what was actually built. The assertion further up has already refused anything + # above the 2.36 bookworm itself carries; this is what says how far below it came in. + glibc_floor="$(readelf --version-info build/sendspin-cli | + grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' | sort -uV | tail -1)" + + cat >>"stage/$name/BUILD-INFO.txt" <>"stage/$name/BUILD-INFO.txt" </dev/null + # Proven here, where the message can name the cause, rather than left to surface as a + # `docker exec` complaining that the container is not running. `docker run` succeeds + # whether or not the kernel can execute what it started: with no handler registered the + # `sleep` above is an ARM binary that cannot exec, and the container is gone by the time + # anything else looks at it. That is by far the likeliest way this fails. + running || { + docker logs "$CONTAINER" 2>&1 || true + fail "the container exited as soon as it started. The usual cause is no binfmt + handler for 32-bit ARM: .github/workflows/build.yml registers one with + docker/setup-qemu-action, and on a developer's own machine 'docker run --privileged --rm + tonistiigi/binfmt --install arm' does the same" + } + # A passwd entry at the invoking user's own id, so that in_container() above has a user # to be. Reused where the image already carries one at that id rather than replaced: # what this needs is an entry and a home, not a particular name. @@ -254,8 +267,17 @@ case "$VERB" in stop) [ "$#" -eq 0 ] || fail "usage: $0 stop" - docker rm --force "$CONTAINER" >/dev/null - printf 'build_armv6_container: removed %s\n' "$CONTAINER" + + # Nothing to remove is reported rather than refused. This is the one verb a caller runs + # unconditionally to tidy up -- .github/workflows/build.yml runs it with `if: always()` + # -- so a run that failed before the container existed must not fail again here, wearing + # a message about the wrong thing. + if docker inspect "$CONTAINER" >/dev/null 2>&1; then + docker rm --force "$CONTAINER" >/dev/null + printf 'build_armv6_container: removed %s\n' "$CONTAINER" + else + printf 'build_armv6_container: no %s container to remove\n' "$CONTAINER" + fi ;; *) diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 6e46466..82a0b4a 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -19,8 +19,7 @@ # One script for every Linux host, a Raspberry Pi included, because a Pi *is* an ordinary # Linux box here: it takes whichever archive its architecture names -- `linux-arm64` on a # 64-bit OS, `linux-armv7` or `linux-armv6` on a 32-bit one -- and installs it the way a -# server takes -# `linux-x86_64`. What is genuinely Pi-specific is advice -- the `audio` group, +# server takes `linux-x86_64`. What is genuinely Pi-specific is advice -- the `audio` group, # and that the headphone jack and HDMI are separate cards -- and that is printed at the end # when a Pi is what this is running on. A second script would have been this one with two # paragraphs changed, and the two would have drifted. @@ -52,12 +51,13 @@ # full, and then either confirmed at a terminal or authorised up front with --yes. # # One asymmetry worth naming, in the spirit of the one .github/workflows/ci.yml names about -# itself: the `shellcheck` job there lints every script under scripts/, and the other three are +# itself: the `shellcheck` job there lints every script under scripts/, and the other four are # also *run* on every build -- smoke_test.sh on each publishing leg, build_arm32.sh on the -# 32-bit ARM one, build_macos_pkg.sh on the macOS one. This is the one script CI lints but -# never executes. A CI leg for it would want a runner willing to take a payload into `/` and a -# sound card to then not find, so what it has instead is the container run recorded in the -# pull request that added it. +# cross-compiled 32-bit ARM one, build_armv6_container.sh on the containerised one, +# build_macos_pkg.sh on the macOS one. This is the one script CI lints but never executes. A CI +# leg for it would want a runner willing to take a payload into `/` and a sound card to then +# not find, so what it has instead is the container run recorded in the pull request that +# added it. # # Usage: scripts/get_started_linux.sh [--version ] [--yes] # From efcfbd290c867b93ffff3e634daa5a4ce9f9b894 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 15:48:06 -0500 Subject: [PATCH 3/6] Say why the job-level container: key is not the mechanism, and stop writing the glibc floor down `build.yml` uses a real job-level `container:` on the pipewire-minimum job eighty lines below the ARMv6 leg's `matrix.container`, and never said why one starts Docker by hand. The answer is ordering: that key creates the container before the first step runs, and the emulator has to be registered on the host before an ARM container can execute anything, so there would be nowhere to put docker/setup-qemu-action. The script's own claim that YAML could not express `--init`, the uid or QEMU_CPU was overstated -- `container.options:` and `container.env:` take all three. What a workflow file cannot be is somewhere a developer runs, which is the argument that actually holds. Four places still wrote `GLIBC_2.34` down while the assertion added beside them deliberately does not: it bounds the floor at the 2.36 bookworm carries, so a drift to 2.35 would go green and silently make all four false. They now claim what is asserted, and the exact figure survives only where it is measured -- in the BUILD-INFO.txt the archive ships. The rest is rewrapping four comment and prose blocks that were edited in place without being reflowed. --- .github/workflows/build.yml | 16 +++++++++---- docs/ROADMAP.md | 23 ++++++++++--------- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 6 ++--- docs/wiki/Troubleshooting.md | 3 ++- scripts/build_armv6_container.sh | 5 ++-- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2fa0b52..3c37252 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,14 +143,21 @@ jobs: # archive is an ordinary native build run inside an emulated Raspbian container. # scripts/build_armv6_container.sh owns it, for the reason build_arm32.sh is a script. # + # Started by hand rather than named in a job-level `container:`, which the + # `pipewire-minimum` job below really does use -- and the difference is ordering, not + # taste. That key creates the container before the first step runs, and the emulator + # has to be registered on the *host* before an ARM container can execute anything at + # all; there would be nowhere left to put docker/setup-qemu-action. Its steps would + # also run as root, which the non-root note further down is about. + # # balenalib's rather than an official Raspberry Pi image, because there is no official # one: Raspberry Pi publishes OS images, not container images. What matters is that # this one installs from archive.raspbian.org -- the ARMv6 port -- rather than from # Debian's ARMv7-A armhf, and its gcc is configured `--with-arch=armv6 # --with-float=hard`, which is why nothing on this leg passes `-march` at all. Pinned # by digest for the reason every action in this file is pinned to a commit, and - # bookworm rather than trixie deliberately: it is what fixes the archive's glibc floor - # at 2.34, low enough to load on both. + # bookworm rather than trixie deliberately: building against the older glibc is what + # keeps the archive loadable on both, which the assertion below the build holds it to. # # `werror` is OFF here alone among the matrix, for the reason the `pipewire-minimum` # job below states: Raspbian's gcc 12.2.0 has a -Wrestrict false positive on @@ -1302,9 +1309,8 @@ jobs: # -Werror is off here, as it is on the linux-armv6 leg above and for the same reason -- # absent altogether in this job, spelled `werror: 'OFF'` there because a matrix key an # omitted leg would read as falsy has to be written down. gcc 12 has a -Wrestrict false - # positive on - # `line += " " + std::to_string(...)` in src/control_common.cpp, which has nothing to do - # with either sound-server backend and would fail this job for a reason it is not here to + # positive on `line += " " + std::to_string(...)` in src/control_common.cpp, which has + # nothing to do with either sound-server backend and would fail this job for a reason it # ask about. The warning line is held on every leg with a newer compiler; what is held # here is that the code compiles at all against the oldest libpipewire this project # claims to support. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5fbd47d..e6d07d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1499,11 +1499,10 @@ Optional, later. Upstream's `examples/tui_client` shows the shape. `.github/workflows/ci.yml` builds and tests every branch push and pull request on `ubuntu-24.04`, `ubuntu-24.04-arm` and `macos-14`, a fourth cross-compiled for ARMv7 on `ubuntu-24.04`, a fifth built natively for ARMv6 inside an emulated Raspbian container on that -same runner, and a sixth configured -`-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles `src/mdns_null.cpp` instead of -`src/mdns_dnssd.cpp`, so that translation unit is built rather than assumed. Every leg but the -ARMv6 one configures `-DSENDSPIN_CLI_WERROR=ON`, for the reason recorded below, and every leg -runs the CTest suite, the no-mDNS leg included: +same runner, and a sixth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles +`src/mdns_null.cpp` instead of `src/mdns_dnssd.cpp`, so that translation unit is built rather +than assumed. Every leg but the ARMv6 one configures `-DSENDSPIN_CLI_WERROR=ON`, for the reason +recorded below, and every leg runs the CTest suite, the no-mDNS leg included: `discovery_test.cpp` links whichever `MdnsService` went in, and that configuration has no other coverage. Each leg also asserts against its own configure output that it found the backends it expects — a missing `-dev` package does not fail a configure, since every @@ -1566,10 +1565,11 @@ armhf multiarch tree — `scripts/build_arm32.sh` owns the configure — and run suite and `scripts/smoke_test.sh` under `qemu-user` rather than skipping them, so it holds the same rule every other leg does. Nothing builds it natively: GitHub has no armv7 runner, and its arm64 runners are Neoverse N1 with no AArch32 at EL0, so no `runs-on` value reaches the target -at all — and a cross build is what buys back everything the ARMv6 leg below has to emulate. What keeps the leg's name honest is a `readelf` of the linked -binary: the linker merges build attributes across every object in the link and reports the -highest, so one read covers the FetchContent tree as well as our own sources, and a dependency -compiled for the wrong architecture fails the leg instead of shipping. +at all — and a cross build is what buys back everything the ARMv6 leg below has to emulate. +What keeps the leg's name honest is a `readelf` of the linked binary: the linker merges build +attributes across every object in the link and reports the highest, so one read covers the +FetchContent tree as well as our own sources, and a dependency compiled for the wrong +architecture fails the leg instead of shipping. **The ARMv6 leg is there as well, and it is not the same job.** A Pi Zero, a Pi Zero W and an original Pi are ARM1176 cores, and Debian and Ubuntu armhf are an ARMv7-A port — which is where @@ -1610,8 +1610,9 @@ exist here — `-Wno-restrict` can only be passed through `CMAKE_CXX_FLAGS`, and `target_compile_options` adds lands after it on the command line and turns the warning back on. One thing improves for free. The published archives need `GLIBC_2.38`, which Raspberry Pi OS -bookworm's 2.36 refuses at load; the ARMv6 binary tops out at `GLIBC_2.34`, lower than -Raspbian bookworm's own, so it loads on bookworm and trixie alike. The bookworm caveat the +bookworm's 2.36 refuses at load; the ARMv6 binary references nothing newer than bookworm's own +2.36, so it loads on bookworm and trixie alike. That is asserted off the finished binary rather +than assumed, and its `BUILD-INFO.txt` names the version it actually came out at. The bookworm caveat the ARMv7 archive's `BUILD-INFO.txt` carries therefore does not apply to this one, and it does not say it. Raising the floor for `linux-x86_64`, `linux-arm64` and `linux-armv7` is a separate piece of work and still owed. diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index f607d9a..7b6d34a 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -48,9 +48,9 @@ emulated Raspbian container rather than cross-compiled, because Debian and Ubunt an ARMv7-A port whose startup objects and `libgcc` would end up in the binary and make it ARMv7 whatever the compiler was told. -One practical consequence, in your favour: `linux-armv6` needs only `GLIBC_2.34`, where the -other archives need `2.38`. So it loads on Raspberry Pi OS **bookworm** as well as trixie, -which the 32-bit ARMv7 archive does not. +One practical consequence, in your favour: `linux-armv6` needs no glibc newer than bookworm +itself carries, where the other archives need `2.38`. So it loads on Raspberry Pi OS +**bookworm** as well as trixie, which the 32-bit ARMv7 archive does not. ## 2. Install diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 35df7d6..a25061e 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -314,7 +314,8 @@ Two answers, and they are different problems. distribution than the one you are running. Raspberry Pi OS **trixie** carries glibc 2.41 and takes it; **bookworm** carries 2.36 and refuses it before a line of the player runs. Move that Pi to trixie, or build from source on it. The one exception is `linux-armv6`, which is built on -Raspbian bookworm and needs no more than `GLIBC_2.34`. +Raspbian bookworm and so needs no glibc newer than bookworm's own — its `BUILD-INFO.txt` names +the version it was built against. **`Illegal instruction`.** The `linux-armv7` archive is compiled for ARMv7, and a Pi Zero, a Pi Zero W or an original Pi is ARMv6. `uname -m` says `armv6l` on those, and both diff --git a/scripts/build_armv6_container.sh b/scripts/build_armv6_container.sh index 46eba2b..d78d03b 100755 --- a/scripts/build_armv6_container.sh +++ b/scripts/build_armv6_container.sh @@ -28,8 +28,9 @@ # A script rather than lines of YAML for the reason scripts/build_arm32.sh and # scripts/build_macos_pkg.sh are: the archive this leg publishes is the only build of this # project a Pi Zero owner will ever run, and a developer has to be able to reproduce it without -# a runner -- which takes the image digest, --init, the uid and QEMU_CPU, none of which YAML can -# hand anybody. It is also what puts them under ci.yml's `shellcheck scripts/*.sh` job. +# a runner -- which takes the image digest, --init, the uid and QEMU_CPU together, and a +# workflow file is not somewhere anybody can run. It is also what puts them under ci.yml's +# `shellcheck scripts/*.sh` job. # # Four verbs where build_arm32.sh has none, and the difference is what each owns. A cross build # is a *configuration*: once cmake has the flags, `cmake --build` over the directory is an From d0834d487d304d38a471a603a24cd2055f2990c5 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 16:15:28 -0500 Subject: [PATCH 4/6] Build the ARMv6 leg with the cores the runner actually has The first CI run came in at 24m56s against a 30-minute timeout, where the plan expected six to nine minutes. The build is 16m59s of it, and it was running three ways parallel on a four-core image: `CMAKE_BUILD_PARALLEL_LEVEL: 3` at the top of this file is a floor, and what sets it is the three-core macOS image rather than anything about Linux. That costs nothing on the legs that compile natively, where the whole build is seconds. On this one every compile is emulated, so it is the difference between a leg with headroom and a leg that fits its timeout by five minutes. `nproc` reads the count off the runner rather than writing one down, so an image with more cores is a faster build instead of an edit nobody remembers to make. The timeout comment claimed the 30 minutes was well clear of the slowest leg, which the measurement did not support. It now says what the margin is really for. --- .github/workflows/build.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c37252..0150b06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,10 +32,12 @@ jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.runner }} - # Well clear of a cold cache on the slowest leg -- which is linux-armv6, the one that - # emulates every compile in the FetchContent tree where the others compile at native speed - # -- and far under the six-hour default: the point is that a player which ignores SIGTERM - # fails rather than holds a runner all day. + # Sized for 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. linux-armv6 is that leg by + # a wide margin and the only one this number is really about -- it emulates every compile in + # the FetchContent tree where the others compile at native speed, which is minutes against + # seconds. The headroom is real but it is not vast, so a leg that starts running long is + # worth a look at rather than a larger number here. timeout-minutes: 30 strategy: # Every leg's result is wanted rather than only the first failure's: a break that shows @@ -486,7 +488,15 @@ jobs: IMAGE: ${{ matrix.container }} run: | if [ -n "$IMAGE" ]; then - scripts/build_armv6_container.sh run cmake --build build + # The one leg that overrides the workflow-level parallelism, because it is the only + # one where the difference is worth anything: every compile here is emulated, and + # the build is most of a job that has to fit the timeout above. The 3 set at the top + # of this file is a floor -- the three-core macOS image is what sets it -- and this + # leg runs on a four-core one. Read off the runner rather than written down, the way + # the cross leg reads its own architecture and suite, so an image with more cores is + # a faster build rather than an edit nobody remembers to make. + CMAKE_BUILD_PARALLEL_LEVEL="$(nproc)" \ + scripts/build_armv6_container.sh run cmake --build build else cmake --build build fi From 10eab8d8c1e1d23a9d253962e1ab203ba905f52a Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 16:41:38 -0500 Subject: [PATCH 5/6] Give the ARMv6 leg a timeout it is not about to trip over The leg measures 22m49s against the 30 minutes this job allowed, a margin of under a quarter. Hosted-runner variance is routinely that wide, so the first thing to go red would have been the weather rather than a defect -- and a leg that flakes teaches people to re-run it rather than to read it. 40 is the measurement plus room for a slow runner. It costs the other legs nothing, all of which finish inside three minutes; the six-hour default is still the thing being guarded against, and a player that ignores SIGTERM still fails rather than holding a runner all day. The pipewire-minimum job keeps its own 30, being a two-minute native build with no emulation in it. --- .github/workflows/build.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0150b06..97d924e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,10 +35,15 @@ jobs: # Sized for 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. linux-armv6 is that leg by # a wide margin and the only one this number is really about -- it emulates every compile in - # the FetchContent tree where the others compile at native speed, which is minutes against - # seconds. The headroom is real but it is not vast, so a leg that starts running long is - # worth a look at rather than a larger number here. - timeout-minutes: 30 + # the FetchContent tree where the others compile at native speed, which measures 23 minutes + # against two or three. + # + # 40 rather than 30 is that measurement plus room for a slow runner. At 30 the margin was + # under a quarter, which on shared hardware is the leg that goes red for the weather rather + # than for a defect -- and a flaky leg teaches people to re-run rather than to read. Every + # other leg finishes inside three minutes, so the larger number costs them nothing: what it + # buys is that a red ARMv6 leg means something. + timeout-minutes: 40 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. From 714ab0d80cedecc4af89468c2691d28ee03a1ae9 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 17:00:33 -0500 Subject: [PATCH 6/6] Hold the warning line on the ARMv6 leg, refuse every pre-ARMv6 host, and pin the binfmt image Three things a review of the branch caught. `scripts/get_started_linux.sh` refused `armv5*` and bare `arm` but left an open fallback to `linux-armv7` beneath them, so `armv4l` -- and any other pre-v5 spelling -- would have installed a binary that traps. It is reachable: the getconf branch maps every 32-bit `arm*` kernel to an armhf userland. Every pre-v6 `uname` is named now, and the fallback is left to the machines it is for. Turning SENDSPIN_CLI_WERROR off suppressed every diagnostic on this leg to accommodate one that is wrong. `-Wno-error=restrict` demotes that one and leaves -Werror standing, so an ARMv6-only warning nobody has met yet still fails the leg. The earlier reasoning against a targeted flag held for `-Wno-restrict`, which a later `-Wall` re-enables, and does not hold for `-Wno-error=`: a later blanket -Werror does not re-promote a diagnostic already exempted. Verified on the real toolchain, whose compile line reads `-Wno-error=restrict -O3 -DNDEBUG -Wall -Wextra -Wpedantic -Werror` -- three -Wrestrict warnings, no errors, 426/426 and the smoke test green. With every leg back on -Werror the `werror` matrix key says nothing, so it is gone. docker/setup-qemu-action was pinned by commit while its `image` input defaulted to the mutable `tonistiigi/binfmt:latest`, which it runs `--privileged` to edit the kernel's binfmt handlers. Pinning the action and not that image is pinning the wrong half. It also installed every emulator rather than the one handler anything here uses. Also: the `stop` usage line ended mid-sentence, and a rewrap had eaten four words out of the pipewire-minimum comment. --- .github/workflows/build.yml | 66 ++++++++++++++++---------------- README.md | 18 ++++----- docs/ROADMAP.md | 17 ++++---- scripts/build_armv6_container.sh | 32 ++++++++++++---- scripts/get_started_linux.sh | 17 ++++---- 5 files changed, 86 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 97d924e..e621e3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,10 +50,10 @@ jobs: fail-fast: false # `publish`, `avahi` and `systemd` 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, or stop proving the unit it installs, and still go green. `with_pulse`, - # `with_pipewire` and `werror` are spelled out for the sibling reason: an omitted key + # publishing, or stop proving the unit it installs, and still go green. `with_mdns`, + # `with_pulse` and `with_pipewire` are spelled out for the sibling reason: an omitted key # reaches cmake as `-DSENDSPIN_CLI_WITH_PULSE=`, which is falsy, so a leg meaning to - # build the backend -- or meaning to hold the warning line -- would silently not. + # build the backend would silently not. # # `arm32` names the 32-bit ARM architecture a leg targets, and `container` says a leg # builds inside one rather than on the runner: what and how, and both empty on every leg @@ -63,10 +63,9 @@ jobs: # scripts/build_arm32.sh refuses by name, and publishes an archive that traps on an # ARM1176. # - # A new leg owes all nine, `with_mdns` included -- it has the same failure mode as its - # two siblings -- and a `publish: true` leg owes a tenth thing in another file: an entry - # in each of the two asset sets release.yml diffs before it publishes, which otherwise - # fails the release with a diff rather than a reason. A publishing + # A new leg owes all eight -- and a `publish: true` leg owes a ninth thing in another + # file: an entry in each of the two asset sets release.yml diffs before it publishes, + # which otherwise fails the release with a diff rather than a reason. A publishing # *macOS* leg owes two entries in each, its tarball and the installer .pkg built further # down. matrix: @@ -75,7 +74,6 @@ jobs: runner: ubuntu-24.04 arm32: '' container: '' - werror: 'ON' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -91,7 +89,6 @@ jobs: runner: ubuntu-24.04-arm arm32: '' container: '' - werror: 'ON' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -129,7 +126,6 @@ jobs: runner: ubuntu-24.04 arm32: 'armv7' container: '' - werror: 'ON' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -166,16 +162,11 @@ jobs: # bookworm rather than trixie deliberately: building against the older glibc is what # keeps the archive loadable on both, which the assertion below the build holds it to. # - # `werror` is OFF here alone among the matrix, for the reason the `pipewire-minimum` - # job below states: Raspbian's gcc 12.2.0 has a -Wrestrict false positive on - # `line += " " + std::to_string(...)`, which has nothing to do with this architecture - # and would fail the leg for a question it is not here to ask. It fires at - # src/control_common.cpp:391 and :401 as it does there, and at - # tests/cli_test.cpp:1133 as well -- that third site being 32-bit-specific, the - # warning turning on a memcpy size the compiler folds differently where size_t is - # `unsigned int`. `-Wno-restrict` is not the narrower fix it looks like: it can only - # be passed through CMAKE_CXX_FLAGS, and the `-Wall` CMakeLists.txt adds with - # target_compile_options lands after it and turns the warning back on. + # This leg holds the warning line like every other, which takes one exemption: + # Raspbian's gcc 12.2.0 has a -Wrestrict false positive that would otherwise fail it + # for a question it is not here to ask. scripts/build_armv6_container.sh passes + # `-Wno-error=restrict` and says why there, including why the `-Wno-error=` form is + # the one that works. # # `runtime_packages` names bookworm's spellings, where the ARMv7 leg above names # trixie's, and adds libatomic1: ARMv6 has no LDREXD, so the 64-bit atomics become @@ -184,7 +175,6 @@ jobs: runner: ubuntu-24.04 arm32: 'armv6' container: 'balenalib/rpi-raspbian:bookworm@sha256:0f3c33faa9d7b1dac778e8afb5854080dcfbddeac7437265598c6076bb0ffc82' - werror: 'OFF' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -204,7 +194,6 @@ jobs: runner: macos-14 arm32: '' container: '' - werror: 'ON' with_mdns: 'ON' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -230,7 +219,6 @@ jobs: runner: ubuntu-24.04 arm32: '' container: '' - werror: 'ON' with_mdns: 'OFF' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -380,9 +368,22 @@ jobs: # container below -- gcc and cmake among them, this being a native build rather than a # cross one -- can run at all. The cross leg above registers its own handler by hand from # qemu-user-static, which is the same mechanism arrived at from the other direction. + # + # `image` is pinned by digest as well as the action by commit, and it is the one that + # matters most in this file: the action's default is the mutable + # `tonistiigi/binfmt:latest`, and what it does with it is run it `--privileged` to edit + # the kernel's binfmt handlers. A tag that can be repointed is a worse thing to hand that + # than write access to a workspace. + # + # `arm` rather than the default `all`, because that is the only handler anything here + # uses -- registering riscv64, ppc64le and the rest is emulators this job will never + # execute, installed by a privileged container. - name: Register the ARM emulator (containerised 32-bit ARM) if: matrix.container uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + with: + image: docker.io/tonistiigi/binfmt@sha256:1b804311fe87047a4c96d38b4b3ef6f62fca8cd125265917a9e3dc3c996c39e6 # qemu-v9.2.2 + platforms: arm # Everything from Configure down runs in here. The container is started once and kept, # rather than a fresh `docker run` per step: cmake records the directory it was configured @@ -417,7 +418,6 @@ jobs: WITH_MDNS: ${{ matrix.with_mdns }} WITH_PULSE: ${{ matrix.with_pulse }} WITH_PIPEWIRE: ${{ matrix.with_pipewire }} - WERROR: ${{ matrix.werror }} DEPS_DIR: ${{ github.workspace }}/.deps ARM32: ${{ matrix.arm32 }} IMAGE: ${{ matrix.container }} @@ -431,7 +431,7 @@ jobs: # builds. options=( -DCMAKE_BUILD_TYPE=Release - -DSENDSPIN_CLI_WERROR="$WERROR" + -DSENDSPIN_CLI_WERROR=ON -DSENDSPIN_CLI_WITH_MDNS="$WITH_MDNS" -DSENDSPIN_CLI_WITH_PULSE="$WITH_PULSE" -DSENDSPIN_CLI_WITH_PIPEWIRE="$WITH_PIPEWIRE" @@ -1321,14 +1321,14 @@ jobs: exit 1 fi - # -Werror is off here, as it is on the linux-armv6 leg above and for the same reason -- - # absent altogether in this job, spelled `werror: 'OFF'` there because a matrix key an - # omitted leg would read as falsy has to be written down. gcc 12 has a -Wrestrict false - # positive on `line += " " + std::to_string(...)` in src/control_common.cpp, which has - # nothing to do with either sound-server backend and would fail this job for a reason it - # ask about. The warning line is held on every leg with a newer compiler; what is held - # here is that the code compiles at all against the oldest libpipewire this project - # claims to support. + # SENDSPIN_CLI_WERROR is deliberately absent, alone among the builds in this file -- the + # linux-armv6 leg above meets the same gcc 12 -Wrestrict false positive on + # `line += " " + std::to_string(...)` in src/control_common.cpp and exempts that one + # diagnostic rather than all of them. Here the whole option is dropped, because the + # warning has nothing to do with either sound-server backend and this job is not the + # place the warning line is held. The matrix above holds it; what is held here is that + # the code compiles at all against the oldest libpipewire this project claims to + # support. - name: Configure run: | cmake -B build -DCMAKE_BUILD_TYPE=Release -DSENDSPIN_CLI_WITH_PORTAUDIO=OFF -DSENDSPIN_CLI_WITH_MDNS=OFF 2>&1 | tee configure.log diff --git a/README.md b/README.md index 2055e88..5f48057 100644 --- a/README.md +++ b/README.md @@ -1330,11 +1330,11 @@ and `macos-14`, a fourth leg cross-compiled for ARMv7 on `ubuntu-24.04`, a fifth built for ARMv6 inside an emulated Raspbian container on the same runner, and a sixth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles `src/mdns_null.cpp` in place of `src/mdns_dnssd.cpp`, so that configuration is -built rather than assumed. Every leg but the ARMv6 one builds with -`-DSENDSPIN_CLI_WERROR=ON`, every leg runs the unit suite, and each asserts from -its own configure output that it found the backends it expects: a missing `-dev` -package does not fail a configure, so without that check the matrix would happily -go green on a deaf, undiscoverable binary. +built rather than assumed. Every leg builds with `-DSENDSPIN_CLI_WERROR=ON` and +runs the unit suite, and each asserts from its own configure output that it found +the backends it expects: a missing `-dev` package does not fail a configure, so +without that check the matrix would happily go green on a deaf, undiscoverable +binary. The ARMv7 leg is cross-compiled because no runner can build it natively: GitHub has no armv7 runner, and its arm64 runners cannot execute 32-bit ARM at all. @@ -1349,10 +1349,10 @@ runner: Debian and Ubuntu `armhf` are an ARMv7-A port, so a cross toolchain's ow Raspbian's are genuinely ARMv6, so that leg runs an ordinary native build inside a digest-pinned Raspbian container under `qemu-user` — with its suite and smoke test under `QEMU_CPU=arm1176`, so the emulator is no more permissive than an ARM1176 — -and asserts ARMv6, hard-float EABI off the finished binary the same way. It is the -one leg without `-Werror`: Raspbian's gcc 12 has the same `-Wrestrict` false -positive on `src/control_common.cpp` that the `pipewire-minimum` job already -documents. +and asserts ARMv6, hard-float EABI off the finished binary the same way. It holds +the warning line with one exemption: Raspbian's gcc 12 has the same `-Wrestrict` +false positive the `pipewire-minimum` job documents, so that leg passes +`-Wno-error=restrict` and leaves `-Werror` standing over everything else. The matrix lives in `.github/workflows/build.yml`, which both `ci.yml` and `release.yml` call, so a release is built and gated exactly the way a push is. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e6d07d9..600ebc5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1501,8 +1501,8 @@ Optional, later. Upstream's `examples/tui_client` shows the shape. `ubuntu-24.04`, a fifth built natively for ARMv6 inside an emulated Raspbian container on that same runner, and a sixth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles `src/mdns_null.cpp` instead of `src/mdns_dnssd.cpp`, so that translation unit is built rather -than assumed. Every leg but the ARMv6 one configures `-DSENDSPIN_CLI_WERROR=ON`, for the reason -recorded below, and every leg runs the CTest suite, the no-mDNS leg included: +than assumed. Every leg configures `-DSENDSPIN_CLI_WERROR=ON` and runs the CTest suite, the +no-mDNS leg included: `discovery_test.cpp` links whichever `MdnsService` went in, and that configuration has no other coverage. Each leg also asserts against its own configure output that it found the backends it expects — a missing `-dev` package does not fail a configure, since every @@ -1603,11 +1603,14 @@ sendspin-cpp's own `connection_manager.cpp` become libatomic calls. It goes in `CMAKE_CXX_STANDARD_LIBRARIES` rather than `CMAKE_EXE_LINKER_FLAGS`, which places it ahead of the objects that need it and leaves the linker to discard it. -`-DSENDSPIN_CLI_WERROR=ON` is absent on that leg alone among the matrix, for the same reason the -`pipewire-minimum` job drops it: Raspbian's gcc 12.2.0 has a `-Wrestrict` false positive on -`line += " " + std::to_string(...)` in `src/control_common.cpp`. The narrower fix does not -exist here — `-Wno-restrict` can only be passed through `CMAKE_CXX_FLAGS`, and the `-Wall` that -`target_compile_options` adds lands after it on the command line and turns the warning back on. +That leg holds the warning line like the rest, with one diagnostic exempted: Raspbian's gcc +12.2.0 has a `-Wrestrict` false positive on `line += " " + std::to_string(...)`, at +`src/control_common.cpp:391` and `:401` and at `tests/cli_test.cpp:1133`. It passes +`-Wno-error=restrict`, which is not interchangeable with `-Wno-restrict`: `CMAKE_CXX_FLAGS` +lands before the `-Wall` `target_compile_options` adds, and a later `-Wall` turns a *disabled* +warning back on — where a later blanket `-Werror` does not re-promote a diagnostic an earlier +`-Wno-error=` has already exempted. So an ARMv6-only warning nobody has met yet still fails the +leg. One thing improves for free. The published archives need `GLIBC_2.38`, which Raspberry Pi OS bookworm's 2.36 refuses at load; the ARMv6 binary references nothing newer than bookworm's own diff --git a/scripts/build_armv6_container.sh b/scripts/build_armv6_container.sh index d78d03b..74111f7 100755 --- a/scripts/build_armv6_container.sh +++ b/scripts/build_armv6_container.sh @@ -54,8 +54,9 @@ # configure configure in the container; remaining arguments reach cmake verbatim, # which is how the caller keeps owning the options that are not about this target # run run one command in the container, in the same directory, as the same user -# stop remove the container. The runner is discarded either way; this is for a developer -# who ran `start` on a machine that is not +# stop remove the container, reporting rather than failing when there is none. A runner +# is discarded whole, so this is for a developer's own machine, which is not -- and +# for the workflow to call with `if: always()` without a failed run failing twice set -euo pipefail @@ -245,17 +246,32 @@ case "$VERB" in shift readonly BUILD_DIR - # -latomic is this target's one link-line fact, and it belongs here for the reason - # build_arm32.sh owns the armv7 -march: it is a property of the machine rather than of - # this project. ARMv6 has no LDREXD, so every 64-bit atomic becomes a libatomic call -- - # from src/pulse_sink.cpp, src/player_listener.cpp and sendspin-cpp's own - # connection_manager.cpp -- and the link fails on __atomic_load_8 without it. The ARMv7 - # leg needs nothing of the kind because ARMv7 has LDREXD. + # Two facts about this toolchain, both of them properties of the machine and its + # compiler rather than of this project -- which is why they belong here, for the reason + # build_arm32.sh owns the armv7 -march rather than leaving it to the caller. # + # -latomic: ARMv6 has no LDREXD, so every 64-bit atomic becomes a libatomic call -- from + # src/pulse_sink.cpp, src/player_listener.cpp and sendspin-cpp's own + # connection_manager.cpp -- and the link fails on __atomic_load_8 without it. The ARMv7 + # leg needs nothing of the kind because ARMv7 has LDREXD. It goes in # CMAKE_CXX_STANDARD_LIBRARIES rather than CMAKE_EXE_LINKER_FLAGS, which places it ahead # of the objects that reference it and leaves the linker to discard it as unused. + # + # -Wno-error=restrict: Raspbian's gcc 12.2.0 has a -Wrestrict false positive on + # `line += " " + std::to_string(...)`, at src/control_common.cpp:391 and :401 and at + # tests/cli_test.cpp:1133 -- the third site 32-bit-specific, the warning turning on a + # memcpy size the compiler folds differently where size_t is `unsigned int`. This + # demotes that one diagnostic and leaves -Werror standing over everything else, so an + # ARMv6-only warning nobody has met yet still fails the leg. + # + # The `-Wno-error=` form is what makes that possible, and it is not interchangeable with + # `-Wno-restrict`. CMAKE_CXX_FLAGS lands before the `-Wall -Wextra -Wpedantic -Werror` + # CMakeLists.txt adds with target_compile_options, and a later -Wall turns a warning + # disabled earlier back on -- where a later blanket -Werror does not re-promote a + # diagnostic an earlier -Wno-error= has already exempted. in_container cmake -B "$BUILD_DIR" \ -DCMAKE_CXX_STANDARD_LIBRARIES=-latomic \ + -DCMAKE_CXX_FLAGS=-Wno-error=restrict \ "$@" printf 'build_armv6_container: configured %s for armv6 in %s\n' "$BUILD_DIR" "$CONTAINER" diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 82a0b4a..8ea3689 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -201,17 +201,20 @@ case "$USERLAND" in # # Below ARMv6 there is still nothing, and that stays a refusal with the whole answer in # it: "unsupported architecture" on a Pi sends people looking for a download that does - # not exist. Bare `arm` is refused with them because it names no instruction set at all, - # and an ARMv5 board answering to it would take a binary that traps. + # not exist. Every pre-v6 spelling is named rather than left to the `armv7` fallback + # below, because that fallback is what an unrecognised machine reaches -- and `armv4l` + # falling into it would install a binary that traps. Bare `arm` is refused with them + # because it names no instruction set at all, which the getconf branch above can + # produce for any 32-bit ARM kernel. case "$MACHINE" in armv6*) LEG='linux-armv6' ;; - armv5* | arm) - fail "'$MACHINE' is ARMv5 or names no ARM architecture at all, and the oldest - archive built is linux-armv6 -- an ARM1176, which is a Pi Zero, a Pi Zero W or an original - Pi. Its instructions would be illegal on an ARMv5 machine, so there is nothing here to - install. Build from source instead: https://github.com/$REPO#build" + armv[0-5]* | arm) + fail "'$MACHINE' is older than ARMv6, or names no ARM architecture at all, and + the oldest archive built is linux-armv6 -- an ARM1176, which is a Pi Zero, a Pi Zero W or an + original Pi. Its instructions would be illegal here, so there is nothing to install. Build + from source instead: https://github.com/$REPO#build" ;; *) LEG='linux-armv7'