diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20e48ae..3a65d34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,12 +320,6 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - # A job of its own, because DodoSSH.Client.Android is deliberately not in DodoSSH.slnx. # Adding it there would make the android workload and a full Android SDK a prerequisite of # `dotnet build DodoSSH.slnx` for everyone — including the build job above, which needs @@ -334,205 +328,107 @@ jobs: # The cost of keeping it out is that nothing in the main job would notice this head # breaking, which for a project sharing view models with the desktop one is a matter of # when rather than whether. This job is that notice. - # ============ the toolchain this job needs and a bare runner does not have ============ # - # This job assumed an image with a JDK and an Android SDK already on it, which is what a GitHub - # ubuntu-latest runner is and what this project's own runner is not. Every step below installs one - # of the things that assumption was making, and each is a no-op where it is already satisfied. + # ============ ◆ WHY THIS HEAD IS BUILT IN A CONTAINER AND THE OTHER TWO ARE NOT ============ # - # The order matters once: the SDK's licence acceptance and every sdkmanager call are Java programs, - # so the JDK has to be first. - - name: ensure a jdk + # .NET for Android cannot build on this runner. Not "needs setting up" — cannot. The runner is + # Alpine, and the SDK's own MSBuild tasks load glibc shared libraries out of the workload pack + # into the build process, which a musl-linked dotnet will not do: + # + # error XARLP7000: Error relocating …/libZipSharpNative-3-3.so: __snprintf_chk: symbol not found + # + # There is no musl variant of that pack and no shim for a library loaded in-process — gcompat + # gets a glibc *executable* started and is no help whatsoever here. Everything this job used to + # do on the host to make Android work was therefore treatment of symptoms: an aapt2 reported + # missing while being present, an "unsupported version" of a binary that had never run. All of + # them were this one sentence in a different accent, and docs/platform-flags.md has the history + # so the next reader does not repeat the three rounds it took to hear it. + # + # So the toolchain moves into an image that is glibc, and the job keeps on the host only what the + # host is good at: checkout, git, and publishing. The daemon needs no arranging — the image job + # builds with it and every Testcontainers suite in the build job reaches it over the socket. + # + # There is no setup-dotnet here any more, and its absence is the point: nothing outside the + # container compiles anything, so a dotnet on the host would be a second toolchain with nothing + # to do and a version nobody checks. + - name: ensure the docker cli run: | set -eu SUDO="" [ "$(id -u)" -eq 0 ] || SUDO="sudo" - # An existing one is used whatever its provenance — the runner image's, or a previous run's. - # 17 is the floor: .NET for Android 36 refuses to start javac below it, and says so in a - # message that names a path rather than a version. - if command -v javac >/dev/null 2>&1; then - have="$(javac -version 2>&1 | sed 's/javac //; s/\..*//')" - if [ "${have:-0}" -ge 17 ]; then - echo "javac $(javac -version 2>&1)" - exit 0 - fi - echo "javac $have is below 17; installing a newer one" - fi - - if command -v apt-get >/dev/null 2>&1; then - $SUDO apt-get update -qq - $SUDO apt-get install -y --no-install-recommends openjdk-17-jdk-headless - elif command -v apk >/dev/null 2>&1; then - $SUDO apk add --no-cache openjdk17-jdk - elif command -v dnf >/dev/null 2>&1; then - $SUDO dnf install -y java-17-openjdk-devel - else - echo "No apt-get, apk or dnf here, so a JDK cannot be installed from inside the job." >&2 - echo "Add one to the runner's image, or point JAVA_HOME at one." >&2 - exit 1 - fi - - # Exported for every later step. dirname twice: `which javac` is .../bin/javac and JAVA_HOME is - # the directory above bin. readlink -f because the packaged javac is a symlink into - # /usr/lib/jvm, and the link is what the alternatives system points at rather than the real - # home the Android SDK wants. - home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" - echo "JAVA_HOME=$home" >> "$GITHUB_ENV" - echo "javac $(javac -version 2>&1) at $home" - - # The SDK, from Google's own zip rather than a package. There is no distribution package for it that - # carries the platform this head needs, and unlike the JDK there is nothing to reuse: the archive is - # the supported way to get cmdline-tools and it is what every CI image does behind the scenes. - # - # Cached by path rather than by an actions/cache, deliberately. This runner is persistent, so the - # directory survives between runs and the check below turns the whole step into an echo; a cache - # action would upload and download a quarter of a gigabyte to reproduce a directory that never left. - - name: ensure the android sdk - env: - # Pinned, and the number is the commandline-tools release rather than an API level — they are - # versioned separately and this one is the current stable. Floating it would make the toolchain - # a moving part of every build. - CMDLINE_TOOLS: commandlinetools-linux-11076708_latest.zip - # Named once here because three things now depend on it being one version: what sdkmanager - # installs, what the build is told to use for aapt2, and what the packaging step reads the - # versionName back with. - BUILD_TOOLS: 36.0.0 - run: | - set -eu - SUDO="" - [ "$(id -u)" -eq 0 ] || SUDO="sudo" - - for tool in curl unzip; do - command -v "$tool" >/dev/null 2>&1 && continue - echo "Installing $tool" - if command -v apt-get >/dev/null 2>&1; then - $SUDO apt-get update -qq && $SUDO apt-get install -y --no-install-recommends "$tool" - elif command -v apk >/dev/null 2>&1; then - $SUDO apk add --no-cache "$tool" + # Only the client. The socket is already there and a daemon is already answering on it, so + # installing an engine would start a second one beside the one in use. The image job carries + # the same step and the same reasoning; change one, look at the other. + if ! command -v docker >/dev/null 2>&1; then + echo "Installing the docker cli" + if command -v apk >/dev/null 2>&1; then + $SUDO apk add --no-cache docker-cli + elif command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y --no-install-recommends docker.io elif command -v dnf >/dev/null 2>&1; then - $SUDO dnf install -y "$tool" + $SUDO dnf install -y docker-cli + else + echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2 + echo "inside the job. Add it to the runner's image." >&2 + exit 1 fi - done - - # Under the runner's own tool directory rather than /opt, so it needs no root and survives - # between runs on a persistent runner. ANDROID_HOME and ANDROID_SDK_ROOT are both exported - # because the .NET Android SDK reads one and sdkmanager reads the other, and a job that set only - # one fails halfway through with a message about neither. - sdk="${ANDROID_HOME:-$HOME/android-sdk}" - mkdir -p "$sdk" - - if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then - echo "Fetching $CMDLINE_TOOLS" - curl -fsSL -o /tmp/cmdline-tools.zip \ - "https://dl.google.com/android/repository/$CMDLINE_TOOLS" - rm -rf /tmp/cmdline-tools-unpacked - unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-unpacked - # The archive unpacks to a directory called cmdline-tools, and sdkmanager insists on living - # at cmdline-tools// — unpacking it in place gives cmdline-tools/cmdline-tools and - # every later call fails with "Could not determine SDK root". - mkdir -p "$sdk/cmdline-tools" - mv /tmp/cmdline-tools-unpacked/cmdline-tools "$sdk/cmdline-tools/latest" - rm -f /tmp/cmdline-tools.zip fi + docker --version - echo "ANDROID_HOME=$sdk" >> "$GITHUB_ENV" - echo "ANDROID_SDK_ROOT=$sdk" >> "$GITHUB_ENV" - export ANDROID_HOME="$sdk" ANDROID_SDK_ROOT="$sdk" - - # yes rather than echo y: there are several licences and each wants its own answer, so a single - # y accepts the first and leaves the rest pending — which surfaces later as a package that - # "failed to install" with no reason given. The pipe is allowed to break when sdkmanager exits - # first, which is what the || true is for and is not hiding a failure: the install below is - # what reports one. - yes 2>/dev/null | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true - - # API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a - # net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its - # terminal. See docs/android-port.md. platform-tools comes along because aapt2 and apksigner - # are what the packaging step actually shells out to. - "$sdk/cmdline-tools/latest/bin/sdkmanager" \ - "platform-tools" "platforms;android-36" "build-tools;$BUILD_TOOLS" - - # ============ aapt2, which the build is told to take from here and not from the workload ============ - # - # .NET for Android ships its own aapt2 inside the workload pack, at - # packs/Microsoft.Android.Sdk.Linux//tools/Linux/aapt2, and prefers it when Aapt2ToolPath is - # unset. On this runner that binary could not be started at all: - # - # warning : An error occurred trying to start process '.../tools/Linux/aapt2' … No such file - # error XA0111: Unsupported version of AAPT2 found at path '.../tools/Linux' - # - # The second line is what the build reports and it is misleading — nothing was found, so nothing - # had a version. This runner is persistent (act's host executor, so /usr/share/dotnet survives - # between runs), and `dotnet workload install` treats an already-listed workload as satisfied - # whatever is actually on disk, so a pack left incomplete by an interrupted install stays - # incomplete for every later run. That is a state no step here can detect and none can repair. - # - # So the build is pointed at the aapt2 from build-tools instead: Google's own, installed by the - # line above, in a directory this job creates and can therefore vouch for. It is also already - # the aapt2 the packaging step shells out to for `dump badging`, so this makes one tool of what - # were two — and the manifest the feed publishes is now read by the same binary that wrote it. - # - # Verified rather than assumed to be usable, because the failure mode above is precisely a file - # that exists and will not run, and because a version check here says so in one line instead of - # as an XA0111 four minutes into a build. - aapt2="$sdk/build-tools/$BUILD_TOOLS" - - if ! "$aapt2/aapt2" version; then - echo "aapt2 at $aapt2 will not run, so there is no usable one on this runner." >&2 - exit 1 - fi - - echo "AAPT2_TOOL_PATH=$aapt2" >> "$GITHUB_ENV" - echo "Android SDK at $sdk" - - # After the SDK, because the workload's own first-run checks look for one and are quieter when they - # find it. --skip-sign-check is for the workload package feed, not for anything this project signs. - # - # The repair is here because `install` is not one. It reads the installed-workload records and does - # nothing when android is listed, whatever is on disk — so a pack left half-extracted by an - # interrupted run stays half-extracted forever on a runner whose filesystem persists, and every - # later build fails on whichever file happened not to make it. This runner has already produced - # exactly that, with a missing tools/Linux/aapt2 (see the SDK step above). - # - # tools/Linux/aapt2 is the probe rather than the point: the build no longer uses that binary at all. - # It is a 5 MB file near the end of a 130 MB package, which makes it a good witness for a truncated - # extraction — and if it is absent then r8.jar and manifestmerger.jar, which the build does need, - # are the next things to go. Repairing costs a re-download and only happens when something is - # already wrong; the alternative is a mystery every few months. - - name: install the android workload + # Tagged by the digest of the Dockerfile that made it, so the tag moves exactly when the toolchain + # does and never otherwise. This runner is persistent, so the first build pays for a JDK, an + # Android SDK and the android workload — several minutes — and every run after it is a cache hit + # that prints two lines. A change to that Dockerfile is what buys a new one. + - name: the android toolchain image run: | set -eu - dotnet workload install android --skip-sign-check + tag="dodossh-android-build:$(sha256sum build/android-build.Dockerfile | cut -c1-16)" + docker build -t "$tag" -f build/android-build.Dockerfile build - root="$(dirname "$(readlink -f "$(command -v dotnet)")")" + echo "ANDROID_BUILD_IMAGE=$tag" >> "$GITHUB_ENV" + echo "$tag" - # tools/Linux/ and not tools/, because the pack's layout is host-shaped: the Linux pack puts its - # host binaries under a named directory and the Windows one puts aapt2.exe straight in tools/. - # This job is linux-only, so the Linux path is the one to look for and a probe that accepted - # either would quietly pass on a pack for the wrong host. - if [ -d "$root/packs" ] && ! ls "$root"/packs/Microsoft.Android.Sdk.*/*/tools/Linux/aapt2 >/dev/null 2>&1; then - echo "::warning::The android workload pack is missing files; repairing it." - dotnet workload repair - fi + # The checkout is bind-mounted rather than copied in, so what the container writes — obj, bin, the + # signed package — is on the host the moment it exits and the publishing step can read it. The + # NuGet cache is mounted for the mirror-image reason: a container that started empty would fetch + # every package again on every run. + # + # The script arrives on stdin under `bash -s` rather than as `bash -c '…'`, which is not a style + # choice: the packaging step below has to match a versionName out of aapt2 with sed, and nesting + # that quoting inside a shell string is how a working command becomes a silently empty variable. + # A quoted heredoc passes the script through untouched, so what is written here is what runs. + # + # CI=true is handed over rather than assumed. Directory.Build.props turns ContinuousIntegrationBuild + # on when it is set — which is what normalises the source paths baked into the PDBs — and a + # container inherits none of the runner's environment unless it is given it. + - name: restore and build + run: | + set -eu - - name: restore - run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode + docker run --rm -i \ + -v "$PWD:/build" \ + -v "$HOME/.nuget/packages:/root/.nuget/packages" \ + -e CI=true \ + "$ANDROID_BUILD_IMAGE" bash -s <<'IN_CONTAINER' + set -euo pipefail - # Aapt2ToolPath on both this and the packaging step, not on one: it is a directory the workload - # would otherwise pick for itself, and a build that resolved a different aapt2 than the package - # step would be a difference nobody could see until one of them failed. See the SDK step. - - name: build - run: > - dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj - --no-restore --configuration Release - -p:Aapt2ToolPath="$AAPT2_TOOL_PATH" + dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode - # Packaging rather than only compiling, because the two failures this head is most exposed to - # are both link-time: a native library with no android ABI, and a managed assembly that - # resolves for net10.0 but has nothing to dex. Neither shows up in a compile. + # Aapt2ToolPath, so the build takes aapt2 from the Android SDK in this image rather than the + # copy inside the workload pack. Both work here; using the SDK's makes it 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. + dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \ + --no-restore --configuration Release \ + -p:Aapt2ToolPath="$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" + IN_CONTAINER + + # Packaging rather than only compiling, because the two failures this head is most exposed to are + # both link-time: a native library with no android ABI, and a managed assembly that resolves for + # net10.0 but has nothing to dex. Neither shows up in a compile. # # ◆ THE NIGHTLY CHANNEL, WHICH IS AN INSTALLABLE APPLICATION AND NOT THE ONE. It has its own package # id and is signed by a keystore committed to this repository in the open, so it can neither replace @@ -553,6 +449,23 @@ jobs: run: | set -euo pipefail + # Under artifacts/ rather than RUNNER_TEMP, and that is forced rather than preferred: the + # package is produced inside a container and read outside one, so it has to land somewhere + # both can see, which means somewhere under the bind-mounted checkout. .gitignore already + # excludes artifacts/* with two named exceptions, neither of which is this. + staged="artifacts/android-nightly" + rm -rf "$staged" + + docker run --rm -i \ + -v "$PWD:/build" \ + -v "$HOME/.nuget/packages:/root/.nuget/packages" \ + -e CI=true \ + "$ANDROID_BUILD_IMAGE" bash -s <<'IN_CONTAINER' + set -euo pipefail + + staged="artifacts/android-nightly" + mkdir -p "$staged" + code="$(git rev-list --count HEAD)" dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \ @@ -560,7 +473,7 @@ jobs: -t:SignAndroidPackage \ -p:DodoChannel=nightly \ -p:DodoNightlyVersionCode="$code" \ - -p:Aapt2ToolPath="$AAPT2_TOOL_PATH" + -p:Aapt2ToolPath="$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" apk="$(find src/DodoSSH.Client.Android/bin/Release -name '*-Signed.apk' | head -1)" if [ -z "$apk" ]; then @@ -571,26 +484,38 @@ jobs: # Read back out of the APK rather than recomputed, so what the feed advertises is what the # bytes say. A versionName derived a second time in shell is a second implementation of the # csproj's target, and the two would drift on the first change to either. - badging="$("$AAPT2_TOOL_PATH/aapt2" dump badging "$apk")" + badging="$("$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS/aapt2" dump badging "$apk")" name="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)" - staged="$RUNNER_TEMP/android-nightly" - rm -rf "$staged" - mkdir -p "$staged" + if [ -z "$name" ]; then + echo "aapt2 reported no versionName for $apk." >&2 + exit 1 + fi + cp "$apk" "$staged/DodoSSH-nightly-$name.apk" # The channel manifest, which is what the client reads and the whole reason the feed is # machine-readable at all. versionCode is the comparison — it is the number Android itself uses # to accept or refuse an install, so comparing anything else would let the client offer an # update the platform then rejects. versionName is for the person reading the banner. - cat > "$staged/android-nightly.json" < "$staged/android-nightly.json" + IN_CONTAINER + + # Read back out of the manifest the container just wrote, rather than passed out of it. A + # container's stdout is the build log as well as its return value, so anything parsed from it + # is one stray MSBuild line away from being wrong. + name="$(sed -n 's/.*"versionName":"\([^"]*\)".*/\1/p' "$staged/android-nightly.json")" + if [ -z "$name" ]; then + echo "The container produced no usable manifest." >&2 + exit 1 + fi echo "staged=$staged" >> "$GITHUB_OUTPUT" echo "version=$name" >> "$GITHUB_OUTPUT" ls -la "$staged" + # ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS. # # Whoever can write a release here can put a build on every nightly phone, because the client fetches diff --git a/build/android-build.Dockerfile b/build/android-build.Dockerfile new file mode 100644 index 0000000..99434cb --- /dev/null +++ b/build/android-build.Dockerfile @@ -0,0 +1,78 @@ +# The toolchain the Android head is built in, and the reason it is a container at all. +# +# docker build -f build/android-build.Dockerfile build +# +# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────── +# This project's runner is Alpine, and .NET for Android cannot build there. Not "is awkward +# to install" — cannot. The SDK's own MSBuild tasks P/Invoke into glibc shared libraries +# shipped inside the workload pack, and a musl-linked dotnet cannot load one: +# +# error XARLP7000: Error relocating …/libZipSharpNative-3-3.so: __snprintf_chk: symbol not found +# +# That is a glibc fortify symbol musl does not have, in a library loaded into the build +# process rather than run beside it. gcompat supplies a loader for glibc *executables* and +# is no help at all here; there is no musl variant of the pack. See docs/platform-flags.md. +# +# So the job stays on the host for everything that works there — checkout, git, publishing — +# and hands the build to this image, which is glibc and carries the whole toolchain. The +# runner already has a Docker daemon: the image job builds with it and every Testcontainers +# suite reaches it over the socket. +# +# ── WHY IT IS BUILT RATHER THAN PULLED ────────────────────────────────────────────────── +# There are community images with .NET and the Android SDK already in them, and using one +# would put a stranger in the path of a package this project signs and publishes. This is +# eleven lines of apt and sdkmanager over Microsoft's own base image, and the runner's +# daemon is persistent, so every run after the first is a cache hit. + +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble + +ENV DOTNET_NOLOGO=true \ + DOTNET_CLI_TELEMETRY_OPTOUT=true \ + DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true + +# 17 is the floor rather than the preference: .NET for Android 36 refuses to start javac below +# it, and says so in a message that names a path rather than a version. +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends openjdk-17-jdk-headless curl unzip \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ + ANDROID_HOME=/opt/android-sdk \ + ANDROID_SDK_ROOT=/opt/android-sdk + +# Pinned, and the number is the commandline-tools release rather than an API level — they are +# versioned separately. Floating it would make the toolchain a moving part of every build. +ARG CMDLINE_TOOLS=commandlinetools-linux-11076708_latest.zip + +# The archive unpacks to a directory called cmdline-tools, and sdkmanager insists on living at +# cmdline-tools// — unpacking it in place gives cmdline-tools/cmdline-tools and every +# later call fails with "Could not determine SDK root". +RUN curl -fsSL -o /tmp/tools.zip "https://dl.google.com/android/repository/$CMDLINE_TOOLS" \ + && mkdir -p "$ANDROID_HOME/cmdline-tools" \ + && unzip -q /tmp/tools.zip -d /tmp/tools \ + && mv /tmp/tools/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" \ + && rm -rf /tmp/tools /tmp/tools.zip + +# ANDROID_BUILD_TOOLS is read by the job as well as here, so the version is named once. API 36 +# specifically, and it is not a preference: Avalonia.Controls.WebView ships only a +# net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its +# terminal. See docs/android-port.md. +# +# yes rather than echo y: there are several licences and each wants its own answer, so a single +# y accepts the first and leaves the rest pending — which surfaces later as a package that +# "failed to install" with no reason given. +ENV ANDROID_BUILD_TOOLS=36.0.0 +RUN yes 2>/dev/null | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null 2>&1 || true \ + && "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ + "platform-tools" "platforms;android-36" "build-tools;$ANDROID_BUILD_TOOLS" > /dev/null \ + && "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS/aapt2" version + +# --skip-sign-check is for the workload package feed and not for anything this project signs. +RUN dotnet workload install android --skip-sign-check + +# The build runs as root over a bind-mounted checkout that git will otherwise refuse to read as +# somebody else's — and MinVer answering 0.0.0-alpha.0 because it could not open the repository +# is a warning, not an error, so the version would be wrong rather than absent. +RUN git config --global --add safe.directory '*' + +WORKDIR /build diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 7814777..93fa50b 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -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//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/`, 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