From 4d9c8c292ee103fab137d343d15bcd01c79de492 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 18:56:51 -0500 Subject: [PATCH 1/2] Move the ARMv6 leg out of core CI into a workflow of its own linux-armv6 emulates every compile under qemu-user inside a Raspbian container: 23 minutes measured, against the two or three every other leg in the matrix takes. As a matrix leg it ran whenever the matrix ran -- every branch push, and again on every pull request -- so every push waited half an hour on the one leg that almost never had anything new to say. It is now .github/workflows/build-armv6.yml, one container-only job carrying its own triggers: workflow_call so release.yml can call it, push on main, a pull_request filtered to the workflow itself, scripts/build_armv6_container.sh and CMakeLists.txt, and workflow_dispatch. No unfiltered branch push, which is the cost being removed. Its concurrency guard is conditioned on the ref rather than on the workflow name, so it cannot cancel a run release.yml started whatever github.workflow resolves to inside a called workflow. Every assertion the matrix leg carried survives: QEMU_CPU=arm1176 throughout, the configure-output expectations, Tag_CPU_arch v6 and the hard-float EABI read off the linked binary, the GLIBC_2.36 bookworm ceiling, the EMULATED BUILD-INFO.txt block, the payload diff against both the staged tree and the tarball, and if: always() on the container stop. build.yml loses the leg and everything only it used: the container matrix key, the emulator registration and container start/stop steps, and every [ -n "$IMAGE" ] branch. Its matrix timeout comes down from 40 to 20, that 40 having been sized for ARMv6 alone. release.yml calls both workflows and its release job needs both, so a red ARMv6 build fails the release rather than publishing without its archive. The two expected-asset lists are untouched and still name sendspin-cli-$VERSION-linux-armv6.tar.gz. The trade this buys -- a tag builds one thing a push does not -- is written down rather than left to be discovered: in both workflows' comments, in README's CI section, and in ROADMAP item 12. --- .github/workflows/build-armv6.yml | 535 ++++++++++++++++++++++++++++++ .github/workflows/build.yml | 314 +++--------------- .github/workflows/ci.yml | 23 +- .github/workflows/release.yml | 43 ++- README.md | 53 ++- docs/ROADMAP.md | 41 ++- docs/wiki/Installation.md | 6 +- scripts/build_arm32.sh | 7 +- scripts/build_armv6_container.sh | 21 +- 9 files changed, 720 insertions(+), 323 deletions(-) create mode 100644 .github/workflows/build-armv6.yml diff --git a/.github/workflows/build-armv6.yml b/.github/workflows/build-armv6.yml new file mode 100644 index 0000000..1edb0a1 --- /dev/null +++ b/.github/workflows/build-armv6.yml @@ -0,0 +1,535 @@ +name: Build ARMv6 + +# The ARMv6 Raspberry Pi archive -- a Pi Zero, a Pi Zero W, an original Pi -- built the only way +# there is to build one, and in a file of its own because of what that costs. 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 route to an ARMv6 archive is an +# ordinary native build run inside an emulated Raspbian container -- which pays for every compile +# in the FetchContent tree under qemu-user. That measures 23 minutes against the two or three +# every leg in build.yml takes. +# +# Its own file rather than a sixth leg of that matrix, because a matrix leg runs whenever the +# matrix does -- on every branch push and again on every pull request, so twice per in-repo PR. +# That is half an hour of waiting, per push, on the one build that almost never has anything new +# to say. Its own file, too, rather than a leg left in the matrix behind `continue-on-error`: what +# it proves is real, and a leg that gates nothing stops meaning anything. +# +# So it carries its own triggers below, and release.yml calls it alongside build.yml -- which is +# what leaves a tag gated on a green ARMv6 build. +# +# The seam that opens is worth naming rather than leaving to be discovered: a tag builds one thing +# a push does not. What stands in for per-push coverage is the trigger set -- `main` is covered +# within half an hour of every merge, the path filter below catches an infrastructure change, and +# `workflow_dispatch` opts a branch in on demand. So a break here can delay a tag. It cannot ship +# an archive nothing built. +on: + # release.yml, on a tag. + workflow_call: + + # Post-merge rather than per-push, that per-push cost being the whole of what this file removes. + # Naming only `branches` is also what leaves a tag to release.yml: a `push` carrying branch + # filters alone does not fire for tags at all, which is the same rule ci.yml's header sets out. + push: + branches: + - main + + # The pull-request opt-in, and it is worth being exact about what it does and does not catch, + # because a paths list reads like a claim of coverage. These three are the *infrastructure*: the + # workflow itself, so the pull request that changes it runs it; the script that owns the + # container, the emulator pinning and the configure inside it; and CMakeLists.txt, which pins + # both dependency tags and so decides what has to compile under the emulator at all. + # + # The ARMv6-only breaks this project has actually met are none of those. They are in the source: + # Raspbian's gcc 12 `-Wrestrict` false positive, and the 64-bit atomics that become libatomic + # calls because ARMv6 has no LDREXD. `src/**` and `tests/**` would catch them and are pointedly + # not here -- they match nearly every pull request, and a filter that fires on nearly every pull + # request is the per-push cost arriving back under another name. + # + # So those breaks are caught after the merge rather than before it, by the `push` above, and + # `workflow_dispatch` is how a pull request that expects to meet one opts in early. What they + # cannot do is reach a release: release.yml calls this workflow and its release job needs the + # result, so a broken ARMv6 delays a tag rather than publishing without one. + pull_request: + paths: + - .github/workflows/build-armv6.yml + - scripts/build_armv6_container.sh + - CMakeLists.txt + + # For a branch the path filter above does not catch. Offered only for workflows that exist on + # the default branch, which is a property of `workflow_dispatch` rather than of this file: until + # this workflow is on main there is no dispatch to run, and the path filter is what covers the + # pull request that puts it there. + workflow_dispatch: + +# Keyed like ci.yml's and cancelling for the same reason -- a superseded push has nothing left to +# say -- which is worth more here than anywhere else in this repository, this being the run that +# holds a runner for 25 minutes while saying it. +# +# `cancel-in-progress` is conditional, alone among this repository's concurrency blocks, because +# release.yml calls this workflow and release.yml deliberately carries no `concurrency` block of +# its own -- precisely so that nothing can cancel it midway through uploading assets. A group here +# is free to reach into that run, so this is what keeps the decision made there true from this +# side of the call. +# +# The condition is on the *ref* rather than on the workflow name, and that is what makes it hold +# rather than nearly hold. Two runs share a group only if they share a ref, a release run's ref is +# always a tag, and no run on a tag ref cancels anything -- so the guard stands whatever +# `github.workflow` resolves to inside a called workflow, which is the caller's name and is not a +# thing this repository can test without cutting a release. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +# Restated here rather than inherited: a called workflow gets none of the caller's workflow-level +# `permissions`, `defaults` or `env`. The one that matters is the shell. The default is `bash -e` +# with no pipefail, which would let the `| tee` in the configure step below swallow a configure +# failure -- leaving the assertion after it grepping a truncated log and both callers green on a +# build that never configured. +permissions: + contents: read + +defaults: + run: + shell: bash + +env: + # A floor, and the Build step below reads the runner's own core count over the top of it -- so + # what this covers is any later cmake invocation that builds without saying how wide. It is set + # here rather than left out because scripts/build_armv6_container.sh forwards this variable by + # name across the `docker exec` boundary and says so: unset, an already emulated build compiles + # one translation unit at a time. + CMAKE_BUILD_PARALLEL_LEVEL: 3 + +jobs: + build: + # The job's name and the LEG below are both exactly `linux-armv6`, and neither is free to + # drift: release.yml diffs `sendspin-cli-$VERSION-linux-armv6.tar.gz` by name in two places, + # and download-artifact's `merge-multiple` lays the archive out where that diff looks for it. + name: linux-armv6 + runs-on: ubuntu-24.04 + # 23 minutes of emulated compiles measured, plus room for a slow runner. 40 rather than 30 + # because at 30 the margin was under a quarter, which on shared hardware is the run that goes + # red for the weather rather than for a defect -- and a flaky run teaches people to re-run + # rather than to read. Far under the six-hour default either way: the point of any number here + # is that a player which ignores SIGTERM fails rather than holds a runner all day. + timeout-minutes: 40 + env: + # 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 that its gcc is configured `--with-arch=armv6 --with-float=hard`, which is why + # nothing here 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. + IMAGE: 'balenalib/rpi-raspbian:bookworm@sha256:0f3c33faa9d7b1dac778e8afb5854080dcfbddeac7437265598c6076bb0ffc82' + LEG: linux-armv6 + steps: + # Every third-party action here is pinned to a commit rather than a tag, because a tag can be + # repointed at any time and these run with write access to the workspace. The trailing + # comment is the version that commit was, for a reader bumping it. + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # 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. + # + # `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 + 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. + # + # Started by hand rather than named in a job-level `container:`, 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 build user scripts/build_armv6_container.sh insists on is about. + - name: Start the Raspbian build 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 compiled: `-build` + # is excluded and `build/` is outside this path entirely, so no object file ever survives a + # compiler upgrade on the runner image. Restoring the downloads is where the time goes anyway + # -- sendspin-cpp pulls its own dependencies (ixwebsocket, opus, FLAC) through FetchContent + # in turn. + # + # Keyed by CMakeLists.txt's hash because it holds both pinned tags, and spelled `linux-armv6` + # to the letter: this is the key build.yml's matrix wrote while this leg lived there, so the + # caches already on the repository still hit. A subbuild left stale by a cmake upgrade on the + # image is the residual risk, and dropping the cache is its remedy. + - name: Cache the fetched sources + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .deps/*-src + .deps/*-subbuild + key: deps-linux-armv6-${{ hashFiles('CMakeLists.txt') }} + + - name: Configure + env: + DEPS_DIR: ${{ github.workspace }}/.deps + run: | + # The same options build.yml configures every leg with, spelled out rather than shared, + # there being no mechanism that could share them across two workflow files. What this + # build changes is where cmake runs and the one library ARMv6's atomics need, and + # scripts/build_armv6_container.sh owns both -- not one of these options. + options=( + -DCMAKE_BUILD_TYPE=Release + -DSENDSPIN_CLI_WERROR=ON + -DSENDSPIN_CLI_WITH_MDNS=ON + -DSENDSPIN_CLI_WITH_PULSE=ON + -DSENDSPIN_CLI_WITH_PIPEWIRE=ON + -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" + ) + + scripts/build_armv6_container.sh configure build "${options[@]}" 2>&1 | tee configure.log + + # The check that stops this going green while quietly building something else. A missing -dev + # package does not fail the configure -- every backend here is optional and auto-detected by + # design -- so without this the job would happily ship a null-sink-only, mDNS-less binary and + # call it built. + # + # Whole-line matches, anchored: `audio backends:` on its own is also satisfied by the + # degraded `null, stdout` line, which is the exact failure being guarded against. The mDNS + # pattern is anchored on the multiarch library path rather than left as a loose + # `.*libdns_sd\.so.*`, so a find_library that answered with a library of the wrong + # architecture is a failure here rather than a binary that links nothing usable. + - name: Assert the configure output found what this leg expects + run: | + patterns=( + '^-- sendspin-cli audio backends: null, stdout, alsa, portaudio, pulse, pipewire$' + '^-- sendspin-cli mDNS: dns_sd \(/usr/lib/arm-linux-gnueabihf/libdns_sd\.so\)$' + # The dns_sd probes are pinned to the answer this host owes, because they fail open: + # one that reported Failed for an unrelated reason would silently cost two case labels + # in describe_error(), and nothing else would complain. + '^-- Performing Test SENDSPIN_CLI_HAVE_ERR_SERVICE_NOT_RUNNING - Failed$' + '^-- Performing Test SENDSPIN_CLI_HAVE_ERR_TIMEOUT - Failed$' + ) + + status=0 + for expected in "${patterns[@]}"; do + if ! grep -Eq -e "$expected" configure.log; then + echo "::error::configure output has no line matching: $expected" + status=1 + fi + done + echo 'What configure actually reported:' + grep -E -- '^-- (sendspin-cli|Performing Test SENDSPIN_CLI)' configure.log || + echo '(configure.log has no sendspin-cli lines at all)' + exit "$status" + + - name: Build + run: | + # The workflow-level 3 above is a floor; this runner has four cores, and every compile + # here is emulated, so the difference is worth reading off. Read off the runner rather + # than written down, 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 + + # What makes this job's name mean something. Every dependency arrives through FetchContent + # and so through add_subdirectory, in the one cmake invocation -- and this build passes no + # `-march` at all, its compiler targeting ARMv6 by configuration, which is exactly why it + # needs this read. What is being claimed 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. 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 + run: | + binary=build/sendspin-cli + + # 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 flags check below + # uses `case`. + 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)" 'v6' + # 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 + + # 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. + 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 job is about to publish" + status=1 + fi + fi + + rm -f elf-header.txt elf-attributes.txt + exit "$status" + + # 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: scripts/build_armv6_container.sh run ctest --test-dir build --output-on-failure + + # No avahi-daemon is installed here and none could be: the binary under test runs under an + # emulator inside the container. So 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 + run: scripts/build_armv6_container.sh run scripts/smoke_test.sh build/sendspin-cli + + # A build output for trying a commit on real hardware, staged by the same `install()` rules an + # operator would use. DESTDIR rather than `--prefix`, so every path in the archive is the path + # the file installs to: the unit's ExecStart is absolute and names the configure-time prefix, + # and `--prefix` would relocate the payload around it without changing what it says -- leaving + # a unit pointing at a binary the archive does not hold. + # + # `--component sendspin-cli` is what keeps the payload ours. Fetched dependencies declare + # install() rules of their own that no option turns off, and CMakeLists.txt says which. + # + # Tarred rather than handed to upload-artifact loose because the artifact is served as a zip, + # and zip does not carry the executable bit -- an untarred binary would arrive chmod-less. + - name: Package + id: package + env: + # bookworm's spellings, where build.yml's linux-armv7 leg names trixie's, and libatomic1 + # besides: ARMv6 has no LDREXD, so the 64-bit atomics become libatomic calls and the + # binary carries NEEDED libatomic.so.1. 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. + RUNTIME_PACKAGES: 'libasound2 libportaudio2 libpulse0 libpipewire-0.3-0 libavahi-compat-libdnssd1 libatomic1' + run: | + # Both the version read and the install below go through the container. 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. + reported="$(scripts/build_armv6_container.sh run ./build/sendspin-cli --version)" + version="$(printf '%s\n' "$reported" | awk 'NR == 1 { print $2 }')" + lib_tag="$(printf '%s\n' "$reported" | awk 'NR == 2 { print $2 }')" + name="sendspin-cli-$version-$LEG" + + mkdir -p "stage/$name" + scripts/build_armv6_container.sh run \ + env DESTDIR="$PWD/stage/$name" cmake --install build --component sendspin-cli + + cat >"stage/$name/BUILD-INFO.txt" <>"stage/$name/BUILD-INFO.txt" <<'LINUX' + + The systemd unit is at usr/local/lib/systemd/system/sendspin-cli.service, which is + on systemd's own search path, so installing as above needs no copying. The one + command a tarball cannot run for itself creates the account the unit runs as, + declared beside it in usr/local/lib/sysusers.d/sendspin-cli.conf: + + sudo systemd-sysusers + sudo systemctl daemon-reload + sudo systemctl enable --now sendspin-cli + + Skip the first line and the unit will not start: systemctl reports 217/USER. + + It runs the player as an unprivileged sendspin-cli user, in the foreground, with its + state in /var/lib/sendspin-cli and its control socket in /run/sendspin-cli. An + existing /var/lib/sendspin-cli left behind by an earlier root-run version needs + nothing done to it -- systemd chowns it. Configure it in + /etc/sendspin-cli.conf; there is an annotated example beside the installed README, + under usr/local/share/doc/sendspin-cli. Its ExecStart names /usr/local/bin/sendspin-cli + absolutely, so a binary put anywhere else needs the unit changed to match. + LINUX + + # 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-armv6, is accurate and baffling at once + # until something accounts for it. + # + # Read off the binary rather than written down, so the glibc line below cannot drift from + # what was actually built. The assertion further up has already refused anything above the + # 2.36 bookworm itself carries; this is what says how far below it came in. + glibc_floor="$(readelf --version-info build/sendspin-cli | + grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' | sort -uV | tail -1)" + + cat >>"stage/$name/BUILD-INFO.txt" <>"$GITHUB_OUTPUT" + + # What the payload holds, asserted rather than assumed -- the same check the configure output + # gets above, and for the same reason. Two things go wrong silently otherwise: a dependency + # that starts declaring install() rules on a SENDSPIN_GIT_TAG bump adds a static library and a + # tree of headers to a tarball nobody reads the manifest of, and a file dropped from our own + # install() rules leaves an archive that is merely smaller. + # + # The expected list below is one of two. build.yml's "Assert the payload holds exactly what + # it should" holds the other, for the legs that live there, and a change to the install() + # rules has to land in both or one of these two workflows fails on a diff. Two workflow files + # cannot share a step, and this list is short enough that a script to hold it would be more + # indirection than it removes -- so the price is this cross-reference. + - name: Assert the payload holds exactly what it should + env: + NAME: ${{ steps.package.outputs.name }} + run: | + expected=$(mktemp) + { + echo BUILD-INFO.txt + echo usr/local/bin/sendspin-cli + echo usr/local/lib/sysusers.d/sendspin-cli.conf + echo usr/local/lib/systemd/system/sendspin-cli.service + echo usr/local/share/doc/sendspin-cli/LICENSE + echo usr/local/share/doc/sendspin-cli/README.md + echo usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example + } | sort >"$expected" + + # `! -type d` rather than `-type f`, so a symlink into the payload is a difference rather + # than an invisible one. + actual=$(mktemp) + (cd "stage/$NAME" && find . ! -type d | sed 's|^\./||') | sort >"$actual" + + echo 'What the payload holds:' + cat "$actual" + diff -u "$expected" "$actual" || { + echo '::error::the staged payload is not the file list this workflow expects' + exit 1 + } + + # And again from inside the archive that actually ships, since that is one `tar` away from + # the directory checked above and it is the artifact anybody downloads. The executable bit + # is asserted here too: it is the reason this is a tarball at all, the artifact being + # served as a zip that does not carry one. + # Directory entries dropped before the prefix is stripped, since both tars list them with + # a trailing slash -- including the archive's own top-level one, which would otherwise + # strip down to an empty line and read as a difference. + tar -tzf "$NAME.tar.gz" | grep -v '/$' | sed -n "s|^$NAME/||p" | sort >"$actual" + diff -u "$expected" "$actual" || { + echo '::error::the tarball does not hold what the staged payload does' + exit 1 + } + [ -x "stage/$NAME/usr/local/bin/sendspin-cli" ] || { + echo '::error::the staged sendspin-cli is not executable' + exit 1 + } + + # Nothing here installs the payload at the real prefix or runs the systemd unit it carries, + # where build.yml's native Linux legs do both, and that is deliberate rather than an omission + # the split introduced: running the unit against a binary this 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 this job + # would go red on a directive the player itself has no quarrel with. The unit in this archive + # is the same file those legs verify, installed by the same rules. + - name: Upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.package.outputs.name }} + path: ${{ steps.package.outputs.name }}.tar.gz + # Long enough to bisect a regression against, short enough that per-commit builds do not + # accumulate. This artifact is never itself a release: on a tag it is how the tarball + # reaches release.yml's release job, which attaches it to a release of its own and leaves + # this copy to expire on the same clock. + retention-days: 14 + if-no-files-found: error + + # `always()` because a failed run still leaves a container behind, and because this is what + # keeps `stop` a verb the script's own usage can promise -- exercised by the file that + # documents it rather than only on a developer's machine. The runner is discarded either way, + # so nothing here depends on it having run. + - name: Stop the build container + if: always() + run: scripts/build_armv6_container.sh stop diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e621e3d..4546bac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,14 +1,22 @@ name: Build -# The one definition of how this project builds, called by `ci.yml` on a push or a pull request -# and by `release.yml` on a tag. It is a reusable workflow rather than a composite action -# because what must not be duplicated is the *matrix* below, not the steps: a composite action -# cannot own `strategy.matrix`, so a second caller would have to restate the very block whose -# per-leg `publish`/`avahi` keys the comment there warns about getting wrong. +# How this project builds everywhere it builds at native speed, called by `ci.yml` on a push or a +# pull request and by `release.yml` on a tag. It is a reusable workflow rather than a composite +# action because what must not be duplicated is the *matrix* below, not the steps: a composite +# action cannot own `strategy.matrix`, so a second caller would have to restate the very block +# whose per-leg `publish`/`avahi` keys the comment there warns about getting wrong. # -# No inputs, deliberately. A release is gated on exactly what a push is gated on -- the no-mDNS -# leg included, since a break in src/mdns_null.cpp is a real break -- and an input is the seam -# along which the two would start to differ. +# No inputs, deliberately. Both callers get exactly these five legs -- the no-mDNS one included, +# since a break in src/mdns_null.cpp is a real break -- and an input is the seam along which the +# two would start to differ. +# +# What a tag gets and a push does not is the sixth archive, and it is a whole workflow away: +# `.github/workflows/build-armv6.yml` builds linux-armv6 inside an emulated Raspbian container, +# which costs 23 minutes against the two or three every leg here takes, so it carries triggers of +# its own instead of running on every push. `release.yml` calls it alongside this file and blocks +# the release on both. The trade that buys -- a tag builds one thing a push does not -- is written +# out in that file's header, and its trigger set is the mitigation: `main` within half an hour of +# every merge, plus a path filter and a dispatch. on: workflow_call: @@ -32,18 +40,13 @@ jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.runner }} - # 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 + # Every leg here compiles at native speed and finishes inside three minutes, so this is not + # sized for a build at all: it is what stops a player which ignores SIGTERM from holding a + # runner all day, the suite and the smoke test both starting one. Far under the six-hour + # default, and far enough above three minutes that a slow runner is not a red leg -- a leg that + # goes red for the weather teaches people to re-run rather than to read. build-armv6.yml sizes + # its own against a build instead, that one emulating every compile it makes. + timeout-minutes: 20 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. @@ -55,15 +58,10 @@ jobs: # 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. + # `arm32` names the 32-bit ARM architecture a leg targets, and is empty on every leg that + # builds for the runner it is on. # - # A new leg owes all eight -- and a `publish: true` leg owes a ninth thing in another + # A new leg owes all seven -- and a `publish: true` leg owes an eighth 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 @@ -73,7 +71,6 @@ jobs: - name: linux-x86_64 runner: ubuntu-24.04 arm32: '' - container: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -88,7 +85,6 @@ jobs: - name: linux-arm64 runner: ubuntu-24.04-arm arm32: '' - container: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -108,9 +104,10 @@ jobs: # emulating only what has to be executed puts configure, build and suite inside a # minute of runner time. # - # That trade is available here and nowhere else in this matrix. The ARMv6 leg below - # has no cross toolchain to take the other side of it, which is what its own comment - # is about -- so it pays the emulation this leg declines. + # That trade is available here and nowhere else. ARMv6 has no cross toolchain to take + # the other side of it -- Raspbian publishes none and every other armhf toolchain is an + # ARMv7-A port -- so build-armv6.yml pays the emulation this leg declines, which is the + # whole reason it is a workflow of its own rather than a leg here. # # `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 @@ -125,7 +122,6 @@ jobs: - name: linux-armv7 runner: ubuntu-24.04 arm32: 'armv7' - container: '' with_mdns: 'ON' with_pulse: 'ON' with_pipewire: 'ON' @@ -137,55 +133,6 @@ 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 @@ -193,7 +140,6 @@ jobs: - name: macos-arm64 runner: macos-14 arm32: '' - container: '' with_mdns: 'ON' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -218,7 +164,6 @@ jobs: - name: linux-x86_64-nomdns runner: ubuntu-24.04 arm32: '' - container: '' with_mdns: 'OFF' with_pulse: 'OFF' with_pipewire: 'OFF' @@ -255,12 +200,8 @@ jobs: # 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. # - # `!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 + if: matrix.arm32 run: | sudo dpkg --add-architecture armhf @@ -364,36 +305,6 @@ 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 @@ -420,15 +331,11 @@ 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 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, 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. + # 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 @@ -438,13 +345,7 @@ jobs: -DFETCHCONTENT_BASE_DIR="$DEPS_DIR" ) - # `$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 + 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 @@ -489,22 +390,7 @@ jobs: exit "$status" - name: 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 + run: cmake --build build # 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 @@ -515,11 +401,6 @@ 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. @@ -531,13 +412,11 @@ 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 @@ -586,32 +465,6 @@ 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" @@ -622,28 +475,14 @@ 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 - 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 + run: ctest --test-dir build --output-on-failure # Deliberately before avahi-daemon is installed below. With no daemon to register with, # this is what exercises the non-fatal advertise-failure path in src/main.cpp -- the # player has to come up and warn rather than exit. - name: Smoke test if: matrix.publish - 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 + run: scripts/smoke_test.sh build/sendspin-cli # The one *runtime* claim this matrix owes roadmap item 5, which was built and exercised # against Bonjour only: that libavahi-compat-libdnssd really implements the calls @@ -761,29 +600,14 @@ jobs: RUNTIME_PACKAGES: ${{ matrix.runtime_packages }} LEG: ${{ matrix.name }} ARM32: ${{ matrix.arm32 }} - IMAGE: ${{ matrix.container }} run: | - # Both the version read and the install below go through the container on the leg that - # has one. The binary is the obvious half; the install is the less obvious one, and it - # is not about executing anything -- cmake_install.cmake was written by the container's - # cmake 3.25 and is read back by whichever cmake runs it, and the files it lays down - # have to arrive owned the way everything else in this workspace is. - if [ -n "$IMAGE" ]; then - reported="$(scripts/build_armv6_container.sh run ./build/sendspin-cli --version)" - else - reported="$(./build/sendspin-cli --version)" - fi + reported="$(./build/sendspin-cli --version)" version="$(printf '%s\n' "$reported" | awk 'NR == 1 { print $2 }')" lib_tag="$(printf '%s\n' "$reported" | awk 'NR == 2 { print $2 }')" name="sendspin-cli-$version-$LEG" mkdir -p "stage/$name" - 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 + DESTDIR="$PWD/stage/$name" cmake --install build --component sendspin-cli cat >"stage/$name/BUILD-INFO.txt" <>"stage/$name/BUILD-INFO.txt" <>"stage/$name/BUILD-INFO.txt" <--` from the run summary. Inside is a @@ -1376,15 +1390,24 @@ unpacking it at `/`. Or run it where you unpacked it, at of `/usr/local` leaves the unit naming a path with nothing at it. The macOS leg publishes a second artifact beside that tarball, `sendspin-cli--macos-arm64-installer`, holding the -[`.pkg`](#the-macos-installer-pkg) described below. These are per-commit builds -kept for 14 days. For something that does not expire, take a -[release](../../releases) instead. +[`.pkg`](#the-macos-installer-pkg) described below. + +Five of the six archives are built on every commit that way. The sixth, +`linux-armv6`, is on its own workflow's runs instead — a push to `main`, a pull +request touching one of the three paths above, a manual dispatch, or a tag — so on +an arbitrary branch commit there is no ARMv6 tarball to take. Dispatch the ARMv6 +workflow against that branch if you need one. + +All of these are kept for 14 days. For something that does not expire, take a +[release](../../releases) instead, which carries all six. ## Releases -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 -six, as a GitHub Release. The workflow triggers on `v*` but refuses anything else +Pushing a `vMAJOR.MINOR.PATCH` tag builds the same matrix, and the ARMv6 workflow +beside it, and publishes the five platform archives and the macOS installer `.pkg`, +plus a `SHA256SUMS` covering all six, as a GitHub Release. Both builds have to go +green: the release job needs them both, so a red ARMv6 build fails the release +rather than publishing without its archive. 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 600ebc5..2373a2a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1498,8 +1498,7 @@ Optional, later. Upstream's `examples/tui_client` shows the shape. `.github/workflows/ci.yml` builds and tests every branch push and pull request on `ubuntu-24.04`, `ubuntu-24.04-arm` and `macos-14`, a fourth cross-compiled for ARMv7 on -`ubuntu-24.04`, a fifth built natively for ARMv6 inside an emulated Raspbian container on that -same runner, and a sixth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles +`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: @@ -1507,7 +1506,7 @@ no-mDNS leg included: 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 five 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 @@ -1515,6 +1514,32 @@ The matrix itself lives in `.github/workflows/build.yml`, called by `ci.yml` and `branches: ['**']`, which excludes tags, so a tag push builds once rather than twice. See item 10 for why that split is a reusable workflow rather than a composite action. +**The sixth archive is not in that matrix, and its absence is deliberate.** `linux-armv6` builds +natively inside an emulated Raspbian container, which pays for every compile in the FetchContent +tree under `qemu-user`: 23 minutes measured, against the two or three every leg above takes. As a +matrix leg it ran whenever the matrix ran — every branch push, and again on every pull request — +so every push waited half an hour on the one leg that almost never had anything new to say. + +It is now `.github/workflows/build-armv6.yml`, one job carrying its own triggers: `workflow_call`, +so `release.yml` calls it beside `build.yml`; `push: branches: [main]`, so a merge is covered +within half an hour of landing; a `pull_request` filtered to that workflow, +`scripts/build_armv6_container.sh` and `CMakeLists.txt`; and `workflow_dispatch`. Deliberately no +unfiltered branch `push` — that is the cost being removed. The alternative considered and rejected +was leaving the leg in the matrix behind `continue-on-error`, which would still hold a runner for +25 minutes twice per pull request and would turn a red leg into one that gates nothing. + +What that buys is a seam, and it is worth naming: **a tag now builds one thing a push does not.** +The `pull_request` path filter is honest about its own reach — it catches infrastructure changes, +not the ARMv6-only source breaks this project has actually met, which are the gcc 12 `-Wrestrict` +false positive and the `-latomic` class described below. Those are caught after the merge instead. +Two things bound the cost. `release.yml` needs both build jobs, so a red ARMv6 build fails the +release rather than publishing an incomplete one, and the release's expected asset set still names +`sendspin-cli--linux-armv6.tar.gz` — so an archive nobody built is a diff rather than a +quietly smaller release. And `push: branches: [main]` means a break can delay a tag but has at most +one merge in which to go unnoticed. Raising the ARMv6 leg's coverage back towards per-push without +paying for it per push — a nightly, or a merge-queue check — is a separate piece of work and is not +owed by anything today. + The unit harness is item 1's and unchanged: GoogleTest via `FetchContent` pinned to a tag, wired to CTest with `gtest_discover_tests()`, defaulting ON only when this is the top-level project. Nothing in `tests/` opens an audio device, a socket, or the mDNS daemon, which is @@ -1565,31 +1590,31 @@ armhf multiarch tree — `scripts/build_arm32.sh` owns the configure — and run suite and `scripts/smoke_test.sh` under `qemu-user` rather than skipping them, so it holds the same rule every other leg does. Nothing builds it natively: GitHub has no armv7 runner, and its arm64 runners are Neoverse N1 with no AArch32 at EL0, so no `runs-on` value reaches the target -at all — and a cross build is what buys back everything the ARMv6 leg below has to emulate. +at all — and a cross build is what buys back everything the ARMv6 build 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 +**The ARMv6 archive 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. 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 +every one of `libgcc.a`'s members reads `Tag_CPU_arch: v6`, so the build 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 +digest-pinned Raspbian bookworm container under `qemu-user`, which is why it is the one archive `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 +Three things about that build 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 diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index d48fef0..441b9d9 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -10,8 +10,10 @@ Four ways in, depending on what you have. If you are on Linux and want the short | Anything else | [Build from source](#build-from-source) | Everything published is on the -[Releases page](https://github.com/Sendspin/sendspin-cpp-cli/releases). Per-commit builds -of unreleased work are under the repository's Actions tab and expire after 14 days — see +[Releases page](https://github.com/Sendspin/sendspin-cpp-cli/releases), and every release +carries all six archives. Per-commit builds of unreleased work are under the repository's +Actions tab and expire after 14 days — five archives per commit there, `linux-armv6` being +built by a workflow of its own rather than on every push. See [CI](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#ci). ## What is in the archive diff --git a/scripts/build_arm32.sh b/scripts/build_arm32.sh index dbe88a3..86fac8b 100755 --- a/scripts/build_arm32.sh +++ b/scripts/build_arm32.sh @@ -91,14 +91,13 @@ case "$TARGET" in # and the merged Tag_CPU_arch build.yml reads back says so. # # 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 + # rather than a flag. The job in .github/workflows/build-armv6.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). 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" + container, which has an armv6 libgcc; see .github/workflows/build-armv6.yml" ;; *) fail "unknown target '$TARGET' -- this builds armv7" diff --git a/scripts/build_armv6_container.sh b/scripts/build_armv6_container.sh index 74111f7..567cebc 100755 --- a/scripts/build_armv6_container.sh +++ b/scripts/build_armv6_container.sh @@ -14,8 +14,8 @@ # 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. +# Builds sendspin-cli for ARMv6 inside an emulated Raspbian container, for the job +# in .github/workflows/build-armv6.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 @@ -41,8 +41,9 @@ # 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 +# a registered binfmt handler for 32-bit ARM, which .github/workflows/build-armv6.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 ...] @@ -109,8 +110,8 @@ in_container() { ) # 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. + # workflow-level `env:` in build-armv6.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 @@ -175,7 +176,7 @@ case "$VERB" in 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 + handler for 32-bit ARM: .github/workflows/build-armv6.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" } @@ -286,9 +287,9 @@ case "$VERB" in [ "$#" -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. + # unconditionally to tidy up -- .github/workflows/build-armv6.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" From 85aabfe985b6aaf31a2de7e9378fc95127b38118 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Sun, 30 Aug 2026 19:06:18 -0500 Subject: [PATCH 2/2] Sweep the comment debt the ARMv6 split left, and size the matrix timeout off a real run - build.yml: drop a stray comment fragment left where the !matrix.container paragraph was, and justify timeout-minutes: 20 against measured job durations rather than an inherited sentence. The slowest remaining leg is linux-armv7 at ~3m09s and macos-arm64 comes in under two minutes, so 20 leaves better than five times the slowest measurement. - build.yml: note that build-armv6.yml holds a third copy of the shared cmake options array, the payload assertion already carrying that kind of cross-reference both ways. - release.yml: say in the no-concurrency comment that the tag-only trigger is also what makes build-armv6.yml's conditional cancel-in-progress hold, so a later workflow_dispatch here cannot silently re-open that window. - scripts/get_started_linux.sh: build_armv6_container.sh is no longer run on every build, and 'the containerised leg' names nothing. - scripts/build_armv6_container.sh: the caller is one job, not a matrix leg. - docs/ROADMAP.md item 10: drop the echo of the retired claim that a release is gated on what a push is gated on. - README, ROADMAP, build-armv6.yml: the tip of main is covered within half an hour of a merge, not every merge -- cancel-in-progress means a superseded run does not finish. - docs/wiki/Installation.md: 'anything not in the matrix' no longer names the set of architectures with no release. - Comments that narrated the split rather than describing the current arrangement, and the overstated claim that the job name is load-bearing. --- .github/workflows/build-armv6.yml | 30 ++++++++++++++++-------------- .github/workflows/build.yml | 22 +++++++++++++++------- .github/workflows/release.yml | 8 +++++++- README.md | 15 ++++++++------- docs/ROADMAP.md | 6 +++--- docs/wiki/Installation.md | 2 +- scripts/build_armv6_container.sh | 6 +++--- scripts/get_started_linux.sh | 7 ++++--- 8 files changed, 57 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-armv6.yml b/.github/workflows/build-armv6.yml index 1edb0a1..ccd96fc 100644 --- a/.github/workflows/build-armv6.yml +++ b/.github/workflows/build-armv6.yml @@ -19,10 +19,10 @@ name: Build ARMv6 # what leaves a tag gated on a green ARMv6 build. # # The seam that opens is worth naming rather than leaving to be discovered: a tag builds one thing -# a push does not. What stands in for per-push coverage is the trigger set -- `main` is covered -# within half an hour of every merge, the path filter below catches an infrastructure change, and -# `workflow_dispatch` opts a branch in on demand. So a break here can delay a tag. It cannot ship -# an archive nothing built. +# a push does not. What stands in for per-push coverage is the trigger set -- the tip of `main` is +# covered within half an hour of a merge, the path filter below catches an infrastructure change, +# and `workflow_dispatch` opts a branch in on demand. So a break here can delay a tag. It cannot +# ship an archive nothing built. on: # release.yml, on a tag. workflow_call: @@ -103,9 +103,11 @@ env: jobs: build: - # The job's name and the LEG below are both exactly `linux-armv6`, and neither is free to - # drift: release.yml diffs `sendspin-cli-$VERSION-linux-armv6.tar.gz` by name in two places, - # and download-artifact's `merge-multiple` lays the archive out where that diff looks for it. + # The LEG below is the one that is not free to drift: release.yml diffs + # `sendspin-cli-$VERSION-linux-armv6.tar.gz` by name in two places, and download-artifact's + # `merge-multiple` lays the archive out where that diff looks for it. This name matches it so + # that the check reads as the archive it produces, which is a courtesy to a reader rather than + # a constraint -- nothing in this repository requires a status check by name. name: linux-armv6 runs-on: ubuntu-24.04 # 23 minutes of emulated compiles measured, plus room for a slow runner. 40 rather than 30 @@ -170,10 +172,10 @@ jobs: # -- sendspin-cpp pulls its own dependencies (ixwebsocket, opus, FLAC) through FetchContent # in turn. # - # Keyed by CMakeLists.txt's hash because it holds both pinned tags, and spelled `linux-armv6` - # to the letter: this is the key build.yml's matrix wrote while this leg lived there, so the - # caches already on the repository still hit. A subbuild left stale by a cmake upgrade on the - # image is the residual risk, and dropping the cache is its remedy. + # Keyed by CMakeLists.txt's hash because it holds both pinned tags, and spelled + # `linux-armv6` to the letter, which is what keeps the caches already on the repository + # hitting. A subbuild left stale by a cmake upgrade on the image is the residual risk, and + # dropping the cache is its remedy. - name: Cache the fetched sources uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -508,9 +510,9 @@ jobs: } # Nothing here installs the payload at the real prefix or runs the systemd unit it carries, - # where build.yml's native Linux legs do both, and that is deliberate rather than an omission - # the split introduced: running the unit against a binary this runner can only reach through - # an emulator tests the emulator. qemu-user translates guest code into buffers it writes and + # where build.yml's native Linux legs do both. That is deliberate, not an oversight: running + # the unit against a binary this 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 this job # would go red on a directive the player itself has no quarrel with. The unit in this archive # is the same file those legs verify, installed by the same rules. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4546bac..db1631f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,12 +40,16 @@ jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.runner }} - # Every leg here compiles at native speed and finishes inside three minutes, so this is not - # sized for a build at all: it is what stops a player which ignores SIGTERM from holding a - # runner all day, the suite and the smoke test both starting one. Far under the six-hour - # default, and far enough above three minutes that a slow runner is not a red leg -- a leg that - # goes red for the weather teaches people to re-run rather than to read. build-armv6.yml sizes - # its own against a build instead, that one emulating every compile it makes. + # Not sized for a build. Every leg here compiles at native speed and the slowest of them, + # linux-armv7, measures a little over three minutes; macos-arm64, the one that brews, packages + # a .pkg, installs it and runs the smoke suite twice, comes in under two. What this number is + # for is a player which ignores SIGTERM: the suite and the smoke test both start one, and the + # point is that it fails rather than holding a runner all day. + # + # 20 leaves better than five times the slowest measurement, which is the margin that keeps a + # slow runner from being a red leg -- a leg that goes red for the weather teaches people to + # re-run rather than to read. build-armv6.yml sizes its own against a build instead, that one + # emulating every compile it makes. timeout-minutes: 20 strategy: # Every leg's result is wanted rather than only the first failure's: a break that shows @@ -199,7 +203,6 @@ 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 (cross-compiled 32-bit ARM) if: matrix.arm32 run: | @@ -336,6 +339,11 @@ jobs: # 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. + # + # There is a third copy, and it is in another file: build-armv6.yml configures the same + # six options for the archive it builds. Nothing mechanical holds the two together the + # way the payload assertion below is held, so an option added here has to be added there + # or the ARMv6 archive quietly stops matching what every other leg was built with. options=( -DCMAKE_BUILD_TYPE=Release -DSENDSPIN_CLI_WERROR=ON diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fe6e9c..9a7e37e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,12 @@ on: # would be free to cancel this run midway through uploading assets, which is the exact partial # state the release below is arranged to make impossible. A superseded tag push is not a thing # that happens anyway: a tag is pushed once. +# +# The single trigger above is load-bearing for a second reason, in another file. build-armv6.yml +# does carry a `concurrency` block, and what stops it reaching into a run started here is that its +# `cancel-in-progress` is false on a tag ref -- which holds only while every run of this workflow +# has one. Giving this workflow a `workflow_dispatch` or any branch trigger would hand a Release +# run a branch ref and quietly re-open the window this comment exists to keep shut. permissions: contents: read @@ -124,7 +130,7 @@ jobs: # that trade out in full. # # Called here rather than left to those triggers, and blocking rather than advisory: `release` - # needs it below, so an ARMv6 build that goes red fails the release. The seam the split opens is + # needs it below, so an ARMv6 build that goes red fails the release. The seam that leaves is # that a break can reach a tag having gone unnoticed since the last merge to main; what it # cannot do is publish a release the archive is missing from. build-armv6: diff --git a/README.md b/README.md index 08b6296..3a66b15 100644 --- a/README.md +++ b/README.md @@ -1367,10 +1367,10 @@ A tag therefore builds one thing a push does not, which is the trade this split buys and worth stating rather than leaving to be discovered. What limits it is that `release.yml` calls the ARMv6 workflow beside the matrix and blocks the release on both, so an ARMv6 break can delay a tag but cannot publish a release the archive is -missing from — and a merge to `main` gets an ARMv6 build within half an hour either -way. A pull request that expects to break ARMv6 alone, which in practice means the -`-Wrestrict` and `-latomic` classes rather than anything in the workflow files, can -be opted in with a manual dispatch. +missing from — and the tip of `main` gets an ARMv6 build within half an hour of a +merge either way. A pull request that expects to break ARMv6 alone, which in +practice means the `-Wrestrict` and `-latomic` classes rather than anything in the +workflow files, can be opted in with a manual dispatch. To try a commit without building it, open its run under the repository's Actions tab and take `sendspin-cli---` from the run summary. Inside is a @@ -1407,9 +1407,10 @@ Pushing a `vMAJOR.MINOR.PATCH` tag builds the same matrix, and the ARMv6 workflo beside it, and publishes the five platform archives and the macOS installer `.pkg`, plus a `SHA256SUMS` covering all six, as a GitHub Release. Both builds have to go green: the release job needs them both, so a red ARMv6 build fails the release -rather than publishing without its archive. 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. +rather than publishing without its archive. 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 because a human tagged a commit whose version `CMakeLists.txt` already agreed with. It is attached whole or not at diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2373a2a..2cc75fe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1247,8 +1247,8 @@ operator to choose between `Type=simple` and `Type=forking` and write the unit t gated by `ci.yml`'s `shellcheck` job, so the file arrives linted with no workflow change. - **It lives in `build.yml`, the shared build, not in `ci.yml`.** That is the whole of the re-homing this slice owes the second one, and it falls out of what the reusable workflow is - for: `build.yml` takes no inputs precisely so a release is gated on what a push is gated on, - and an installer that only a push produced would be the seam that claim warns about. One + for: `build.yml` takes no inputs precisely so both callers get the same legs, and an installer + that only a push produced would be the seam that rationale warns about. One definition therefore builds it, installs it and asserts it on both paths, and `release.yml` attaches what that build hands it. - **`pkgbuild --root /usr/local --install-location /usr/local`,** not a root one level @@ -1522,7 +1522,7 @@ so every push waited half an hour on the one leg that almost never had anything It is now `.github/workflows/build-armv6.yml`, one job carrying its own triggers: `workflow_call`, so `release.yml` calls it beside `build.yml`; `push: branches: [main]`, so a merge is covered -within half an hour of landing; a `pull_request` filtered to that workflow, +within half an hour of a merge; a `pull_request` filtered to that workflow, `scripts/build_armv6_container.sh` and `CMakeLists.txt`; and `workflow_dispatch`. Deliberately no unfiltered branch `push` — that is the cost being removed. The alternative considered and rejected was leaving the leg in the matrix behind `continue-on-error`, which would still hold a runner for diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 441b9d9..d59c135 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -149,7 +149,7 @@ as trixie. See ## Build from source -For an architecture with no release — an Intel Mac, anything not in the matrix — or to build +For an architecture with no release — an Intel Mac, anything CI does not build — or to build against a different version of the library. ```bash diff --git a/scripts/build_armv6_container.sh b/scripts/build_armv6_container.sh index 567cebc..d98ce84 100755 --- a/scripts/build_armv6_container.sh +++ b/scripts/build_armv6_container.sh @@ -66,9 +66,9 @@ fail() { 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. +# One container per checkout is enough -- the workflow that calls this runs one job on a runner of +# its 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 diff --git a/scripts/get_started_linux.sh b/scripts/get_started_linux.sh index 8ea3689..965ecd5 100755 --- a/scripts/get_started_linux.sh +++ b/scripts/get_started_linux.sh @@ -52,9 +52,10 @@ # # 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 four are -# also *run* on every build -- smoke_test.sh on each publishing leg, build_arm32.sh on the -# 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 +# also *run* by a build -- smoke_test.sh on each publishing leg, build_arm32.sh on the +# cross-compiled 32-bit ARM one, build_macos_pkg.sh on the macOS one, and +# build_armv6_container.sh throughout .github/workflows/build-armv6.yml. 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.