Public Access
Build the phone in a container, because this runner cannot build it at all
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:
+116
-191
@@ -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
|
||||
# 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 openjdk-17-jdk-headless
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache openjdk17-jdk
|
||||
$SUDO apt-get install -y --no-install-recommends docker.io
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
$SUDO dnf install -y java-17-openjdk-devel
|
||||
$SUDO dnf install -y docker-cli
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
done
|
||||
docker --version
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user