From b095d326e5e6076716e1427d07b04512ddb85090 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 11:55:20 -0500 Subject: [PATCH 1/3] Add a cross-compiled linux-armv7 leg to the CI matrix 32-bit Raspberry Pi hardware had no release archive: `get_started_linux.sh` refused every 32-bit ARM userland and told the operator to reinstall a 64-bit OS. This adds a `linux-armv7` publishing leg so a Pi 2, Pi 3, Pi 4 or Pi Zero 2 running a 32-bit Raspberry Pi OS has something to install, and so the tree is proven to still build for that target on every push. It is cross-compiled because nothing else can reach the target: GitHub offers no armv7 runner, and its arm64 runners are Neoverse N1 with no AArch32 at EL0. `scripts/build_arm32.sh` owns the cross configure -- a script rather than lines of YAML, following the precedent build.yml states for `build_macos_pkg.sh`, so it is reproducible without a runner and gated by ci.yml's shellcheck job. The suite is run rather than skipped: `qemu-user-static` through binfmt executes the ctest binaries and `smoke_test.sh` on the host, so the leg keeps the rule every other leg holds. The target flags go in `CMAKE_C_FLAGS`/`CMAKE_CXX_FLAGS` globally rather than on our own targets, because every dependency arrives through FetchContent in the one cmake invocation and a per-target flag would leave them compiling for the host default. A new step reads the linked binary's merged ELF build attributes back and fails the leg unless they say ARMv7, hard-float EABI -- the linker merges attributes across every object in the link, so that one read covers the whole dependency tree. Two steps gated on `runner.os == 'Linux'` rather than on a matrix key would have fired against a cross-built binary, so the matrix gains a `systemd:` key, spelled out on every leg beside `publish` and `avahi`, and an `arm32:` key naming the target. The arm32 leg installs the `:armhf` build dependencies instead of the runner's own, which is what leaves `find_library` nothing of the wrong architecture to answer with; its `expect_mdns` is anchored on the `arm-linux-gnueabihf` path so a host-tree `libdns_sd` fails the leg rather than satisfying the pattern the native legs use. ARMv6 is deliberately not here. Debian and Ubuntu armhf are an ARMv7-A port, and that is where the toolchain's own `crt1.o`, `crtbegin.o` and `libgcc.a` come from -- all ARMv7, all linked in -- so compiling our sources `-march=armv6` does not yield an ARMv6 archive, and the merged `Tag_CPU_arch` says so. It needs a Raspberry Pi OS sysroot. `build_arm32.sh` refuses `armv6` rather than building something that would trap on a Pi Zero, `get_started_linux.sh` refuses an `armv6l` host, and ROADMAP item 12 records what it would take. Downstream: both `for leg in ...` lists in release.yml and its notes prose, the installer's architecture mapping, README, ROADMAP item 12, four wiki pages and a Troubleshooting entry for the two ways a Pi install can fail to start. The archives need glibc 2.38 and so will not start on Raspberry Pi OS bookworm (2.36); they do on trixie (2.41), which is what Raspberry Pi OS ships. That floor is not introduced here -- the existing linux-x86_64 and linux-arm64 archives are built on the same image and carry it too -- but this is the leg that aims a Pi audience at it, so BUILD-INFO.txt now states it where that reader will meet it. Verified in containers: cross configure finds all six backends and resolves mDNS to the armhf path with both dns_sd probes Failed; the build is clean under -Werror on gcc 13; ctest is 426/426 and smoke_test.sh fully green under qemu; Package and the payload assertion pass unchanged, unit and sysusers fragment included. The new gates were checked in both directions -- the ELF assertion rejects an armv8 build, a soft-float build and a native host binary, and the anchored expect_mdns refuses a configure that resolved libdns_sd from the host tree. The native branch still builds and passes 426/426. --- .github/workflows/build.yml | 314 ++++++++++++++++-- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 19 +- README.md | 30 +- docs/ROADMAP.md | 41 ++- docs/wiki/Getting-Started-on-Linux.md | 8 +- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 47 ++- docs/wiki/Home.md | 9 +- docs/wiki/Installation.md | 19 +- docs/wiki/Troubleshooting.md | 20 ++ packaging/sendspin-cli.service.in | 3 +- scripts/build_arm32.sh | 158 +++++++++ scripts/get_started_linux.sh | 51 +-- 13 files changed, 609 insertions(+), 117 deletions(-) create mode 100755 scripts/build_arm32.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f86aeb5..df7f780 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,19 +39,22 @@ jobs: # Every leg's result is wanted rather than only the first failure's: a break that shows # on one architecture and not another is precisely what the matrix is here to tell apart. fail-fast: false - # `publish` and `avahi` are spelled out on every leg, false included: an `if:` reads a - # missing key as false, so a leg that simply omitted one would quietly stop publishing - # and still go green. `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. A new leg owes all four -- - # and a `publish: true` leg owes a fifth thing in another file: an entry in the asset - # set release.yml asserts before it publishes, which otherwise fails the release with a - # diff rather than a reason. A publishing *macOS* leg owes two entries there, its - # tarball and the installer .pkg built further down. + # `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. matrix: include: - name: linux-x86_64 runner: ubuntu-24.04 + arm32: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -61,9 +64,11 @@ jobs: runtime_packages: 'libasound2t64 libportaudio2 libpulse0 libpipewire-0.3-0 libavahi-compat-libdnssd1' publish: true avahi: true + systemd: true - name: linux-arm64 runner: ubuntu-24.04-arm + arm32: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -73,6 +78,39 @@ jobs: runtime_packages: 'libasound2t64 libportaudio2 libpulse0 libpipewire-0.3-0 libavahi-compat-libdnssd1' publish: true 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 + # 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. + # + # `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, + # report mDNS as found, link nothing usable, and satisfy the looser pattern. + # + # `runtime_packages` names Raspberry Pi OS trixie's spellings, not this runner's. + # Debian renamed both of the ones that moved in its 64-bit time_t transition, so + # `libasound2` and `libpipewire-0.3-0` are bookworm's names and wrong for what ships + # today -- and this string is copied verbatim into the BUILD-INFO.txt inside the + # archive, so it is read by someone on the target rather than by someone here. + - name: linux-armv7 + runner: ubuntu-24.04 + arm32: 'armv7' + 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: 'libasound2t64 libportaudio2 libpulse0 libpipewire-0.3-0t64 libavahi-compat-libdnssd1' + 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 @@ -80,6 +118,7 @@ jobs: # PipeWire for macOS at all. CoreAudio through PortAudio is the macOS path either way. - name: macos-arm64 runner: macos-14 + arm32: '' with_mdns: 'ON' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -89,6 +128,7 @@ jobs: runtime_packages: 'portaudio (brew). Bonjour is part of macOS.' publish: true avahi: false + systemd: false # Compile coverage only, and the reason it is a leg of its own: turning mDNS off # swaps which translation unit is built -- src/mdns_null.cpp instead of @@ -102,6 +142,7 @@ jobs: # for `pulse:` names the alsa: route as well as the CMake flag. - name: linux-x86_64-nomdns runner: ubuntu-24.04 + arm32: '' with_mdns: 'OFF' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -109,6 +150,7 @@ jobs: expect_mdns: '^-- sendspin-cli mDNS: none$' publish: false avahi: false + systemd: true steps: # Every third-party action here is pinned to a commit rather than a tag, because a tag @@ -118,7 +160,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install build dependencies (Linux) - if: runner.os == 'Linux' + if: runner.os == 'Linux' && !matrix.arm32 run: | sudo apt-get update # pkg-config is named even though the image already has it: it is what finds @@ -132,6 +174,111 @@ jobs: libpipewire-0.3-dev \ libavahi-compat-libdnssd-dev + # In place of the step above rather than beside it, and that is the point: with none of + # 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 + run: | + sudo dpkg --add-architecture armhf + + # Both read off the runner rather than written down, because nothing else here binds + # them to it. A leg moved to a newer image would otherwise keep asking for this + # release's armhf packages and link them with the newer image's cross toolchain, and + # go green doing it -- the same failure the `pipewire-minimum` job's "Assert this + # image still ships the minimum" step exists to refuse. + host_arch="$(dpkg --print-architecture)" + . /etc/os-release + [ -n "${VERSION_CODENAME:-}" ] || { + echo '::error::/etc/os-release names no VERSION_CODENAME, so there is no suite to point the armhf source at' + exit 1 + } + echo "Cross-building on $host_arch/$VERSION_CODENAME." + + # armhf is a *port*: archive.ubuntu.com carries amd64 and i386 and nothing else. So + # every source already on this image is pinned to the host architecture -- the + # third-party ones included, which would otherwise 404 on an armhf index nobody asked + # them for and fail the update before a single :armhf package is looked at -- and one + # ports.ubuntu.com source is added carrying armhf alone. + sources=/etc/apt/sources.list.d/ubuntu.sources + [ -f "$sources" ] || { + echo "::error::this image has no $sources; its apt layout has changed and the armhf constraint below no longer lands anywhere" + exit 1 + } + + # `Types:` opens every deb822 stanza, so that is where the constraint goes. A file + # that already names its architectures is left alone rather than given a second, + # contradictory line. + for f in /etc/apt/sources.list.d/*.sources; do + [ -e "$f" ] || continue + grep -q '^Architectures:' "$f" || + sudo sed -i "/^Types:/a Architectures: $host_arch" "$f" + done + + # The one-line format, which third-party repositories on this image still use. Both + # substitutions skip a line that already names an architecture and nothing else, which + # is the same question the loop above asks: an entry carrying `signed-by=` alone is + # unconstrained despite having a bracket, and skipping it on the strength of the + # bracket is how this whole step would come to pass while doing half its job. + for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list; do + [ -e "$f" ] || continue + sudo sed -i -E \ + -e "/^deb(-src)? .*arch=/! s|^(deb(-src)?) \[|\1 [arch=$host_arch |" \ + -e "/^deb(-src)? .*arch=/! s|^(deb(-src)?) ([^[])|\1 [arch=$host_arch] \3|" \ + "$f" + done + + sudo tee /etc/apt/sources.list.d/ubuntu-ports-armhf.sources >/dev/null <binfmt-probe.c + arm-linux-gnueabihf-gcc -o binfmt-probe binfmt-probe.c + ./binfmt-probe || { + echo '::error::this kernel will not execute a 32-bit ARM binary -- qemu-arm is not registered, so nothing below can run what it builds' + systemctl status systemd-binfmt --no-pager --full || true + ls /proc/sys/fs/binfmt_misc || true + exit 1 + } + 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 @@ -161,15 +308,26 @@ jobs: WITH_PULSE: ${{ matrix.with_pulse }} WITH_PIPEWIRE: ${{ matrix.with_pipewire }} DEPS_DIR: ${{ github.workspace }}/.deps + ARM32: ${{ matrix.arm32 }} run: | - cmake -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DSENDSPIN_CLI_WERROR=ON \ - -DSENDSPIN_CLI_WITH_MDNS="$WITH_MDNS" \ - -DSENDSPIN_CLI_WITH_PULSE="$WITH_PULSE" \ - -DSENDSPIN_CLI_WITH_PIPEWIRE="$WITH_PIPEWIRE" \ - -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" \ - 2>&1 | tee configure.log + # Held in one array rather than written out on both sides 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. + options=( + -DCMAKE_BUILD_TYPE=Release + -DSENDSPIN_CLI_WERROR=ON + -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 + scripts/build_arm32.sh "$ARM32" build "${options[@]}" 2>&1 | tee configure.log + else + cmake -B build "${options[@]}" 2>&1 | tee configure.log + fi # The check that stops a leg passing while quietly building something else. A missing # -dev package does not fail the configure -- every backend here is optional and @@ -212,6 +370,82 @@ jobs: - name: Build run: cmake --build build + # What makes this leg's name 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 + # reached sendspin-cli and missed a dependency still links, still passes the suite under + # an emulator permissive enough to run anything, and then traps on the hardware the + # 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. + # + # 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. + # + # Ahead of the suite rather than after it: a binary of the wrong architecture is a fact + # about the build, and reading it off costs a `readelf` rather than the whole suite run + # under an emulator. + - name: Assert the binary is built for the architecture this leg claims + if: matrix.arm32 + env: + ARM32: ${{ matrix.arm32 }} + run: | + binary=build/sendspin-cli + + case "$ARM32" in + armv7) want_arch='v7' ;; + *) + echo "::error::no expected CPU architecture is recorded here for '$ARM32'" + exit 1 + ;; + esac + + # Read into files rather than piped into each check, so that `grep -q` matching early + # cannot take the writer down with SIGPIPE -- `defaults.run.shell` is `bash -eo + # pipefail`, where that reads as a failed pipeline. The same reason the systemd step + # below uses `case` throughout. + readelf -h "$binary" >elf-header.txt + readelf -A "$binary" >elf-attributes.txt + echo 'What the linked binary reports:' + file "$binary" + cat elf-header.txt elf-attributes.txt + + field() { + sed -n "/^[[:space:]]*$1:/ { s/^[[:space:]]*$1:[[:space:]]*//p; q; }" "$2" + } + + status=0 + expect() { + [ "$2" = "$3" ] || { + echo "::error::$1 is '$2', not '$3'" + status=1 + } + } + + expect 'the ELF class' "$(field Class elf-header.txt)" 'ELF32' + expect 'the ELF machine' "$(field Machine elf-header.txt)" 'ARM' + expect 'Tag_CPU_arch' "$(field Tag_CPU_arch elf-attributes.txt)" "$want_arch" + # The calling convention, taken from the attribute that records it rather than from + # the -mfloat-abi this build passed. A soft-float object and a hard-float one disagree + # about how every function taking a double is called, and nothing refuses the mixture + # at load time -- it is the arithmetic that comes out wrong. + expect 'Tag_ABI_VFP_args' "$(field Tag_ABI_VFP_args elf-attributes.txt)" 'VFP registers' + + # Substring-matched, alone among these: the flags word is printed alongside its + # numeric value, which carries bits that have nothing to do with the ABI being named. + flags="$(field Flags elf-header.txt)" + case "$flags" in + *'hard-float ABI'*) ;; + *) + echo "::error::the ELF header flags are '$flags', which do not declare the hard-float EABI" + status=1 + ;; + esac + + rm -f elf-header.txt elf-attributes.txt + exit "$status" + # Run on every leg, the no-mDNS one included: the suite links sendspin-cli-core, so # discovery_test.cpp is compiled against whichever MdnsService went in. That # configuration has no other coverage in this matrix. @@ -343,6 +577,7 @@ jobs: env: RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} LEG: ${{ matrix.name }} + ARM32: ${{ matrix.arm32 }} run: | reported="$(./build/sendspin-cli --version)" version="$(printf '%s\n' "$reported" | awk 'NR == 1 { print $2 }')" @@ -416,6 +651,27 @@ jobs: LINUX fi + # Said inside the archive, because the lines above it are read on the machine this was + # built *for* while naming the machine it was built *on*: "Built from commit ... on + # Linux/X64", under a heading that says linux-armv7, is accurate and baffling at once + # until something accounts for it. + if [ -n "$ARM32" ]; then + cat >>"stage/$name/BUILD-INFO.txt" <>"$GITHUB_OUTPUT" # Carried out of this step rather than derived again by the installer step below: the @@ -493,8 +749,16 @@ jobs: # `verify` is only a parse, so the step below runs the thing as well. What it cannot cover # is the audio path: a runner has no sound card, which is why `output = null` goes in the # config and why the unit ships no directive that gates /dev/snd. + # + # Gated on `systemd` rather than on `runner.os == 'Linux'`, which is what a cross leg + # would also satisfy while having nothing to offer here. Running the unit against a + # binary the runner can only reach through an emulator tests the emulator: qemu-user + # translates guest code into buffers it writes and then executes, which the unit's + # `MemoryDenyWriteExecute=yes` forbids outright, so the leg would fail on a directive the + # player itself has no quarrel with. The unit in the arm32 archive is the same file these + # legs verify, installed by the same rules. - name: Install at the real prefix and verify the systemd unit - if: runner.os == 'Linux' + if: matrix.systemd run: | sudo cmake --install build --component sendspin-cli unit=/usr/local/lib/systemd/system/sendspin-cli.service @@ -516,7 +780,7 @@ jobs: # reading the fragment the install rules just put in /usr/local/lib/sysusers.d -- so this # covers the search path holding for /usr/local as well as the fragment's own content. - name: Run the unit as its own user, with the hardening block - if: runner.os == 'Linux' + if: matrix.systemd env: AVAHI: ${{ matrix.avahi }} run: | @@ -773,16 +1037,18 @@ jobs: retention-days: 14 if-no-files-found: error - # A job of its own rather than a fifth matrix leg. The matrix is built for hosted runners -- + # A job of its own rather than a sixth matrix leg. The matrix is built for hosted runners -- # its steps use `sudo apt-get` and Ubuntu's package names, and every leg owes `publish`, - # `avahi` and `runtime_packages` keys that mean nothing in a container. This has one question - # to answer and no binary to hand anybody, so it stays clear of those invariants entirely. + # `avahi`, `systemd`, `arm32` and `runtime_packages` keys that mean nothing in a container. + # This has one question to answer and no binary to hand anybody, so it stays clear of those + # invariants entirely. pipewire-minimum: name: linux-x86_64-pipewire-min runs-on: ubuntu-24.04 # Debian 12 is the whole reason this exists: it ships libpipewire 0.3.65, the oldest release # that satisfies SENDSPIN_CLI_PIPEWIRE_MIN_VERSION, and it is what Raspberry Pi OS bookworm - # is. Every hosted image carries 1.x, where a pw_core_events member added after 0.3.65 + # is -- which is the floor still in the field rather than the current release, Raspberry Pi + # OS having moved to trixie. Every hosted image carries 1.x, where a pw_core_events member added after 0.3.65 # initialises cleanly -- so with only those legs the floor the README advertises goes # untested until somebody on bookworm tries to build and finds a designated initialiser # naming a member their header does not declare. That is a compile error, not a warning. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f3ae00..a4c7999 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,9 +47,10 @@ 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 both scripts being run -- smoke_test.sh on every publishing leg, - # 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 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. shellcheck: name: shellcheck runs-on: ubuntu-24.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2a5aa3..3b9fe54 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 macos-arm64; do + for leg in linux-x86_64 linux-arm64 linux-armv7 macos-arm64; do echo "sendspin-cli-$VERSION-$leg.tar.gz" done echo "sendspin-cli-$VERSION-macos-arm64.pkg" @@ -204,9 +204,14 @@ jobs: # Heredoc quoted, so the only interpolation is the one done explicitly below: this # text is full of backticks and $ that belong to the reader, not to the shell. cat >notes.md <<'NOTES' - Binaries for Linux (x86-64 and arm64) and macOS (Apple silicon), plus a macOS - installer `.pkg`, built and published by `.github/workflows/release.yml` from the tag - alone. + Binaries for Linux (x86-64, arm64 and 32-bit ARM) and macOS (Apple silicon), plus a + 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. ## Install @@ -269,7 +274,7 @@ jobs: ## Checksums - `SHA256SUMS` covers the four files above. It does not cover the `Source code` + `SHA256SUMS` covers the five files above. It does not cover the `Source code` archives GitHub attaches on its own, which are generated by GitHub rather than built here. @@ -279,7 +284,7 @@ jobs: ``` Run that from the directory you downloaded into. `--ignore-missing` is there because - `SHA256SUMS` lists all four and you have almost certainly taken one: without it, the + `SHA256SUMS` lists all five 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. @@ -382,7 +387,7 @@ jobs: expected=$(mktemp) { echo SHA256SUMS - for leg in linux-x86_64 linux-arm64 macos-arm64; do + for leg in linux-x86_64 linux-arm64 linux-armv7 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 fa2ab6b..1d9e72e 100644 --- a/README.md +++ b/README.md @@ -1326,13 +1326,21 @@ 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`, plus a fourth leg 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. +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 +`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. + +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. +`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 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. @@ -1362,9 +1370,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 three +Pushing a `vMAJOR.MINOR.PATCH` tag builds the same matrix and publishes the four platform archives and the macOS installer `.pkg`, plus a `SHA256SUMS` covering all -four, as a GitHub Release. The workflow triggers on `v*` but refuses anything else +five, 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 @@ -1382,9 +1390,9 @@ sha256sum --ignore-missing -c SHA256SUMS # Linux shasum -a 256 --ignore-missing -c SHA256SUMS # macOS ``` -`--ignore-missing` because `SHA256SUMS` lists all four and you have almost +`--ignore-missing` because `SHA256SUMS` lists all five 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 four things built +exits non-zero on a file that is fine. Those checksums cover the five 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 098ebe0..cc0fa2b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1175,7 +1175,7 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t publishing nothing* — `Upload` is `if: matrix.publish`, and `if-no-files-found: error` catches an empty upload rather than an absent one, while `download-artifact` matching nothing is an empty directory rather than an error. So the release job names the assets it expects and - diffs the set, in the shape the payload assertions already use — three tarballs and, since the + diffs the set, in the shape the payload assertions already use — four tarballs and, since the third slice below, the macOS `.pkg`. Only the set: each archive's own file list was diffed twice inside the build, and re-opening them here would be the duplication this split exists to avoid. @@ -1215,10 +1215,10 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t - **`SHA256SUMS` is generated from inside the directory** so its entries are bare filenames: `sha256sum -c` resolves paths relative to the working directory, and a file naming `dist/…` is one a downloader cannot use without knowing that. The notes give the macOS spelling - (`shasum -a 256 -c`) beside the Linux one, two of the four assets being macOS-only, and + (`shasum -a 256 -c`) beside the Linux one, two of the five assets being macOS-only, and say out loud that the checksums do not cover the `Source code` archives GitHub attaches on its own. Both are given with `--ignore-missing`, which is not a detail: `SHA256SUMS` lists - all four and a reader has almost certainly taken one, so the bare form reports the + all five and a reader has almost certainly taken one, so the bare form reports the rest as failures and exits non-zero on a file that is perfectly good. Supported by GNU coreutils and by macOS's Perl `shasum` alike, both checked rather than assumed. - **The notes are written by hand, not `--generate-notes`.** On a first tag that emits every @@ -1493,10 +1493,11 @@ 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; sink contract still owed)* +### 12. CI and tests — *shipped (matrix and smoke test; ARMv6 and 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`, plus a fourth `ubuntu-24.04` leg configured +`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: @@ -1504,7 +1505,7 @@ configures `-DSENDSPIN_CLI_WERROR=ON` and runs the CTest suite, the no-mDNS leg 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 three platform legs additionally run the smoke +on a null-sink-only, mDNS-less binary. The four 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 @@ -1557,11 +1558,31 @@ macOS. A leg that configures `-DCMAKE_CXX_FLAGS=-fsanitize=thread` and runs reasoning into a check. It is cheap: the smoke test already drives the whole boot, socket and shutdown path, and produced zero reports when run that way by hand. -The matrix also has no armv7 or 32-bit Pi leg, and 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 describe is gone: item 10 shipped the `install()` rules, and the archive is now a +**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 +`-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. + +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 +describe is gone: item 10 shipped the `install()` rules, and the archive is now a `DESTDIR`-staged `cmake --install` payload whose file list this workflow asserts — plus, on the -Linux legs, a `systemd-analyze verify` of the unit it installs. +legs that run on the architecture they built for, a `systemd-analyze verify` of the unit it +installs. ### 13. `PlayerRoleConfig` wiring — *shipped* diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index a27af97..bd52cda 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -4,7 +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 64-bit Linux host (`x86_64` or `arm64`), systemd, a sound card, and root. +**You need:** a Linux host (`x86_64`, `arm64`, or `armv7` on a 32-bit Raspberry Pi OS), +systemd, a sound card, and root. ## The short way @@ -38,8 +39,9 @@ instead of the newest. ### What it actually does -1. **Checks the architecture.** `x86_64` and `aarch64` have builds. 32-bit ARM does not, and - is refused with the reason and the fix rather than an "unsupported" shrug. +1. **Checks the architecture.** `x86_64`, `aarch64` and `armv7l` 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. 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 39f263f..5a01d41 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -2,45 +2,42 @@ A Pi makes an excellent Sendspin endpoint: it is quiet, it is cheap, and one per room is the whole point of a synchronized multi-room protocol. Installing on one is -[Getting Started on Linux](Getting-Started-on-Linux) — a Pi is an arm64 Linux box and takes -the same `linux-arm64` archive an arm64 server does — plus the five things on this page. +[Getting Started on Linux](Getting-Started-on-Linux) — a Pi is an ordinary Linux box and takes +whichever archive its architecture names — plus the five things on this page. -## 1. You need a 64-bit OS. This is not negotiable +## 1. Check which build your OS wants -**The builds are `arm64` only. There is no 32-bit ARM build, and none is coming from CI.** -The matrix has no armv7 or 32-bit Pi leg, which is recorded in -[`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md), -item 12. - -Check what you are running before anything else: +Two archives serve a Pi, and `uname -m` is what picks between them: ```console $ uname -m aarch64 ``` -| It says | Where you are | +| It says | What installs | |---|---| -| `aarch64` | Good. Carry on. | -| `armv7l`, `armv6l` | A 32-bit userland. No archive will install. | +| `aarch64` | `linux-arm64`, the 64-bit build. | +| `armv7l` | `linux-armv7`, the 32-bit build. | +| `armv6l` | Nothing. See below. | -A 32-bit answer on 64-bit hardware is the common case — Raspberry Pi OS shipped a 32-bit -userland by default for years, and plenty of installed cards still run it. The fix is to -reimage with **Raspberry Pi OS (64-bit)**, or *Raspberry Pi OS Lite (64-bit)* for a headless -player, which is what a Sendspin endpoint wants anyway. In Raspberry Pi Imager the 64-bit -builds are under **Raspberry Pi OS (other)**. +Either archive 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 +Pi OS (other)**. | Model | 64-bit capable | |---|---| | 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** — build from source, or use other hardware | - -If your board is not on either row, do not go looking it up: install a 64-bit image and run -`uname -m`. That answer is the only one that decides anything here. - -The getting-started script refuses a 32-bit userland with this whole answer rather than an -"unsupported architecture", because it is the single most common way a Pi install goes -wrong. +| 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. ## 2. Install diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 9e8cf53..4ec19b0 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -51,16 +51,17 @@ $ sendspin-cli vol 40 | Platform | Architecture | How | |---|---|---| -| Linux | `x86_64`, `arm64` | Release tarball, or `scripts/get_started_linux.sh` | +| Linux | `x86_64`, `arm64`, `armv7` | Release tarball, or `scripts/get_started_linux.sh` | | macOS | Apple silicon (`arm64`) | Release tarball or installer `.pkg` | -| Raspberry Pi | `arm64` only — **a 64-bit OS is required** | The Linux tarball, same as any arm64 host | +| Raspberry Pi | `arm64`, or `armv7` 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 32-bit ARM build and no Intel-Mac build. The CI matrix has no armv7, 32-bit Pi -or macOS `x86_64` leg, which is recorded in +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 [`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 082bb60..046d1f7 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` or `linux-arm64` tarball | +| A Linux box or a Raspberry Pi | The `linux-x86_64`, `linux-arm64` or `linux-armv7` tarball | | An Apple-silicon Mac | The `macos-arm64` installer `.pkg`, or the tarball | | Anything else | [Build from source](#build-from-source) | @@ -41,7 +41,11 @@ nothing. Read `BUILD-INFO.txt` first; it names the runtime packages that build n ```bash # 1. Take the archive for this machine's architecture, and the checksums VERSION=0.1.0 -ARCH=$(uname -m); [ "$ARCH" = aarch64 ] && LEG=linux-arm64 || LEG=linux-x86_64 +case "$(uname -m)" in + aarch64) LEG=linux-arm64 ;; + armv7l) LEG=linux-armv7 ;; + *) LEG=linux-x86_64 ;; +esac BASE=https://github.com/Sendspin/sendspin-cpp-cli/releases/download/v$VERSION curl -fLO "$BASE/sendspin-cli-$VERSION-$LEG.tar.gz" curl -fLO "$BASE/SHA256SUMS" @@ -63,7 +67,7 @@ the other. It is idempotent, and without it the unit reports `217/USER` rather t See [Running as a Service](Running-as-a-Service#it-runs-as-its-own-account). `--ignore-missing` because `SHA256SUMS` covers every archive the release carries and you -have taken one of them; without it the other three are reported as failures. It is not a +have taken one of them; without it the other four are reported as failures. It is not a way of passing with nothing checked — `sha256sum` still exits non-zero if the flag leaves it with no file to verify. @@ -129,14 +133,15 @@ launch agent you write. ## Raspberry Pi -The Pi takes the `linux-arm64` archive like any other arm64 Linux host, with one hard -requirement: **a 64-bit OS**. See +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 [Getting Started on a Raspberry Pi](Getting-Started-on-a-Raspberry-Pi). ## Build from source -For an architecture with no release — 32-bit ARM, an Intel Mac, anything not in the matrix -— or to build against a different version of the library. +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. ```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 305c86a..fba4a10 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -306,6 +306,26 @@ raised to the floor and says so at `debug`. See If it is one speaker out of sync with the others rather than dropping out, that is `delay`, not `buffer-ms` — see [Controlling the Player](Controlling-the-Player). +## Raspberry Pi: it will not start at all + +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. + +**`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. + +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 +`BUILD-INFO.txt` are trixie's. + ## macOS: "cannot be opened because the developer cannot be verified" Clear the quarantine flag: diff --git a/packaging/sendspin-cli.service.in b/packaging/sendspin-cli.service.in index f96f61e..fc94d36 100644 --- a/packaging/sendspin-cli.service.in +++ b/packaging/sendspin-cli.service.in @@ -125,7 +125,8 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # @system-service covers every syscall libasound imports, ioctl, mmap, mlock and the SysV IPC # calls dmix uses included -- checked against the shipped library's own import table, so the -# audio path is not what this is gambling on. Native ABI only, since nothing here is 32-bit. +# audio path is not what this is gambling on. Native ABI only: every archive installs a player +# built for the machine it runs on, 32-bit and 64-bit alike, so there is no second ABI to want. SystemCallArchitectures=native SystemCallFilter=@system-service diff --git a/scripts/build_arm32.sh b/scripts/build_arm32.sh new file mode 100755 index 0000000..ffd8402 --- /dev/null +++ b/scripts/build_arm32.sh @@ -0,0 +1,158 @@ +#!/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. +# +# Configures a 32-bit ARM cross build of sendspin-cli against the host's armhf multiarch tree, +# for the linux-armv7 leg of .github/workflows/build.yml. It configures only: `cmake --build` +# over the directory it writes is an ordinary build with no cross-specific argument to +# remember, so there is nothing here for a second entry point to own. +# +# A script rather than lines of YAML for the reason scripts/build_macos_pkg.sh is one: the +# archive this leg publishes is the only build of this project most 32-bit Raspberry Pi owners +# will ever run, and a developer has to be able to reproduce it without a runner. It is also +# what puts the cross flags under ci.yml's `shellcheck scripts/*.sh` job. +# +# The target is an argument rather than a CMake toolchain file per architecture, so that a +# second 32-bit target is a case label here instead of a second file free to drift from this +# one. +# +# What this needs on the host, which .github/workflows/build.yml installs: +# +# dpkg --add-architecture armhf, with an apt source that carries it -- armhf is a port, so +# archive.ubuntu.com does not +# crossbuild-essential-armhf, for the arm-linux-gnueabihf compilers +# the :armhf build dependencies, so there is something for ALSA, PortAudio, PulseAudio, +# PipeWire and dns_sd to be found in +# qemu-user-static with binfmt registration, because gtest_discover_tests runs the test +# binary at build time to enumerate its cases +# +# 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 +# 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 + +set -euo pipefail + +fail() { + printf 'build_arm32: FAIL: %s\n' "$*" >&2 + exit 1 +} + +[ "$(uname -s)" = 'Linux' ] || + fail 'Linux only -- this cross-compiles against the host distribution armhf multiarch tree' + +[ "$#" -ge 2 ] || + fail "usage: $0 [cmake option ...]" + +TARGET=$1 +BUILD_DIR=$2 +shift 2 +readonly TARGET BUILD_DIR + +readonly TRIPLE='arm-linux-gnueabihf' + +case "$TARGET" in + armv7) + # A Pi 2, a Pi 3, a Pi 4 or a Pi Zero 2 running a 32-bit userland. -mfloat-abi is spelled + # out even though the triplet above implies it, because build.yml asserts the hard-float + # EABI off the finished binary and an assertion is worth more against a declared fact + # than against an implied one. NEON and VFPv4 are the Pi's, not Debian armhf's baseline + # of vfpv3-d16 -- every machine this archive is for has both. + ARCH_FLAGS=(-march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard) + ;; + armv6) + # Refused rather than quietly built, because what comes out is not what it says. Debian + # and Ubuntu armhf are an armv7-a port, and that is where this toolchain's own + # crt1.o, crtbegin.o and every member of libgcc.a come from -- all of them armv7, all of + # 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. + 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" + ;; + *) + fail "unknown target '$TARGET' -- this builds armv7" + ;; +esac +readonly -a ARCH_FLAGS + +for tool in "$TRIPLE-gcc" "$TRIPLE-g++" cmake; do + command -v "$tool" >/dev/null 2>&1 || + fail "'$tool' is not on \$PATH -- install crossbuild-essential-armhf and cmake" +done + +# The multiarch library directory, which is the whole sysroot this build has: the :armhf +# packages put their libraries here and their headers in the shared /usr/include, and Debian's +# cross compilers are configured to look in both. Checked rather than assumed, because its +# absence is what an unenabled `dpkg --add-architecture armhf` looks like from here -- and the +# configure that followed would find no backend at all and still succeed. +readonly ARMHF_LIBDIR="/usr/lib/$TRIPLE" +[ -d "$ARMHF_LIBDIR" ] || + fail "no $ARMHF_LIBDIR -- enable armhf multiarch and install the :armhf build dependencies: + sudo dpkg --add-architecture armhf" + +# Nothing this script runs needs an emulator; `cmake --build` over the directory it writes +# does, because gtest_discover_tests executes the freshly built test binary to enumerate its +# cases. Proven here, where the message can name the cause, rather than left to surface as a +# build failure two commands later. +PROBE_DIR="$(mktemp -d)" +readonly PROBE_DIR +trap 'rm -rf "$PROBE_DIR"' EXIT +printf 'int main(void) { return 0; }\n' >"$PROBE_DIR/probe.c" +"$TRIPLE-gcc" "${ARCH_FLAGS[@]}" -o "$PROBE_DIR/probe" "$PROBE_DIR/probe.c" +"$PROBE_DIR/probe" || + fail "this host will not execute a 32-bit ARM binary. Install qemu-user-static and register + its handlers -- 'sudo systemctl restart systemd-binfmt' on a systemd host -- or the build + over $BUILD_DIR will fail where gtest_discover_tests runs the test binary" + +# LIBDIR rather than PATH, because LIBDIR *replaces* pkg-config's default search path where PATH +# only prepends to it. This is what stops a host .pc file answering for PortAudio, PulseAudio or +# PipeWire and handing the link line a library of the wrong architecture. /usr/share/pkgconfig +# stays, holding the .pc files that are architecture-independent by definition. +export PKG_CONFIG_LIBDIR="$ARMHF_LIBDIR/pkgconfig:/usr/share/pkgconfig" + +# The flags go in CMAKE_C_FLAGS/CMAKE_CXX_FLAGS, which reach every target in the build, rather +# than onto sendspin-cli's own targets the way CMakeLists.txt scopes its warning flags. The +# asymmetry is deliberate: warnings are a standard held over the code that is ours to keep +# clean, while the instruction set is a property of the machine that every object in the link +# has to agree on. Each dependency here arrives through FetchContent and so through +# add_subdirectory, in the one cmake invocation, and a flag scoped to our targets alone would +# leave the decoders and ixwebsocket compiling for whatever the compiler's default is -- and +# leave a probe like micro_opus's for what the CPU can do reading the wrong answer. build.yml +# reads the merged build attributes back off the linked binary, which is what turns this from a +# claim into a check. +# +# CMAKE_LIBRARY_ARCHITECTURE is what points find_package(ALSA), find_library(dns_sd) and +# find_path(dns_sd.h) at the armhf tree. Without it CMake searches the host's own multiarch +# directory, and a find_library that answers with an x86_64 libdns_sd.so configures cleanly, +# reports mDNS as found, and links nothing usable. build.yml pins its expect_mdns assertion to a +# path under this directory for exactly that reason. +cmake -B "$BUILD_DIR" \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=arm \ + -DCMAKE_C_COMPILER="$TRIPLE-gcc" \ + -DCMAKE_CXX_COMPILER="$TRIPLE-g++" \ + -DCMAKE_C_FLAGS="${ARCH_FLAGS[*]}" \ + -DCMAKE_CXX_FLAGS="${ARCH_FLAGS[*]}" \ + -DCMAKE_LIBRARY_ARCHITECTURE="$TRIPLE" \ + "$@" + +printf 'build_arm32: configured %s for %s (%s)\n' "$BUILD_DIR" "$TARGET" "${ARCH_FLAGS[*]}" diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 901ef2a..f28a92f 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -16,9 +16,10 @@ # # Installs a released sendspin-cli on a Linux host and sets its systemd unit up. # -# One script for every Linux host, a Raspberry Pi included, because a Pi *is* an arm64 -# Linux box here: it takes the same `linux-arm64` archive an arm64 server takes and -# installs it the same way. What is genuinely Pi-specific is advice -- the `audio` group, +# 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, # 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. @@ -50,11 +51,12 @@ # 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 two are -# also *run* on every build -- smoke_test.sh on each publishing leg, build_macos_pkg.sh on the -# macOS one. This is the first 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. +# itself: the `shellcheck` job there lints every script under scripts/, and the other three 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. # # Usage: scripts/get_started_linux.sh [--version ] [--yes] # @@ -146,7 +148,7 @@ for tool in tar sed grep; do done # The release archives are named for the CI leg that built them rather than for `uname -m`, -# so both spellings of 64-bit ARM map onto the one leg that exists. +# so every spelling of an architecture maps onto the one leg that serves it. MACHINE="$(uname -m)" case "$MACHINE" in x86_64 | amd64) @@ -155,21 +157,26 @@ case "$MACHINE" in aarch64 | arm64) LEG='linux-arm64' ;; - armv6l | armv7l | armhf | arm) - # The single most common way a Pi install goes wrong, so it gets the whole answer - # rather than "unsupported architecture". There is no 32-bit build to fall back to -- - # docs/ROADMAP.md item 12 records that the matrix has no armv7 or 32-bit Pi leg -- and - # the fix is a 64-bit OS on hardware that is almost certainly already 64-bit capable. - fail "this is a 32-bit ARM userland ($MACHINE), and the builds are arm64 only. - docs/ROADMAP.md item 12 records why: the CI matrix has no armv7 or 32-bit Pi leg, so no - such archive exists to install. A Raspberry Pi 3 or newer is 64-bit hardware, so the fix - is a 64-bit OS: install Raspberry Pi OS (64-bit), or 'Raspberry Pi OS Lite (64-bit)' for - a headless player, and check with 'uname -m' after -- it must say aarch64. A Pi Zero (the - original), a Pi 1 or a Pi 2 cannot run 64-bit at all, and needs a build from source" + armv7l | armhf | arm) + # `armhf` and a bare `arm` are grouped with `armv7l` rather than left to the catch-all + # because both are 32-bit ARM spellings a kernel can report, and armv7 is the only + # 32-bit archive there is: an ARMv6 machine reports `armv6l` and is caught below. + LEG='linux-armv7' + ;; + armv6l) + # 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. + fail "this is an ARMv6 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" ;; *) - fail "no release is built for '$MACHINE' -- the archives are linux-x86_64 and - linux-arm64. Build from source instead: https://github.com/$REPO#build" + fail "no release is built for '$MACHINE' -- the archives are linux-x86_64, linux-arm64 + and linux-armv7. Build from source instead: https://github.com/$REPO#build" ;; esac readonly MACHINE LEG From 3d7b2af41b6d3bde9fec1e04a74c7ba2454d374b Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 14:01:12 -0500 Subject: [PATCH 2/3] Select the archive by userland, and build to the armhf FPU baseline Two ways the armv7 leg could hand somebody a binary their machine cannot run. `uname -m` names the kernel, not the userland, and on a Raspberry Pi the two routinely disagree: `arm_64bit` defaults to 1 on a Pi 4, a Pi 400 and a CM4, so a 32-bit armhf install on one of those boots a 64-bit kernel and reports `aarch64` with no 64-bit library on the disk. `get_started_linux.sh` chose on that answer and installed `linux-arm64`, whose loader is not there. It now asks the userland -- `dpkg --print-architecture`, falling back to `getconf LONG_BIT` paired with the kernel's family -- and keeps `uname -m` only for the question the userland cannot answer: an ARMv6 board cannot run a 64-bit kernel, so `armv6l` there is the CPU speaking. A soft-float `armel` userland is refused rather than handed a hard-float archive, and an unknown architecture is refused rather than falling through to x86-64. The build targeted the Pi's FPU rather than the ABI's. `armv7l` guarantees neither NEON nor VFPv4 -- a Cortex-A8 or A9 is ARMv7-A with VFPv3 and optional NEON -- while the installer maps every ARMv7 host to this archive and the docs advertise generic Linux `armv7`. `-mfpu` is now the armhf baseline of `vfpv3-d16`, so the archive runs on what its name promises. The decoders are fixed-point, so the baseline costs nothing on the path that matters. Verified: the selection was driven through eleven host shapes, including the Pi 4 32-bit-userland-on-64-bit-kernel case that was broken, armel, i386 and s390x. The rebuilt binary reports Tag_CPU_arch v7, Tag_FP_arch VFPv3-D16 and no Tag_Advanced_SIMD_arch at all, still passes the leg's ELF assertion, and is 426/426 on ctest with the smoke test green under qemu. --- docs/wiki/Getting-Started-on-Linux.md | 8 ++- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 21 ++++-- docs/wiki/Installation.md | 11 +-- scripts/build_arm32.sh | 20 ++++-- scripts/get_started_linux.sh | 72 +++++++++++++------ 5 files changed, 93 insertions(+), 39 deletions(-) diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index bd52cda..101db2b 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -39,9 +39,11 @@ instead of the newest. ### What it actually does -1. **Checks the architecture.** `x86_64`, `aarch64` and `armv7l` 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. +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. 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 5a01d41..6bb8bd2 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -7,18 +7,27 @@ 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 `uname -m` is what picks between them: +Two archives serve a Pi, and it is the **userland** that picks between them — not +`uname -m`, which names the kernel: ```console -$ uname -m -aarch64 +$ dpkg --print-architecture +arm64 ``` | It says | What installs | |---|---| -| `aarch64` | `linux-arm64`, the 64-bit build. | -| `armv7l` | `linux-armv7`, the 32-bit build. | -| `armv6l` | Nothing. See below. | +| `arm64` | `linux-arm64`, the 64-bit build. | +| `armhf` | `linux-armv7`, the 32-bit build — unless the board is ARMv6; 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 +reports `aarch64` — while every library on the disk is armhf. Choosing on that answer +installs the arm64 archive, whose loader is not there, and the binary fails with a "No such +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. Either archive 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 diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 046d1f7..c151c99 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -41,10 +41,13 @@ nothing. Read `BUILD-INFO.txt` first; it names the runtime packages that build n ```bash # 1. Take the archive for this machine's architecture, and the checksums VERSION=0.1.0 -case "$(uname -m)" in - aarch64) LEG=linux-arm64 ;; - armv7l) LEG=linux-armv7 ;; - *) LEG=linux-x86_64 ;; +# The *userland*, not `uname -m`: a 32-bit Raspberry Pi OS on a Pi 4 runs a 64-bit kernel +# and reports aarch64, and the arm64 archive will not load there. +case "$(dpkg --print-architecture)" in + amd64) LEG=linux-x86_64 ;; + arm64) LEG=linux-arm64 ;; + armhf) LEG=linux-armv7 ;; # not on an ARMv6 board -- see Raspberry Pi below + *) echo 'no release is built for this architecture'; exit 1 ;; esac BASE=https://github.com/Sendspin/sendspin-cpp-cli/releases/download/v$VERSION curl -fLO "$BASE/sendspin-cli-$VERSION-$LEG.tar.gz" diff --git a/scripts/build_arm32.sh b/scripts/build_arm32.sh index ffd8402..7171f54 100755 --- a/scripts/build_arm32.sh +++ b/scripts/build_arm32.sh @@ -68,12 +68,20 @@ readonly TRIPLE='arm-linux-gnueabihf' case "$TARGET" in armv7) - # A Pi 2, a Pi 3, a Pi 4 or a Pi Zero 2 running a 32-bit userland. -mfloat-abi is spelled - # out even though the triplet above implies it, because build.yml asserts the hard-float - # EABI off the finished binary and an assertion is worth more against a declared fact - # than against an implied one. NEON and VFPv4 are the Pi's, not Debian armhf's baseline - # of vfpv3-d16 -- every machine this archive is for has both. - ARCH_FLAGS=(-march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard) + # A Pi 2, a Pi 3, a Pi 4 or a Pi Zero 2 running a 32-bit userland -- and any other + # ARMv7-A machine, which is what the archive's name promises and so what these flags + # have to hold to. + # + # -mfpu is the armhf ABI's own baseline rather than the Cortex-A7's NEON and VFPv4. + # `armv7l` says nothing about either: a Cortex-A8 or a Cortex-A9 is ARMv7-A with VFPv3 + # and NEON that is optional, so a binary built for the Pi's FPU would meet an + # instruction those machines do not have. The decoders this links are fixed-point, so + # the baseline costs nothing on the path that matters. + # + # -mfloat-abi is spelled out even though the triplet above implies it, because build.yml + # asserts the hard-float EABI off the finished binary and an assertion is worth more + # against a declared fact than against an implied one. + ARCH_FLAGS=(-march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard) ;; armv6) # Refused rather than quietly built, because what comes out is not what it says. Debian diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index f28a92f..3266cd2 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -147,39 +147,71 @@ for tool in tar sed grep; do fail "'$tool' is not on \$PATH, and this cannot install anything without it" done -# The release archives are named for the CI leg that built them rather than for `uname -m`, -# so every spelling of an architecture maps onto the one leg that serves it. +# `uname -m` names the *kernel*, and on a Raspberry Pi the kernel and the userland routinely +# disagree: `arm_64bit` defaults to 1 on a Pi 4, a Pi 400 and a CM4, so a 32-bit armhf install +# on one of those boots a 64-bit kernel and reports `aarch64` with not a single 64-bit library +# on the disk. Choosing on that answer hands it the arm64 archive, whose loader +# (/lib/ld-linux-aarch64.so.1) is not there -- and the binary dies with a "No such file or +# directory" naming a file that plainly exists. +# +# So the archive is chosen by the *userland*, and `uname -m` is kept for the one question the +# userland cannot answer: which ARM instruction set the CPU has. MACHINE="$(uname -m)" -case "$MACHINE" in - x86_64 | amd64) + +# dpkg answers it outright, and it is on every distribution these archives are built against. +# Where it is absent, the width of a userland binary paired with the kernel's family is the +# same answer arrived at the long way -- `getconf` is part of libc, so one of the two is here. +if command -v dpkg >/dev/null 2>&1; then + USERLAND="$(dpkg --print-architecture)" +elif command -v getconf >/dev/null 2>&1; then + case "$(getconf LONG_BIT):$MACHINE" in + 64:x86_64 | 64:amd64) USERLAND='amd64' ;; + 64:aarch64 | 64:arm64) USERLAND='arm64' ;; + 32:aarch64 | 32:arm64 | 32:arm*) USERLAND='armhf' ;; + # Passed through rather than mapped, so the refusal below can name what was found. + *) USERLAND="$MACHINE" ;; + esac +else + fail "neither dpkg nor getconf is on \$PATH, and one of them is needed to tell a 32-bit + userland from the 64-bit kernel it may be running under" +fi + +# The release archives are named for the CI leg that built them rather than for the userland, +# so every spelling of an architecture maps onto the one leg that serves it. +case "$USERLAND" in + amd64 | x86_64) LEG='linux-x86_64' ;; - aarch64 | arm64) + arm64 | aarch64) LEG='linux-arm64' ;; - armv7l | armhf | arm) - # `armhf` and a bare `arm` are grouped with `armv7l` rather than left to the catch-all - # because both are 32-bit ARM spellings a kernel can report, and armv7 is the only - # 32-bit archive there is: an ARMv6 machine reports `armv6l` and is caught below. - LEG='linux-armv7' - ;; - armv6l) + armhf) + # 32-bit ARM, where which archive to take is a question about the CPU rather than the + # userland -- and this is the one `uname -m` answers well. An ARMv6 board cannot run a + # 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. - fail "this is an ARMv6 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: + 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" + ;; + esac + LEG='linux-armv7' ;; *) - fail "no release is built for '$MACHINE' -- the archives are linux-x86_64, linux-arm64 - and linux-armv7. Build from source instead: https://github.com/$REPO#build" + 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" ;; esac -readonly MACHINE LEG +readonly MACHINE USERLAND LEG # systemd being *booted* rather than merely installed, which is what decides whether there is # anything to enable. A container or a chroot without it still gets the binary. @@ -229,7 +261,7 @@ readonly WORK_DIR trap 'rm -rf "$WORK_DIR"' EXIT say 'sendspin-cli getting started' -say " host: Linux $MACHINE${PI_MODEL:+ ($PI_MODEL)}" +say " host: Linux $MACHINE, $USERLAND userland${PI_MODEL:+ ($PI_MODEL)}" say " release leg: $LEG" if [ "$HAVE_SYSTEMD" = 'yes' ]; then say ' systemd: yes' From 5e8e06f8ceaa5639e4bf7c1c41ba7de7e952e1cf Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 14:49:12 -0500 Subject: [PATCH 3/3] Refuse a userland the fallback cannot identify, and guard the doc snippet Two more ways to reach an archive the machine cannot run. The `getconf` fallback passed `$MACHINE` through when it recognised neither the width nor the kernel, which is the mistake the whole block exists to prevent made a second time: a 32-bit x86 userland under an x86-64 kernel reports `x86_64`, and passing that on as the userland hands it the 64-bit archive. It now leaves the userland unset and refuses, so what cannot be positively identified is never guessed at -- which closes the class rather than the one instance of it. The manual-install snippet in the wiki carried its ARMv6 caveat as a trailing comment, and a comment does not stop a Pi Zero downloading the ARMv7 archive and meeting an illegal instruction. It now runs the same `uname -m` guard the script does, for the same reason the script keeps one: an ARMv6 board reports `armhf` like any other 32-bit ARM machine, and cannot run a 64-bit kernel, so `armv6l` there is the CPU speaking. Verified: the selection was driven through thirteen host shapes, the new x86 case and the ten from the previous commit included, with no regression; and the doc snippet was extracted from the page itself and run against six, refusing the Pi Zero and choosing correctly for the rest. --- docs/wiki/Installation.md | 9 +++++++-- scripts/get_started_linux.sh | 12 ++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index c151c99..f194a16 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -46,8 +46,13 @@ VERSION=0.1.0 case "$(dpkg --print-architecture)" in amd64) LEG=linux-x86_64 ;; arm64) LEG=linux-arm64 ;; - armhf) LEG=linux-armv7 ;; # not on an ARMv6 board -- see Raspberry Pi below - *) echo 'no release is built for this architecture'; exit 1 ;; + 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 ;; + *) echo 'no release is built for this architecture'; exit 1 ;; esac BASE=https://github.com/Sendspin/sendspin-cpp-cli/releases/download/v$VERSION curl -fLO "$BASE/sendspin-cli-$VERSION-$LEG.tar.gz" diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 3266cd2..b1e47ba 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -168,8 +168,11 @@ elif command -v getconf >/dev/null 2>&1; then 64:x86_64 | 64:amd64) USERLAND='amd64' ;; 64:aarch64 | 64:arm64) USERLAND='arm64' ;; 32:aarch64 | 32:arm64 | 32:arm*) USERLAND='armhf' ;; - # Passed through rather than mapped, so the refusal below can name what was found. - *) USERLAND="$MACHINE" ;; + # Left empty rather than filled in from `$MACHINE`, which would be this block's own + # mistake made twice: a 32-bit x86 userland under an x86-64 kernel reports `x86_64`, + # and passing that on hands it the 64-bit archive. What cannot be identified is + # refused below. + *) USERLAND='' ;; esac else fail "neither dpkg nor getconf is on \$PATH, and one of them is needed to tell a 32-bit @@ -206,6 +209,11 @@ case "$USERLAND" in esac LEG='linux-armv7' ;; + '') + fail "this host's userland could not be identified from a $MACHINE kernel alone, and + guessing at it is how a 32-bit userland ends up with a 64-bit binary. Install dpkg, or + build from source: https://github.com/$REPO#build" + ;; *) 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"