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
+116 -191
View File
@@ -320,12 +320,6 @@ jobs:
with: with:
fetch-depth: 0 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. # 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 # 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 # `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 # 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 # 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. # 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 # ============ ◆ WHY THIS HEAD IS BUILT IN A CONTAINER AND THE OTHER TWO ARE NOT ============
# 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.
# #
# The order matters once: the SDK's licence acceptance and every sdkmanager call are Java programs, # .NET for Android cannot build on this runner. Not "needs setting up" — cannot. The runner is
# so the JDK has to be first. # Alpine, and the SDK's own MSBuild tasks load glibc shared libraries out of the workload pack
- name: ensure a jdk # 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: | run: |
set -eu set -eu
SUDO="" SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo" [ "$(id -u)" -eq 0 ] || SUDO="sudo"
# An existing one is used whatever its provenance — the runner image's, or a previous run's. # Only the client. The socket is already there and a daemon is already answering on it, so
# 17 is the floor: .NET for Android 36 refuses to start javac below it, and says so in a # installing an engine would start a second one beside the one in use. The image job carries
# message that names a path rather than a version. # the same step and the same reasoning; change one, look at the other.
if command -v javac >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
have="$(javac -version 2>&1 | sed 's/javac //; s/\..*//')" echo "Installing the docker cli"
if [ "${have:-0}" -ge 17 ]; then if command -v apk >/dev/null 2>&1; then
echo "javac $(javac -version 2>&1)" $SUDO apk add --no-cache docker-cli
exit 0 elif command -v apt-get >/dev/null 2>&1; then
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 update -qq
$SUDO apt-get install -y --no-install-recommends openjdk-17-jdk-headless $SUDO apt-get install -y --no-install-recommends docker.io
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 elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y java-17-openjdk-devel $SUDO dnf install -y docker-cli
else else
echo "No apt-get, apk or dnf here, so a JDK cannot be installed from inside the job." >&2 echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2
echo "Add one to the runner's image, or point JAVA_HOME at one." >&2 echo "inside the job. Add it to the runner's image." >&2
exit 1 exit 1
fi 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"
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y "$tool"
fi fi
done docker --version
# Under the runner's own tool directory rather than /opt, so it needs no root and survives # Tagged by the digest of the Dockerfile that made it, so the tag moves exactly when the toolchain
# between runs on a persistent runner. ANDROID_HOME and ANDROID_SDK_ROOT are both exported # does and never otherwise. This runner is persistent, so the first build pays for a JDK, an
# because the .NET Android SDK reads one and sdkmanager reads the other, and a job that set only # Android SDK and the android workload — several minutes — and every run after it is a cache hit
# one fails halfway through with a message about neither. # that prints two lines. A change to that Dockerfile is what buys a new one.
sdk="${ANDROID_HOME:-$HOME/android-sdk}" - name: the android toolchain image
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/<channel>/ — 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
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/<v>/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
run: | run: |
set -eu 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 # The checkout is bind-mounted rather than copied in, so what the container writes — obj, bin, the
# host binaries under a named directory and the Windows one puts aapt2.exe straight in tools/. # signed package — is on the host the moment it exits and the publishing step can read it. The
# This job is linux-only, so the Linux path is the one to look for and a probe that accepted # NuGet cache is mounted for the mirror-image reason: a container that started empty would fetch
# either would quietly pass on a pack for the wrong host. # every package again on every run.
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." # The script arrives on stdin under `bash -s` rather than as `bash -c '…'`, which is not a style
dotnet workload repair # choice: the packaging step below has to match a versionName out of aapt2 with sed, and nesting
fi # 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 docker run --rm -i \
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode -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 dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
# 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"
# Packaging rather than only compiling, because the two failures this head is most exposed to # Aapt2ToolPath, so the build takes aapt2 from the Android SDK in this image rather than the
# are both link-time: a native library with no android ABI, and a managed assembly that # copy inside the workload pack. Both work here; using the SDK's makes it the same binary the
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile. # 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 # ◆ 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 # 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: | run: |
set -euo pipefail 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)" code="$(git rev-list --count HEAD)"
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \ dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \
@@ -560,7 +473,7 @@ jobs:
-t:SignAndroidPackage \ -t:SignAndroidPackage \
-p:DodoChannel=nightly \ -p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code" \ -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)" apk="$(find src/DodoSSH.Client.Android/bin/Release -name '*-Signed.apk' | head -1)"
if [ -z "$apk" ]; then 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 # 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 # 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. # 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)" name="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
staged="$RUNNER_TEMP/android-nightly" if [ -z "$name" ]; then
rm -rf "$staged" echo "aapt2 reported no versionName for $apk." >&2
mkdir -p "$staged" exit 1
fi
cp "$apk" "$staged/DodoSSH-nightly-$name.apk" cp "$apk" "$staged/DodoSSH-nightly-$name.apk"
# The channel manifest, which is what the client reads and the whole reason the feed is # 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 # 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 # 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. # update the platform then rejects. versionName is for the person reading the banner.
cat > "$staged/android-nightly.json" <<JSON printf '{"versionCode":%s,"versionName":"%s","apk":"DodoSSH-nightly-%s.apk"}' \
{"versionCode":$code,"versionName":"$name","apk":"DodoSSH-nightly-$name.apk"} "$code" "$name" "$name" > "$staged/android-nightly.json"
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 "staged=$staged" >> "$GITHUB_OUTPUT"
echo "version=$name" >> "$GITHUB_OUTPUT" echo "version=$name" >> "$GITHUB_OUTPUT"
ls -la "$staged" ls -la "$staged"
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS. # ◆ 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 # Whoever can write a release here can put a build on every nightly phone, because the client fetches
+78
View File
@@ -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/<channel>/ — 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
+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 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. 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 **.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
one-off failure and a permanent one.** It reads the installed-workload records and does nothing when the toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
workload is listed, whatever is actually on disk. This runner is act's host executor — the checkout path into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
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 It fails first inside .NET for Android's tooling resolution:
reinstalled nothing and failed identically:
``` ```
warning : An error occurred trying to start process '…/packs/Microsoft.Android.Sdk.Linux/36.1.69/tools/Linux/aapt2' … No such file or directory 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' 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 **XA0111 names the wrong problem.** Nothing was found, so nothing had a version, and it sends you to an
had a version, and XA0111 sends you to `Aapt2ToolPath` in the project file — where there is nothing to `Aapt2ToolPath` in the project file that has never been set. The warning above it is the real message and
find. The warning above it is the real message and it is a warning. 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 …/build-tools/36.0.0/aapt2: cannot execute: required file not found
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.
The pack layout is host-shaped, which the probe has to know: the Linux pack keeps its host binaries under **That is bash's wording for `ENOENT` out of `execve`, which for a file that exists means the ELF
`tools/Linux/`, the Windows one puts `aapt2.exe` straight in `tools/`. A probe written on Windows and interpreter is missing** — not the binary. `aapt2` names `/lib64/ld-linux-x86-64.so.2`, glibc's loader,
matching `tools/aapt2*` finds nothing on Linux and repairs a pack that is fine. 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 **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 for the rest of the stage, MSBuild reads environment variables as global properties, and MSBuild property