Skip to content

Add a linux-armv6 release leg built in an emulated Raspbian container - #48

Merged
chrisuthe merged 6 commits into
mainfrom
chrisuthe/task/add-a-linux-armv6-release-leg-built-in-an
Aug 30, 2026
Merged

Add a linux-armv6 release leg built in an emulated Raspbian container#48
chrisuthe merged 6 commits into
mainfrom
chrisuthe/task/add-a-linux-armv6-release-leg-built-in-an

Conversation

@chrisuthe

@chrisuthe chrisuthe commented Aug 30, 2026

Copy link
Copy Markdown
Member

A Pi Zero, a Pi Zero W and an original Pi are ARM1176, and until now they had no
archive at all — scripts/get_started_linux.sh refused them by name and the docs
told them to build from source. This adds the leg that serves them.

Why it is not the ARMv7 leg with a different -march

Debian and Ubuntu armhf are an ARMv7-A port. A cross toolchain's own
crt1.o, crtbegin.o and every member of libgcc.a come from there, and they
are linked into the binary whatever -march said — the linker merges build
attributes and reports the maximum, so the result reads v7 regardless. That is
why scripts/build_arm32.sh refuses armv6 by name, and that refusal stands:
that script cross-compiles against the host distribution's armhf tree, and
nothing here changes what is in it.

Raspbian's really are ARMv6. Its gcc is configured --with-arch=armv6 --with-float=hard, so this leg passes no -march at all, and Raspbian
publishes no cross toolchain. So linux-armv6 is not a cross build: it is an
ordinary native build run inside a digest-pinned Raspbian bookworm container
under qemu-user. Emulating the whole tree is not the slower of two options
here — it is the only one, which is the opposite of the trade the ARMv7 leg
makes.

scripts/build_armv6_container.sh owns the container, for the reason
build_arm32.sh is a script: this archive is the only build of the project a Pi
Zero owner will ever run, and it has to be reproducible without a runner — which
takes the image digest, --init, the uid and QEMU_CPU, none of which YAML can
hand anybody. It lands under ci.yml's shellcheck scripts/*.sh gate too.

Three things that are load-bearing and are not about ARMv6

  • Everything runs under QEMU_CPU=arm1176. qemu-arm otherwise emulates a
    Cortex-A15-class core and would execute ARMv7 instructions happily — an
    emulator more permissive than the hardware proves nothing. It applies at
    configure time as well as in the suite, a cmake try_run probe asking what the
    CPU can do being answerable for the wrong CPU otherwise.
  • --init and a non-root user. Without a reaping PID 1, an exited daemon
    stays a zombie, kill -0 keeps answering, and smoke_test.sh reports a player
    that outlived SIGTERM. And three StateStore cases assert that an unwritable
    directory is refused, which root is refused nothing by. Both failures look like
    ARMv6 breakage and are neither.
  • The link needs -latomic. ARMv6 has no LDREXD, so 64-bit atomics become
    libatomic calls. It goes in CMAKE_CXX_STANDARD_LIBRARIES rather than
    CMAKE_EXE_LINKER_FLAGS, which places it ahead of the objects that reference
    it and leaves the linker to discard it.

The warning line holds here too, with one diagnostic exempted

gcc 12.2.0 has a -Wrestrict false positive — at src/control_common.cpp:391
and :401, as the pipewire-minimum job already documents, and additionally at
tests/cli_test.cpp:1133, that third site 32-bit-specific. The leg passes
-Wno-error=restrict and keeps -Werror, so an ARMv6-only warning nobody has met
yet still fails it.

-Wno-error= is what makes that work and it is not interchangeable with
-Wno-restrict. CMAKE_CXX_FLAGS lands before the -Wall -Wextra -Wpedantic -Werror that target_compile_options adds, and a later -Wall turns a
disabled warning back on — where a later blanket -Werror does not re-promote a
diagnostic an earlier -Wno-error= has already exempted. Verified on the real
toolchain: the compile line reads -Wno-error=restrict -O3 -DNDEBUG -Wall -Wextra -Wpedantic -Werror, and the build reports three -Wrestrict warnings and no
errors. Nothing else in our own targets warns.

The glibc floor improves for free

The archive needs no more than GLIBC_2.34, where the others need 2.38. So it
loads on Raspberry Pi OS bookworm as well as trixie, which the linux-armv7
archive does not. The architecture-assertion step refuses anything referencing a glibc symbol above
the 2.36 bookworm carries, and BUILD-INFO.txt states the measured floor
rather than a written-down one — so the claim the archive ships cannot drift from
the binary. That replaces the ARMv7 leg's bookworm caveat, which is exactly
inverted for a binary built on bookworm. Raising the floor for linux-x86_64, linux-arm64 and linux-armv7
is a separate piece of work and deliberately not attempted here.

Support tier

No OHF project currently ships 32-bit ARM: the MA Local Audio add-on that ships
this binary is amd64/aarch64 only, ESPHome removed armv7 in Feb 2025, and
OHF-Voice's C++ wheels are CIBW_ARCHS: auto64. This archive is offered on the
same footing the linux-armv7 one is — built and exercised under emulation on
every push, never on the hardware — and its BUILD-INFO.txt says so in those
words. It is a convenience build for hardware nobody upstream targets, not a
tier-1 platform.

Verified by running it, not by reasoning

Built in the container against this tree:

  • configure reports exactly the anchored lines the matrix asserts —
    null, stdout, alsa, portaudio, pulse, pipewire,
    dns_sd (/usr/lib/arm-linux-gnueabihf/libdns_sd.so), both dns_sd probes Failed
  • the linked binary reads Tag_CPU_arch: v6, Tag_ABI_VFP_args: VFP registers,
    hard-float ABI, NEEDED libatomic.so.1, and tops out at GLIBC_2.34 — under
    the GLIBC_2.36 bookworm carries, which the leg now asserts rather than assumes
  • 426/426 ctest cases pass under QEMU_CPU=arm1176 as a non-root user
  • scripts/smoke_test.sh reports every check passed
  • the DESTDIR component install through the container stages exactly the six
    files the payload assertion expects

Alongside that: the host suite is 410/410 with a clean -Werror build,
shellcheck is clean on every script, and both of release.yml's asset lists
were cross-checked against the matrix's publish: true legs and match exactly.

Job time — the plan's estimate was wrong by 3x

Both CI runs on this branch are green on every leg. But the plan projected
6–9 minutes for this job from a 4m49s local measurement, and the first run
came in at 24m56s against the 30-minute timeout. Reported here rather than quietly
absorbed, as the plan asked; the timeout was then raised deliberately, below.

Step First run (-j3) Now (-j$(nproc) = 4, warm cache)
Start the Raspbian container 5m02s 4m56s
Configure 1m32s 0m47s
Build 16m59s 15m51s
Test 0m49s 0m49s
Smoke test 0m11s 0m12s
Job wall-clock 24m56s 22m49s

The estimate did not fail on the emulation multiplier, which held. It failed on
the hardware: it was taken on an Apple M5 Pro at -j4, and a hosted runner vCPU
is several times slower than an M5 Pro core.

CMAKE_BUILD_PARALLEL_LEVEL: 3 at the top of build.yml is a floor set by the
three-core macOS image, and this leg runs on a four-core one, so the Build
step now reads the count off the runner with nproc. That bought less than I
expected
— 1m08s on the build, about 7%, not the 25% four-over-three suggests.
The emulated build does not scale cleanly with cores. The change is still right
(a floor set for a different platform should not govern this leg), but it is not
the reason the total came down; the warm FetchContent cache is.

So ~23 minutes is what an emulated ARMv6 build costs, against 1m27s–3m01s for
every other leg. Two things follow, and both are judgement calls rather than
defects:

  • timeout-minutes is now 40, up from 30. At 30 the margin was under a
    quarter, and hosted-runner variance is routinely that wide — the first thing to
    go red would have been the weather rather than a defect, and a leg that flakes
    teaches people to re-run it rather than read it. 40 is the measurement plus room
    for a slow runner. It costs the other legs nothing (all finish inside three
    minutes), the six-hour default is still what is being guarded against, and the
    pipewire-minimum job keeps its own 30.
  • It is a standing cost on every push, since ci.yml builds every branch.
    fail-fast: false means it never blocks the other legs' results, and the
    archive is the point — but it is worth knowing before merging rather than
    after.

A Pi Zero, a Pi Zero W and an original Pi are ARM1176, and until now they had
no archive at all: scripts/get_started_linux.sh refused them and the docs told
them to build from source.

ARMv6 cannot be reached the way linux-armv7 is. Debian and Ubuntu armhf are an
ARMv7-A port, so a cross toolchain's own crt1.o, crtbegin.o and every member of
libgcc.a are ARMv7 and are linked into the binary whatever -march said -- which
is why scripts/build_arm32.sh refuses armv6 by name, and why that refusal
stands. Raspbian's really are ARMv6, its gcc being configured --with-arch=armv6
--with-float=hard, and Raspbian publishes no cross toolchain. So this leg is an
ordinary native build run inside a digest-pinned Raspbian bookworm container
under qemu-user, owned by scripts/build_armv6_container.sh for the reason
build_arm32.sh is a script: the archive is the only build of this project a Pi
Zero owner will ever run, and it has to be reproducible without a runner.

Three things about that container are load-bearing and none of them are about
the instruction set. Everything runs under QEMU_CPU=arm1176, because qemu-arm
otherwise emulates a Cortex-A15-class core and would be more permissive than
the hardware -- at configure time as well as in the suite, a cmake try_run
probe asking what the CPU can do being answered for the wrong CPU. The
container runs --init and builds as a non-root user, without which the smoke
test reads an unreaped zombie as a player that outlived SIGTERM and three
StateStore cases fail on a permission bit root ignores. And the link needs
-latomic, ARMv6 having no LDREXD; it goes in CMAKE_CXX_STANDARD_LIBRARIES
rather than CMAKE_EXE_LINKER_FLAGS, which places it ahead of the objects that
reference it and leaves the linker to discard it.

The leg is the one in the matrix without -DSENDSPIN_CLI_WERROR=ON, for the
reason the pipewire-minimum job already documents: gcc 12.2.0's -Wrestrict
false positive, at src/control_common.cpp:391 and :401 and additionally at
tests/cli_test.cpp:1133. -Wno-restrict is not the narrower fix it looks like --
CMAKE_CXX_FLAGS lands before the -Wall that target_compile_options adds, which
turns the warning back on.

The archive's glibc floor improves for free. It needs no more than GLIBC_2.34,
where the others need 2.38, so it loads on Raspberry Pi OS bookworm as well as
trixie -- which the linux-armv7 archive does not. Its BUILD-INFO.txt says so
rather than inheriting the ARMv7 leg's bookworm caveat, which is inverted here.

Verified by building: configure reports the same backend and mDNS lines the
linux-x86_64 leg expects, the linked binary reads Tag_CPU_arch v6 with VFP
registers and hard-float EABI and NEEDED libatomic.so.1, 426/426 ctest cases
pass under arm1176, and scripts/smoke_test.sh reports every check passed.
…t its glibc floor

The release notes still told every downloader that a Pi Zero, a Pi Zero W and an
original Pi had no build and pointed them at a roadmap item that is now closed,
which is the widest-audience place the claim appears. Five file counts across
release.yml and README.md were left at the pre-armv6 arithmetic, and
Getting-Started-on-Linux listed the architectures without armv6 in the line a Pi
Zero owner reads first.

The one claim the archive shipped that nothing checked was its glibc floor.
BUILD-INFO.txt tells a Pi owner the binary loads on Raspberry Pi OS bookworm, and
a container digest moved off bookworm would raise the floor silently and leave
that text promising something the loader would refuse. The architecture assertion
now reads the highest GLIBC_ symbol version off the binary and refuses anything
above the 2.36 bookworm carries, and BUILD-INFO.txt states the measured value
rather than a written-down one.

build_armv6_container.sh was fail-open on its likeliest failure: `docker run`
succeeds whether or not the kernel can execute what it started, so with no binfmt
handler the container is gone before anything looks at it. It is proven now,
where the message can name the cause. `stop` reports rather than refuses when
there is nothing to remove, which is what lets build.yml run it with
`if: always()` -- the step that makes the script's fourth verb reachable from the
file documenting it.

Three claims contradicted their own neighbours: build.yml, README.md and
ROADMAP.md each said the ARMv7 leg is cross-compiled because "nothing else can
build it" within a paragraph of describing the leg that does. The claim is that
no runner builds it natively. The pipewire-minimum comment said -Werror was
absent on the ARMv6 leg, where the matrix insists every leg spell the key out.
ci.yml and get_started_linux.sh both still counted three scripts run on a build.
…riting the glibc floor down

`build.yml` uses a real job-level `container:` on the pipewire-minimum job eighty
lines below the ARMv6 leg's `matrix.container`, and never said why one starts
Docker by hand. The answer is ordering: that key creates the container before the
first step runs, and the emulator has to be registered on the host before an ARM
container can execute anything, so there would be nowhere to put
docker/setup-qemu-action. The script's own claim that YAML could not express
`--init`, the uid or QEMU_CPU was overstated -- `container.options:` and
`container.env:` take all three. What a workflow file cannot be is somewhere a
developer runs, which is the argument that actually holds.

Four places still wrote `GLIBC_2.34` down while the assertion added beside them
deliberately does not: it bounds the floor at the 2.36 bookworm carries, so a
drift to 2.35 would go green and silently make all four false. They now claim
what is asserted, and the exact figure survives only where it is measured -- in
the BUILD-INFO.txt the archive ships.

The rest is rewrapping four comment and prose blocks that were edited in place
without being reflowed.
The first CI run came in at 24m56s against a 30-minute timeout, where the plan
expected six to nine minutes. The build is 16m59s of it, and it was running three
ways parallel on a four-core image: `CMAKE_BUILD_PARALLEL_LEVEL: 3` at the top of
this file is a floor, and what sets it is the three-core macOS image rather than
anything about Linux.

That costs nothing on the legs that compile natively, where the whole build is
seconds. On this one every compile is emulated, so it is the difference between a
leg with headroom and a leg that fits its timeout by five minutes. `nproc` reads
the count off the runner rather than writing one down, so an image with more
cores is a faster build instead of an edit nobody remembers to make.

The timeout comment claimed the 30 minutes was well clear of the slowest leg,
which the measurement did not support. It now says what the margin is really for.
The leg measures 22m49s against the 30 minutes this job allowed, a margin of
under a quarter. Hosted-runner variance is routinely that wide, so the first
thing to go red would have been the weather rather than a defect -- and a leg
that flakes teaches people to re-run it rather than to read it.

40 is the measurement plus room for a slow runner. It costs the other legs
nothing, all of which finish inside three minutes; the six-hour default is still
the thing being guarded against, and a player that ignores SIGTERM still fails
rather than holding a runner all day. The pipewire-minimum job keeps its own 30,
being a two-minute native build with no emulation in it.
@chrisuthe
chrisuthe marked this pull request as ready for review August 30, 2026 21:43
@chrisuthe
chrisuthe requested a balanced review from Copilot August 30, 2026 21:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The architecture fallback can install ARMv7 on older ARM hosts, and the privileged binfmt image remains mutable.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds ARMv6 release support for original Raspberry Pi and Pi Zero hardware.

Changes:

  • Builds and tests ARMv6 artifacts in an emulated Raspbian container.
  • Publishes and selects the new linux-armv6 archive.
  • Updates installation, release, and troubleshooting documentation.
File summaries
File Description
scripts/get_started_linux.sh Selects ARMv6 releases.
scripts/build_armv6_container.sh Adds containerized ARMv6 builds.
scripts/build_arm32.sh Documents the separate ARMv6 path.
.github/workflows/build.yml Adds the ARMv6 matrix leg.
.github/workflows/release.yml Publishes ARMv6 artifacts.
.github/workflows/ci.yml Updates shell-script CI notes.
README.md Documents ARMv6 CI and releases.
docs/ROADMAP.md Records ARMv6 support as shipped.
docs/wiki/Installation.md Adds ARMv6 installation instructions.
docs/wiki/Home.md Lists ARMv6 support.
docs/wiki/Troubleshooting.md Updates ARM compatibility guidance.
docs/wiki/Getting-Started-on-Linux.md Documents architecture selection.
docs/wiki/Getting-Started-on-a-Raspberry-Pi.md Adds Pi-specific ARMv6 guidance.
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/get_started_linux.sh Outdated
Comment thread .github/workflows/build.yml
Comment thread .github/workflows/build.yml Outdated
Comment thread scripts/build_armv6_container.sh Outdated
Comment thread .github/workflows/build.yml Outdated
…and pin the binfmt image

Three things a review of the branch caught.

`scripts/get_started_linux.sh` refused `armv5*` and bare `arm` but left an open
fallback to `linux-armv7` beneath them, so `armv4l` -- and any other pre-v5
spelling -- would have installed a binary that traps. It is reachable: the
getconf branch maps every 32-bit `arm*` kernel to an armhf userland. Every pre-v6
`uname` is named now, and the fallback is left to the machines it is for.

Turning SENDSPIN_CLI_WERROR off suppressed every diagnostic on this leg to
accommodate one that is wrong. `-Wno-error=restrict` demotes that one and leaves
-Werror standing, so an ARMv6-only warning nobody has met yet still fails the
leg. The earlier reasoning against a targeted flag held for `-Wno-restrict`,
which a later `-Wall` re-enables, and does not hold for `-Wno-error=`: a later
blanket -Werror does not re-promote a diagnostic already exempted. Verified on
the real toolchain, whose compile line reads `-Wno-error=restrict -O3 -DNDEBUG
-Wall -Wextra -Wpedantic -Werror` -- three -Wrestrict warnings, no errors,
426/426 and the smoke test green. With every leg back on -Werror the `werror`
matrix key says nothing, so it is gone.

docker/setup-qemu-action was pinned by commit while its `image` input defaulted
to the mutable `tonistiigi/binfmt:latest`, which it runs `--privileged` to edit
the kernel's binfmt handlers. Pinning the action and not that image is pinning
the wrong half. It also installed every emulator rather than the one handler
anything here uses.

Also: the `stop` usage line ended mid-sentence, and a rewrap had eaten four words
out of the pipewire-minimum comment.
@chrisuthe
chrisuthe merged commit e87b4b6 into main Aug 30, 2026
16 checks passed
@chrisuthe chrisuthe mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants