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 "$tag"
# 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.
# ◆ COPIED IN AND COPIED OUT, NOT BIND-MOUNTED, AND THAT IS NOT A PREFERENCE.
#
# 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.
# `-v "$PWD:/build"` fails here, and it fails quietly: this runner is itself a container with the
# host's Docker socket handed to it, so the workspace path it reports — /root/.cache/act/<hash>/…
# — is a path inside the runner, not on the daemon's host. Docker resolves a bind source on the
# 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
# 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
# MSBUILD : error MSB1009: Project file does not exist.
#
# Nothing in that says "empty mount", and nothing earlier in the job would have caught it: the
# 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: |
set -eu
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
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"
staged=artifacts/android-nightly
rm -rf "$staged"
docker run --rm -i \
-v "$PWD:/build" \
-v "$HOME/.nuget/packages:/root/.nuget/packages" \
# Created rather than run, because the copy has to happen between creating and starting: the
# script being executed arrives with it.
cid="$(docker create \
-e CI=true \
"$ANDROID_BUILD_IMAGE" bash -s <<'IN_CONTAINER'
set -euo pipefail
-v dodossh-nuget:/root/.nuget/packages \
"$ANDROID_BUILD_IMAGE" bash /build/scripts/ci-android.sh)"
staged="artifacts/android-nightly"
mkdir -p "$staged"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
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 \
--no-restore --configuration Release \
-t:SignAndroidPackage \
-p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code" \
-p:Aapt2ToolPath="$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS"
# --attach streams the build log and exits with the container's own status, so a failure in
# there is a failure here.
docker start --attach "$cid"
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
mkdir -p artifacts
docker cp "$cid:/build/$staged" artifacts/
# 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="$("$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.
# Read out of the manifest the container wrote rather than out of anything it printed. A
# container's stdout is the build log as well as its result, so a value 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