Build the phone in a container, because this runner cannot build it at all
ci / build and test (push) Successful in 1m54s
ci / android head (push) Failing after 3m54s
ci / api image (push) Successful in 44s

The runner is Alpine, and .NET for Android does not work on musl. Not "needs setting up" — the SDK's
own MSBuild tasks pull glibc shared objects out of the workload pack into the build process, and a
musl-linked dotnet will not load one:

  error XARLP7000: Error relocating .../libZipSharpNative-3-3.so: __snprintf_chk: symbol not found

That is a glibc fortify symbol musl does not implement, reached through a DllImport rather than an
exec, so gcompat is no help: it gets a glibc *executable* started, which is a different problem. There
is no musl variant of the pack.

Everything this job did on the host to make Android work was therefore treatment of symptoms, mine
included. The missing aapt2 was present. The "unsupported version" was of a binary that had never run.
Both were this one sentence in a different accent, and the loader was the accent, not the sentence.

So the toolchain moves into build/android-build.Dockerfile — Microsoft's own sdk:10.0-noble plus a JDK,
the Android SDK and the workload — and the job keeps on the host only what the host is good at:
checkout, git, publishing. The daemon needed no arranging, since the image job already builds with it
and every Testcontainers suite reaches it over the socket. The image is tagged by the digest of the
Dockerfile that made it, so on a persistent runner every run after the first is a cache hit, and a
change to the toolchain is the only thing that buys a new one.

Built rather than pulled: a community image with the Android SDK already in it would put a stranger in
the path of a package this project signs and publishes. Eleven lines of apt and sdkmanager is the
cheaper trade.

Verified end to end in that image against a real clone rather than reasoned about, which after three
rounds of reasoning seemed the least I could do. Restore under locked mode, Release build, then
SignAndroidPackage:

  package: name='dev.dodotech.dodossh.nightly' versionCode='195' versionName='0.0.0-alpha.0.128'
  Signer #1 certificate SHA-256 digest: a9f067877724ddb0fdc04b637fbd5bfb97df753976616f100b48b522e132ba22

which is the keystore in build/. The versionName carries MinVer's height, so the csproj's target fires
in the container too, and the manifest the feed publishes parses back on the host.

Staging moves from RUNNER_TEMP to artifacts/, which is forced rather than preferred: the package is
made inside a container and read outside one, so it has to land under the bind-mounted checkout.
This commit is contained in:
2026-08-04 23:28:47 +02:00
parent 65256fa337
commit 30a3edb1d4
3 changed files with 246 additions and 216 deletions
+49 -22
View File
@@ -574,36 +574,63 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
on being noticed later than every other one.
**`dotnet workload install` is not a repair, and on a persistent runner that is the difference between a
one-off failure and a permanent one.** It reads the installed-workload records and does nothing when the
workload is listed, whatever is actually on disk. This runner is act's host executor — the checkout path
is `/root/.cache/act/<hash>/hostexecutor`, so `/usr/share/dotnet` survives between runs — and it reached
a state where the android pack's targets were present and `tools/Linux/aapt2` was not. Every later run
reinstalled nothing and failed identically:
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
It fails first inside .NET for Android's tooling resolution:
```
warning : An error occurred trying to start process '…/packs/Microsoft.Android.Sdk.Linux/36.1.69/tools/Linux/aapt2' … No such file or directory
error XA0111: Unsupported version of AAPT2 found at path '…/tools/Linux'
```
**The error names the wrong problem**, which is the part worth remembering: nothing was found, so nothing
had a version, and XA0111 sends you to `Aapt2ToolPath` in the project file — where there is nothing to
find. The warning above it is the real message and it is a warning.
**XA0111 names the wrong problem.** Nothing was found, so nothing had a version, and it sends you to an
`Aapt2ToolPath` in the project file that has never been set. The warning above it is the real message and
it is only a warning. Pointing the build at Google's `aapt2` from `build-tools` instead moves the failure
one step earlier and says it more plainly:
Two changes, and each stands on its own. The build is now given
`-p:Aapt2ToolPath=$ANDROID_HOME/build-tools/<v>`, so it uses Google's aapt2 from the SDK the job installs
itself rather than the workload's copy — the same binary the packaging step already shells out to for
`dump badging`, which makes one tool of what were two and means the manifest the feed publishes is read
by the binary that wrote it. Verified compatible rather than assumed: build-tools 36.0.0 answers
`aapt2 2.20-13193326` and .NET for Android 36.1.43 builds and packages the head against it without
complaint. And the workload step now probes for that pack file and runs `dotnet workload repair` when it
is absent — the probe is a witness rather than the point, since a 5 MB file near the end of a 130 MB
package is what a truncated extraction loses first, and `r8.jar` and `manifestmerger.jar` are what it
loses next.
```
…/build-tools/36.0.0/aapt2: cannot execute: required file not found
```
The pack layout is host-shaped, which the probe has to know: the Linux pack keeps its host binaries under
`tools/Linux/`, the Windows one puts `aapt2.exe` straight in `tools/`. A probe written on Windows and
matching `tools/aapt2*` finds nothing on Linux and repairs a pack that is fine.
**That is bash's wording for `ENOENT` out of `execve`, which for a file that exists means the ELF
interpreter is missing** — not the binary. `aapt2` names `/lib64/ld-linux-x86-64.so.2`, glibc's loader,
which musl does not have. The runner reports `linux-musl-x64`. Both copies of `aapt2` fail for this one
reason and the workload pack was never incomplete.
`gcompat` and `libstdc++` fix that much — measured on `alpine:latest`, where `aapt2` and `zipalign` will
not start bare and both answer their version once those are installed. **And it is not enough**, because
the next thing to fail is not a program the build runs but a library the build *loads*:
```
error XARLP7000: Error relocating …/tools/libZipSharpNative-3-3.so: __snprintf_chk: symbol not found
```
`__snprintf_chk` is a glibc fortify symbol musl does not implement, and this is a `DllImport` from an
MSBuild task — a glibc shared object being pulled into a musl-linked `dotnet` process. `gcompat` supplies
a loader for glibc *executables*, which is a different problem; there is no shim for this and no musl
variant of the pack. **This is the end of the road on Alpine, not a harder step along it.**
So the android job builds in a container instead. `build/android-build.Dockerfile` is Microsoft's own
`sdk:10.0-noble` plus a JDK, the Android SDK and the workload; the job keeps on the host only what the
host is good at — checkout, git, publishing — and hands the build to the image over a bind-mounted
checkout. The daemon needed no arranging: the `image` job already builds with it and every Testcontainers
suite reaches it over the socket. The image is tagged by the digest of the Dockerfile that made it, so on
a persistent runner every run after the first is a cache hit.
Three smaller things worth keeping. **The image is built rather than pulled**, because a community image
with the Android SDK already in it would put a stranger in the path of a package this project signs and
publishes. **`Aapt2ToolPath` points at the SDK's `build-tools`** even though the workload's own copy works
inside the container — it makes the build use the same binary the packaging step reads the versionName
back with, so the manifest the feed publishes is read by the thing that wrote it. And **the pack's layout
is host-shaped**, which matters to anything that goes looking: the Linux pack keeps host binaries under
`tools/Linux/`, the Windows one puts `aapt2.exe` straight in `tools/`.
The thing to carry forward is that **nothing in this toolchain will tell you the C library is wrong.**
Java tools run, `dotnet` runs, `sdkmanager` installs, `restore` succeeds — and then one native thing fails
with a sentence about a file that is plainly on disk. `file` on the binary, or the name of the missing
symbol, answers in one step what the build's own diagnostics will not.
**A Docker `ARG` named `VERSION` silently sets MSBuild's `Version`.** An `ARG` is an environment variable
for the rest of the stage, MSBuild reads environment variables as global properties, and MSBuild property