Let the phone replace itself, and give CI a channel it may sign
ci / build and test (push) Successful in 1m53s
ci / android head (push) Failing after 32s
ci / api image (push) Successful in 28s

The Android head had no updater and no release path, and the two are one problem:
Android refuses an update signed by a different key, and CI generates a fresh debug
key in every container. An APK released from a workflow could be installed once and
never updated again — each new one an uninstall, which on this product means losing
the cache, the outbox and the device key.

So there are two channels, and they are two applications because the platform gives
no third option. dev.dodotech.dodossh is cut from a v* tag by a person running
scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners.
dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore
committed here in the open — a key everybody has cannot be stolen and grants nothing
by being held, which is why putting it in CI does not touch the rule. Neither can
update the other, by construction. See ADR 0014.

The android job assumed an image with a JDK and an Android SDK on it, which is what
a GitHub runner is and what this project's is not. It now installs a JDK, fetches
Google's command-line tools, accepts the licences and installs API 36 — each a no-op
where it is already satisfied, and each cached by the persistent runner's own disk
rather than by an action that would move a quarter of a gigabyte to rebuild a
directory that never left.

The client reads a small JSON manifest beside the APK, the counterpart of
releases.win.json, and compares Android's versionCode rather than a version name:
that integer is what the platform itself uses to accept or refuse an install, so
comparing anything else would offer updates the phone then rejects. It fetches, and
then asks Android to ask — the system draws its own confirmation, and from API 26
will not draw even that until unknown sources is on for this application.

IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files
and restarts, so the shell disposes the vault first and that is what zeroes the keys.
On the phone the install is a request and the answer may be no, so disposing first
would answer "not now" with a locked keychain and every shell closed — a punishment
for declining an update.

Two measured bugs found on the way, both older than this work and both invisible to
a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a
top-level PropertyGroup, so the target setting it from MinVer ran after the only
thing that reads it: every APK ever built here said versionName 1.0.0. And nothing
found so far varies the launcher name per channel — four mechanisms tried, all of
them recorded in platform-flags, none of them reaching the label the launcher shows.
The two channels share an icon name for now and are told apart by package name,
version, and what the preferences screen says.
This commit is contained in:
2026-08-04 21:46:01 +02:00
parent f90c331334
commit b4a6c19ac1
18 changed files with 1520 additions and 49 deletions
+249 -17
View File
@@ -259,6 +259,10 @@ jobs:
android:
name: android head
runs-on: [linux]
# Writes the nightly release at the end of the job; see that step for what the capability is and why
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
permissions:
contents: write
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three.
@@ -330,17 +334,129 @@ 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.
#
# 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
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
$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
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y java-17-openjdk-devel
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
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
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
# 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;36.0.0"
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.
- name: install the android workload
run: dotnet workload install android --skip-sign-check
# 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.
- name: install the android sdk platform
run: |
echo "y" | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-36" "build-tools;36.0.0"
- name: restore
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
@@ -353,15 +469,131 @@ jobs:
# 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.
#
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
# docs/adr/0011-android-distribution.md puts the release key on a machine that is not a runner,
# because a signing key reachable from a workflow is a key held by everyone who can change one.
# This APK is a build check. It is not something anybody installs.
- name: package
run: >
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
--no-restore --configuration Release
-t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64
# ◆ 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
code="$(git rev-list --count HEAD)"
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \
--no-restore --configuration Release \
-t:SignAndroidPackage \
-p:DodoChannel=nightly \
-p:DodoNightlyVersionCode="$code"
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="$($ANDROID_HOME/build-tools/36.0.0/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"
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
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
# from this feed and Android's only check is that the signature matches — and this channel's key is
# in the repository for everybody. That is the same capability as the signing key, reached through a
# different door, and it is exactly what ADR 0011 rule 1 keeps off runners.
#
# It is acceptable here for one reason: this is not that channel. A nightly is signed by a key with
# no secrecy to lose, installs under its own package id, and cannot update the application anybody
# is trusting with their credentials. The release channel has none of this — no job, no token, no
# key on a runner — and the two are separate applications so that no mistake here can reach it.
#
# main only. A tag build must not touch this: a v* tag is the release channel's, and cutting it is a
# person's job.
- name: publish the nightly
if: github.ref == 'refs/heads/main'
env:
FORGE: https://git.dodotech.cloud
REPO: DodoTech/DodoSSH
TOKEN: ${{ secrets.GITHUB_TOKEN }}
STAGED: ${{ steps.nightly.outputs.staged }}
VERSION: ${{ steps.nightly.outputs.version }}
run: |
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "No token, so the nightly was built and not published." >&2
echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
echo "without it, and the job's contents: write permission is what asks for it." >&2
exit 1
fi
api="$FORGE/api/v1/repos/$REPO"
auth="Authorization: token $TOKEN"
# Deleted and recreated rather than updated in place. A rolling tag has to move, and moving one
# through this API is two calls with no atomic form either way — so the shape with the fewest
# states is to remove both and make them again. The window where no nightly exists is a few
# seconds and the client's answer to it is the same as to an unreachable forge: try later.
existing="$(curl -fsS -H "$auth" "$api/releases/tags/nightly" 2>/dev/null || true)"
if [ -n "$existing" ]; then
id="$(printf '%s' "$existing" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)"
[ -n "$id" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
fi
curl -fsS -X DELETE -H "$auth" "$api/tags/nightly" >/dev/null 2>&1 || true
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(printf '{"tag_name":"nightly","target_commitish":"%s","name":"Nightly %s","prerelease":true,"body":"Built from %s by CI, signed with the public nightly key. Installs beside the release build, never over it. See docs/adr/0014-android-updates.md."}' \
"$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
"$api/releases")"
release="$(printf '%s' "$created" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)"
if [ -z "$release" ]; then
echo "Gitea accepted the release call and returned no id:" >&2
printf '%s\n' "$created" >&2
exit 1
fi
# The APK first and the manifest last, which is the ordering the client depends on: it reads the
# manifest and then fetches what the manifest names, so a manifest published before its APK is a
# few seconds in which every phone is told to download something that is not there yet.
for file in "$STAGED"/*.apk "$STAGED"/android-nightly.json; do
echo "Uploading $(basename "$file")"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$(basename "$file")" >/dev/null
done
echo "Published nightly $VERSION"
image:
name: api image