diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df7f780..e621e3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,29 +32,48 @@ 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. - timeout-minutes: 30 + # 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 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. 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_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 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: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -69,6 +88,7 @@ jobs: - name: linux-arm64 runner: ubuntu-24.04-arm arm32: '' + container: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -80,14 +100,18 @@ 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 # 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 +125,7 @@ jobs: - name: linux-armv7 runner: ubuntu-24.04 arm32: 'armv7' + container: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -112,6 +137,55 @@ 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. + # + # 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: building against the older glibc is what + # keeps the archive loadable on both, which the assertion below the build holds it to. + # + # 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 + # 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' + 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 +193,7 @@ jobs: - name: macos-arm64 runner: macos-14 arm32: '' + container: '' with_mdns: 'ON' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -143,6 +218,7 @@ jobs: - name: linux-x86_64-nomdns runner: ubuntu-24.04 arm32: '' + container: '' with_mdns: 'OFF' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -178,8 +254,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 @@ -283,6 +364,36 @@ jobs: 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 + # 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 + # 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: + 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 # without them every run refetches. What is deliberately *not* in here is anything @@ -309,11 +420,15 @@ jobs: WITH_PIPEWIRE: ${{ matrix.with_pipewire }} DEPS_DIR: ${{ github.workspace }}/.deps ARM32: ${{ matrix.arm32 }} + IMAGE: ${{ 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 @@ -323,7 +438,13 @@ jobs: -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" ) - if [ -n "$ARM32" ]; then + # `$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 "$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 else cmake -B build "${options[@]}" 2>&1 | tee configure.log @@ -368,9 +489,24 @@ jobs: exit "$status" - name: Build - run: cmake --build build + env: + IMAGE: ${{ matrix.container }} + run: | + if [ -n "$IMAGE" ]; then + # 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 - # 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 +515,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. @@ -390,11 +531,13 @@ jobs: if: matrix.arm32 env: ARM32: ${{ matrix.arm32 }} + IMAGE: ${{ matrix.container }} run: | binary=build/sendspin-cli case "$ARM32" in armv7) want_arch='v7' ;; + armv6) want_arch='v6' ;; *) echo "::error::no expected CPU architecture is recorded here for '$ARM32'" exit 1 @@ -443,6 +586,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" @@ -453,14 +622,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: + IMAGE: ${{ matrix.container }} + run: | + 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 + 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: + IMAGE: ${{ matrix.container }} + run: | + 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 + 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 +761,29 @@ jobs: RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} LEG: ${{ matrix.name }} ARM32: ${{ matrix.arm32 }} + IMAGE: ${{ 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 "$IMAGE" ]; 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 "$IMAGE" ]; 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" <>"stage/$name/BUILD-INFO.txt" <&1 | tee configure.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4c7999..6d04a52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,10 +47,11 @@ jobs: # Stays here rather than moving into build.yml with the rest, and the asymmetry is worth being # honest about now that build_macos_pkg.sh shapes a released artifact: what this job adds is # *lint*, and a tag push runs release.yml alone, so it is lint that a tag does not get. What a - # tag does get is three of them being run -- smoke_test.sh on every publishing leg, - # build_arm32.sh on the 32-bit ARM one, and build_macos_pkg.sh on the macOS one, whose .pkg is - # then installed and its receipt asserted -- so a release is gated on the scripts working - # whether or not it is gated on their style. + # tag does get is four of them being run -- smoke_test.sh on every publishing leg, + # build_arm32.sh on the cross-compiled 32-bit ARM one, build_armv6_container.sh on the + # containerised one, and build_macos_pkg.sh on the macOS one, whose .pkg is then installed and + # its receipt asserted -- so a release is gated on the scripts working whether or not it is + # gated on their style. shellcheck: name: shellcheck runs-on: ubuntu-24.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b9fe54..11ed578 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" @@ -208,10 +208,14 @@ jobs: macOS installer `.pkg`, built and published by `.github/workflows/release.yml` from the tag alone. - `linux-armv7` is the 32-bit Raspberry Pi build — a Pi 2, a Pi 3, a Pi 4 or a Pi - Zero 2 running a 32-bit Raspberry Pi OS. It is cross-compiled, and its unit suite and - smoke test are run under emulation rather than on a Pi. A Pi Zero, a Pi Zero W and an - original Pi are ARMv6 and this archive is not for them; see the roadmap below. + `linux-armv7` and `linux-armv6` are the two 32-bit Raspberry Pi builds, and which one + a board takes is a question about its CPU. `linux-armv7` is for a Pi 2, a Pi 3, a Pi 4 + or a Pi Zero 2 running a 32-bit Raspberry Pi OS; `linux-armv6` is for a Pi Zero, a Pi + Zero W or an original Pi, which are ARM1176 and would meet an illegal instruction in + the other. `scripts/get_started_linux.sh` picks between them for you. Neither is built + on a Pi — the unit suite and smoke test of both are run under emulation on the runner + that built them — and `linux-armv6` is the only archive here that loads on Raspberry + Pi OS bookworm as well as trixie. ## Install @@ -274,7 +278,7 @@ jobs: ## Checksums - `SHA256SUMS` covers the five files above. It does not cover the `Source code` + `SHA256SUMS` covers the six files above. It does not cover the `Source code` archives GitHub attaches on its own, which are generated by GitHub rather than built here. @@ -284,7 +288,7 @@ jobs: ``` Run that from the directory you downloaded into. `--ignore-missing` is there because - `SHA256SUMS` lists all five and you have almost certainly taken one: without it, the + `SHA256SUMS` lists all six and you have almost certainly taken one: without it, the ones you did not download are reported as failures and the command exits non-zero on a perfectly good file. @@ -387,7 +391,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..5f48057 100644 --- a/README.md +++ b/README.md @@ -1326,8 +1326,9 @@ 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 @@ -1335,13 +1336,24 @@ 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 -no armv7 runner, and its arm64 runners cannot execute 32-bit ARM at all. +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. `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 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. `ci.yml` ignores tags for that reason — otherwise a tag would build twice. @@ -1370,9 +1382,9 @@ kept for 14 days. For something that does not expire, take a ## Releases -Pushing a `vMAJOR.MINOR.PATCH` tag builds the same matrix and publishes the four +Pushing a `vMAJOR.MINOR.PATCH` tag builds the same matrix and publishes the five platform archives and the macOS installer `.pkg`, plus a `SHA256SUMS` covering all -five, as a GitHub Release. The workflow triggers on `v*` but refuses anything else +six, as a GitHub Release. The workflow triggers on `v*` but refuses anything else that matches — a prerelease like `v0.2.0-rc1` is rejected rather than quietly published as the latest release, until somebody decides what it should mean. Nothing else publishes, and the workflow never creates a tag: a release exists @@ -1390,9 +1402,9 @@ sha256sum --ignore-missing -c SHA256SUMS # Linux shasum -a 256 --ignore-missing -c SHA256SUMS # macOS ``` -`--ignore-missing` because `SHA256SUMS` lists all five and you have almost +`--ignore-missing` because `SHA256SUMS` lists all six and you have almost certainly taken one; without it the rest are reported as failures and the command -exits non-zero on a file that is fine. Those checksums cover the five things built +exits non-zero on a file that is fine. Those checksums cover the six things built here, not the `Source code` archives GitHub attaches on its own. Neither the macOS binary nor the `.pkg` around it is signed — see below. A Developer ID signature and notarization are still owed, tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md) item 10. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a4cdaa8..600ebc5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1494,19 +1494,20 @@ 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 -`-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: +`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: `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 @@ -1562,21 +1563,62 @@ shutdown path, and produced zero reports when run that way by hand. **The 32-bit Pi leg is there.** `linux-armv7` cross-compiles on `ubuntu-24.04` against the armhf multiarch tree — `scripts/build_arm32.sh` owns the configure — and runs the whole unit suite and `scripts/smoke_test.sh` under `qemu-user` rather than skipping them, so it holds the -same rule every other leg does. A cross build is the only shape available: 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. 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. - -**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 +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. + +**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. Everything it runs in the container runs 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. That covers the +configure as well as the suite, a CMake `try_run` probe asking what the CPU can do being +answerable for the wrong CPU otherwise. 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. + +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 +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. 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..70f133d 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -4,8 +4,8 @@ From nothing to a player your Sendspin server can find. On a Raspberry Pi, read [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi) instead — it is this page plus the handful of things a Pi does differently. -**You need:** a Linux host (`x86_64`, `arm64`, or `armv7` on a 32-bit Raspberry Pi OS), -systemd, a sound card, and root. +**You need:** a Linux host (`x86_64`, `arm64`, or `armv7`/`armv6` on a 32-bit Raspberry Pi +OS), systemd, a sound card, and root. ## The short way @@ -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..7b6d34a 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 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/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..a25061e 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -313,14 +313,15 @@ 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 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 -`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..74111f7 --- /dev/null +++ b/scripts/build_armv6_container.sh @@ -0,0 +1,303 @@ +#!/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 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 +# 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, 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 + +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 + + # 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. + 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 + + # 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" + ;; + + run) + [ "$#" -ge 1 ] || fail "usage: $0 run [argument ...]" + in_container "$@" + ;; + + stop) + [ "$#" -eq 0 ] || fail "usage: $0 stop" + + # 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 + ;; + + *) + 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..8ea3689 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -18,8 +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 -# `linux-x86_64`. What is genuinely Pi-specific is advice -- the `audio` group, +# 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 # paragraphs changed, and the two would have drifted. @@ -51,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] # @@ -194,20 +195,31 @@ 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. 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 - 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' + ;; + 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' ;; esac - LEG='linux-armv7' ;; '') fail "this host's userland could not be identified from a $MACHINE kernel alone, and @@ -216,7 +228,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