Take the phone's aapt2 from the SDK the job installs rather than the workload's
ci / build and test (push) Successful in 1m49s
ci / android head (push) Failing after 7s
ci / api image (push) Successful in 30s

The android job got past restore and died in the .NET Android SDK's own 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. Nothing was found, so nothing had a version, and XA0111 points at
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.

The pack was incomplete, and on this runner it would have stayed that way: act's host executor keeps
/usr/share/dotnet between runs, and `dotnet workload install` reads the installed-workload records and
does nothing when android is listed, whatever is on disk. So two changes, each of which stands alone.

The build and the packaging step are now given -p:Aapt2ToolPath pointing at build-tools, which this job
installs itself and can therefore vouch for. That is 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 binary that wrote it. Checked rather than assumed: build-tools 36.0.0 answers aapt2 2.20 and
.NET for Android 36.1.43 builds and packages this head against it with no complaint. The version is
named once, in the step's env, because three things now depend on it agreeing with itself.

And the workload step probes for the pack file that went missing and repairs the workload when it is
absent. The probe is a witness rather than the point — the build no longer touches that binary — but 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.
This commit is contained in:
2026-08-04 22:32:52 +02:00
parent 7e0a1b2af8
commit 65256fa337
2 changed files with 101 additions and 4 deletions
+70 -4
View File
@@ -394,6 +394,10 @@ jobs:
# versioned separately and this one is the current stable. Floating it would make the toolchain # versioned separately and this one is the current stable. Floating it would make the toolchain
# a moving part of every build. # a moving part of every build.
CMDLINE_TOOLS: commandlinetools-linux-11076708_latest.zip 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: | run: |
set -eu set -eu
SUDO="" SUDO=""
@@ -448,22 +452,83 @@ jobs:
# terminal. See docs/android-port.md. platform-tools comes along because aapt2 and apksigner # terminal. See docs/android-port.md. platform-tools comes along because aapt2 and apksigner
# are what the packaging step actually shells out to. # are what the packaging step actually shells out to.
"$sdk/cmdline-tools/latest/bin/sdkmanager" \ "$sdk/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0" "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" echo "Android SDK at $sdk"
# After the SDK, because the workload's own first-run checks look for one and are quieter when they # 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. # 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 - name: install the android workload
run: dotnet workload install android --skip-sign-check run: |
set -eu
dotnet workload install android --skip-sign-check
root="$(dirname "$(readlink -f "$(command -v dotnet)")")"
# 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
- name: restore - name: restore
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
# 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 - name: build
run: > run: >
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
--no-restore --configuration Release --no-restore --configuration Release
-p:Aapt2ToolPath="$AAPT2_TOOL_PATH"
# Packaging rather than only compiling, because the two failures this head is most exposed to # 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 # are both link-time: a native library with no android ABI, and a managed assembly that
@@ -494,7 +559,8 @@ jobs:
--no-restore --configuration Release \ --no-restore --configuration Release \
-t:SignAndroidPackage \ -t:SignAndroidPackage \
-p:DodoChannel=nightly \ -p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code" -p:DodoNightlyVersionCode="$code" \
-p:Aapt2ToolPath="$AAPT2_TOOL_PATH"
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
@@ -505,7 +571,7 @@ 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="$($ANDROID_HOME/build-tools/36.0.0/aapt2 dump badging "$apk")" badging="$("$AAPT2_TOOL_PATH/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" staged="$RUNNER_TEMP/android-nightly"
+31
View File
@@ -574,6 +574,37 @@ 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
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:
```
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.
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.
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.
**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
names are case-insensitive — so `ARG VERSION` in a build stage sets `Version` for every project built in names are case-insensitive — so `ARG VERSION` in a build stage sets `Version` for every project built in