diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a65d34..d500652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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//… + # — 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/ 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 diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 93fa50b..7a57569 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -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.** 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. +`sdk:10.0-noble` plus a JDK, the Android SDK and the workload; `scripts/ci-android.sh` is everything that +has to happen inside it; and the job keeps on the host only what the host is good at — checkout, git, +publishing. 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. + +**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//…` +— 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 with the Android SDK already in it would put a stranger in the path of a package this project signs and diff --git a/scripts/ci-android.sh b/scripts/ci-android.sh new file mode 100644 index 0000000..b9651fa --- /dev/null +++ b/scripts/ci-android.sh @@ -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"