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
+119 -194
View File
@@ -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/<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
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/<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
# 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" <<JSON
{"versionCode":$code,"versionName":"$name","apk":"DodoSSH-nightly-$name.apk"}
JSON
printf '{"versionCode":%s,"versionName":"%s","apk":"DodoSSH-nightly-%s.apk"}' \
"$code" "$name" "$name" > "$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