Copy the checkout into the container, because a socket is not a shared filesystem
ci / build and test (push) Successful in 1m51s
ci / android head (push) Failing after 6m24s
ci / api image (push) Successful in 23s

The container started and could not see the repository:

  MSBUILD : error MSB1009: Project file does not exist.
  Switch: src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj

`-v "$PWD:/build"` cannot work here. This runner is itself a container holding the host's Docker
socket, so the workspace path it reports — /root/.cache/act/<hash>/… — exists in the runner and not on
the daemon's host, which is where Docker resolves a bind source. It finds nothing, creates an empty
directory, and mounts that. Nothing about the failure says so.

Nothing earlier in this workflow would have caught it either, and that is the part worth keeping: the
image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so neither
of the two places this repository already used Docker proves a bind mount would work. I read a working
daemon as a shared filesystem, and they are not the same claim.

`docker cp` goes over the same API and so does not care where the daemon lives. In with the whole
checkout, .git included, since MinVer and the versionCode both read it — the repository is well under a
megabyte packed. Out with the staged package. The NuGet cache becomes a named volume for the same
reason: it lives on the daemon and needs no path either side has to agree on.

The two container steps collapse into one, since with a copy in and a copy out there is nothing to be
gained by paying for both twice, and scripts/ci-android.sh is now what runs inside — a file that can be
read and executed on its own rather than a heredoc inside a workflow.

Exercised locally against a real clone, every step as the job runs it: create, cp in, start --attach,
cp out, parse the manifest on the outside.

  package: name='dev.dodotech.dodossh.nightly' versionCode='196' versionName='0.0.0-alpha.0.129'

The second run took 2m25s against the first run's 8m, which is the named volume doing its job.
This commit is contained in:
2026-08-05 07:29:53 +02:00
parent 30a3edb1d4
commit a18ca56fde
3 changed files with 150 additions and 106 deletions
+39 -101
View File
@@ -391,120 +391,58 @@ jobs:
echo "ANDROID_BUILD_IMAGE=$tag" >> "$GITHUB_ENV" echo "ANDROID_BUILD_IMAGE=$tag" >> "$GITHUB_ENV"
echo "$tag" echo "$tag"
# The checkout is bind-mounted rather than copied in, so what the container writes — obj, bin, the # ◆ COPIED IN AND COPIED OUT, NOT BIND-MOUNTED, AND THAT IS NOT A PREFERENCE.
# 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 # `-v "$PWD:/build"` fails here, and it fails quietly: this runner is itself a container with the
# choice: the packaging step below has to match a versionName out of aapt2 with sed, and nesting # host's Docker socket handed to it, so the workspace path it reports — /root/.cache/act/<hash>/…
# that quoting inside a shell string is how a working command becomes a silently empty variable. # — is a path inside the runner, not on the daemon's host. Docker resolves a bind source on the
# A quoted heredoc passes the script through untouched, so what is written here is what runs. # daemon side, finds nothing there, helpfully creates an empty directory and mounts that. The
# container then starts perfectly and answers:
# #
# CI=true is handed over rather than assumed. Directory.Build.props turns ContinuousIntegrationBuild # MSBUILD : error MSB1009: Project file does not exist.
# 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. # Nothing in that says "empty mount", and nothing earlier in the job would have caught it: the
- name: restore and build # image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so
# neither of them proves a bind mount would work.
#
# `docker cp` goes over the same API and so does not care where the daemon lives. The repository
# is well under a megabyte packed, so copying the whole checkout in — .git included, because
# MinVer and the versionCode both read it — costs a moment.
#
# The NuGet cache is a named volume for the same reason. It lives on the daemon, needs no path
# either side can agree on, and turns the restore into a cache hit on every run after the first.
- name: build and package the nightly
id: nightly
run: | run: |
set -eu set -eu
docker run --rm -i \ staged=artifacts/android-nightly
-v "$PWD:/build" \
-v "$HOME/.nuget/packages:/root/.nuget/packages" \
-e CI=true \
"$ANDROID_BUILD_IMAGE" bash -s <<'IN_CONTAINER'
set -euo pipefail
dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
# 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
# nor be replaced by the release channel — see the csproj, and ADR 0014. The APK this produces is
# meant to be installed; the release APK is cut from a v* tag by a person running
# scripts/release-android.ps1, on a machine that holds the key ADR 0011 rule 1 keeps off runners.
#
# No RuntimeIdentifier, where this step used to pin android-arm64. That produced the smallest
# possible build check and the least installable artefact: an arm64-only APK will not run on an
# x86_64 emulator, which is what most people testing a nightly actually have. Every supported ABI
# costs size on a package nobody ships to users.
#
# versionCode is the commit count, which is monotonic by construction and needs nobody to remember
# anything. It is not a version and is never displayed; versionName carries MinVer's full answer
# including the height, which is what tells two nightlies apart.
- name: package the nightly
id: nightly
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" rm -rf "$staged"
docker run --rm -i \ # Created rather than run, because the copy has to happen between creating and starting: the
-v "$PWD:/build" \ # script being executed arrives with it.
-v "$HOME/.nuget/packages:/root/.nuget/packages" \ cid="$(docker create \
-e CI=true \ -e CI=true \
"$ANDROID_BUILD_IMAGE" bash -s <<'IN_CONTAINER' -v dodossh-nuget:/root/.nuget/packages \
set -euo pipefail "$ANDROID_BUILD_IMAGE" bash /build/scripts/ci-android.sh)"
staged="artifacts/android-nightly" trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
mkdir -p "$staged"
code="$(git rev-list --count HEAD)" # ./. rather than . — with the trailing dot it is the directory's *contents* that land in
# /build, and without it the whole directory lands as /build/<name> and nothing is where the
# script expects it.
docker cp ./. "$cid:/build"
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \ # --attach streams the build log and exits with the container's own status, so a failure in
--no-restore --configuration Release \ # there is a failure here.
-t:SignAndroidPackage \ docker start --attach "$cid"
-p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code" \
-p:Aapt2ToolPath="$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS"
apk="$(find src/DodoSSH.Client.Android/bin/Release -name '*-Signed.apk' | head -1)" mkdir -p artifacts
if [ -z "$apk" ]; then docker cp "$cid:/build/$staged" artifacts/
echo "The package step produced no signed APK." >&2
exit 1
fi
# Read back out of the APK rather than recomputed, so what the feed advertises is what the # Read out of the manifest the container wrote rather than out of anything it printed. A
# bytes say. A versionName derived a second time in shell is a second implementation of the # container's stdout is the build log as well as its result, so a value parsed from it is one
# csproj's target, and the two would drift on the first change to either. # stray MSBuild line away from being wrong.
badging="$("$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS/aapt2" dump badging "$apk")"
name="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
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.
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")" name="$(sed -n 's/.*"versionName":"\([^"]*\)".*/\1/p' "$staged/android-nightly.json")"
if [ -z "$name" ]; then if [ -z "$name" ]; then
echo "The container produced no usable manifest." >&2 echo "The container produced no usable manifest." >&2
+25 -5
View File
@@ -613,11 +613,31 @@ a loader for glibc *executables*, which is a different problem; there is no shim
variant of the pack. **This is the end of the road on Alpine, not a harder step along it.** 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 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 `sdk:10.0-noble` plus a JDK, the Android SDK and the workload; `scripts/ci-android.sh` is everything that
host is good at — checkout, git, publishing — and hands the build to the image over a bind-mounted has to happen inside it; and the job keeps on the host only what the host is good at — checkout, git,
checkout. The daemon needed no arranging: the `image` job already builds with it and every Testcontainers publishing. The daemon needed no arranging: the `image` job already builds with it and every
suite reaches it over the socket. The image is tagged by the digest of the Dockerfile that made it, so on Testcontainers suite reaches it over the socket. The image is tagged by the digest of the Dockerfile that
a persistent runner every run after the first is a cache hit. made it, so on a persistent runner every run after the first is a cache hit.
**A bind mount into that container does not work, and it does not fail either.** This runner is itself a
container holding the host's Docker socket, so the workspace path it reports — `/root/.cache/act/<hash>/…`
— exists in the runner and not on the daemon's host, which is where Docker resolves a bind source. It
finds nothing, creates an empty directory and mounts that. The container then starts perfectly and says:
```
MSBUILD : error MSB1009: Project file does not exist.
```
Nothing in that names an empty mount, and nothing earlier in the job would have caught it: `docker build`
sends its context over the API and Testcontainers mounts nothing, so neither of the two places this
repository was already using Docker proves that a bind mount would work. **A socket is not a shared
filesystem, and every check that looked like it said otherwise was answering a different question.**
`docker cp` goes over the same API and therefore does not care where the daemon lives, which is what the
job does now — in with the whole checkout including `.git`, since MinVer and the versionCode both read
it, and out with the staged package. The repository is well under a megabyte packed, so it costs a
moment. The NuGet cache is a named volume for the same reason: it lives on the daemon and needs no path
either side has to agree on.
Three smaller things worth keeping. **The image is built rather than pulled**, because a community image 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 with the Android SDK already in it would put a stranger in the path of a package this project signs and
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Everything the android CI job does inside the toolchain image, which is everything that needs a
# glibc host. See build/android-build.Dockerfile for why there is an image at all, and
# docs/platform-flags.md for the three rounds of CI that established it.
#
# It runs against a copy of the checkout at /build, put there with `docker cp` rather than a bind
# mount — see the job for why — so it may read .git and it may write anywhere. What it leaves in
# artifacts/android-nightly is what the job copies back out and publishes.
#
# Nothing here holds a secret. The key this signs with is committed to this repository in the open and
# is meant to be; the token that publishes the result never enters this container.
set -euo pipefail
project=src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
staged=artifacts/android-nightly
# Aapt2ToolPath takes the directory and aapt2 itself is inside it. Both come from the Android SDK in
# this image rather than from the copy inside the workload pack: both work here, and using the SDK's
# makes the build and the versionName read below the same binary, so the manifest the feed publishes
# is read by the thing that wrote it.
build_tools="$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS"
echo "==> restore"
# Locked mode, the same gate the solution restore gives every other project. This head is not in
# DodoSSH.slnx — it needs a workload the other jobs have no reason to install — so this is the only
# place its lock file is ever checked. It has silently gone stale before.
dotnet restore "$project" --locked-mode
echo "==> build"
dotnet build "$project" \
--no-restore --configuration Release \
"-p:Aapt2ToolPath=$build_tools"
echo "==> package the nightly"
# 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.
#
# versionCode is the commit count: monotonic by construction, and nobody has to remember anything. It
# is not a version and is never displayed. versionName carries MinVer's full answer including the
# prerelease height, which is what tells two nightlies apart.
#
# No RuntimeIdentifier, where this once pinned android-arm64. That produced the smallest possible build
# check and the least installable artefact — an arm64-only APK will not run on an x86_64 emulator,
# which is what most people testing a nightly actually have.
code="$(git rev-list --count HEAD)"
dotnet build "$project" \
--no-restore --configuration Release \
-t:SignAndroidPackage \
-p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code" \
"-p:Aapt2ToolPath=$build_tools"
apk="$(find src/DodoSSH.Client.Android/bin/Release -name '*-Signed.apk' | head -1)"
if [ -z "$apk" ]; then
echo "The package step produced no signed APK." >&2
exit 1
fi
# 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="$("$build_tools/aapt2" dump badging "$apk")"
name="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
if [ -z "$name" ]; then
echo "aapt2 reported no versionName for $apk." >&2
printf '%s\n' "$badging" | head -3 >&2
exit 1
fi
mkdir -p "$staged"
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.
printf '{"versionCode":%s,"versionName":"%s","apk":"DodoSSH-nightly-%s.apk"}' \
"$code" "$name" "$name" > "$staged/android-nightly.json"
printf '%s\n' "$badging" | head -1
ls -la "$staged"