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
@@ -19,15 +19,29 @@
<SupportedOSPlatformVersion>28</SupportedOSPlatformVersion>
<TargetPlatformVersion>36</TargetPlatformVersion>
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal. Android requires a monotonically increasing
integer and SemVer does not give you one — deriving it from the version would work until the day
a patch number reached 10 and the arithmetic went backwards, which is the sort of failure that
surfaces as an upload Google refuses for reasons it will not explain.
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
Two of them, and they are two applications rather than two configurations. Android identifies an
app by its package id and its signing certificate together, and refuses an update signed by a
different key — so a build CI signs and a build a person signs cannot replace one another whatever
they are called. Pretending otherwise would ship an installer that fails with
INSTALL_FAILED_UPDATE_INCOMPATIBLE at the one moment somebody is trying to get a fix.
So each channel gets its own id, its own key and its own feed, and neither can update the other by
construction rather than by accident. Both can be installed at once, which is the useful half: a
person testing a nightly does not lose the release they rely on. See ADR 0014.
release — dev.dodotech.dodossh, signed by the key ADR 0011 puts on a machine that is not a runner.
Cut from a v* tag by scripts/release-android.ps1, by a person.
nightly — dev.dodotech.dodossh.nightly, signed by a keystore committed to this repository in the
open. Cut from main by CI, every push.
Default release, so that an unqualified `dotnet build` is the real application and the nightly is
the one you have to ask for. A build with no keystore named at all falls back to the debug key,
which is what a developer's own device gets and is neither channel.
-->
<ApplicationVersion>1</ApplicationVersion>
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
<!--
False here for the same reason the desktop head sets it false: this process formats timestamps
@@ -36,6 +50,71 @@
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!-- ============ the release channel ============ -->
<PropertyGroup Condition="'$(DodoChannel)' == 'release'">
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal on this channel. Android requires a monotonically
increasing integer and SemVer does not give you one — deriving it from the version would work until
the day a patch number reached 10 and the arithmetic went backwards, which is the sort of failure
that surfaces as an install the phone refuses for reasons it will not explain.
Forgetting to bump it is caught rather than shipped: scripts/release-android.ps1 reads the code
already published to the release channel and refuses to build one that is not higher. A guard on a
literal, which is the arrangement this comment argued for, rather than arithmetic nobody checks.
-->
<ApplicationVersion>1</ApplicationVersion>
</PropertyGroup>
<!-- ============ the nightly channel ============ -->
<PropertyGroup Condition="'$(DodoChannel)' == 'nightly'">
<ApplicationId>dev.dodotech.dodossh.nightly</ApplicationId>
<!--
Passed in by CI as the number of commits on the branch, which is monotonic by construction and needs
nobody to remember anything. It is meaningless as a version and is not shown anywhere: versionName
is what a person reads, and it carries MinVer's full answer on this channel including the height —
see the target below.
The fallback exists so that `-p:DodoChannel=nightly` builds outside CI at all, and 1 is deliberately
the lowest it can be: a locally built nightly then cannot replace one installed from the feed, which
is the right way round.
-->
<ApplicationVersion Condition="'$(DodoNightlyVersionCode)' != ''">$(DodoNightlyVersionCode)</ApplicationVersion>
<ApplicationVersion Condition="'$(DodoNightlyVersionCode)' == ''">1</ApplicationVersion>
<!--
◆ THE KEY IS IN THE REPOSITORY, IN THE OPEN, AND THAT IS THE DESIGN RATHER THAN AN OVERSIGHT.
ADR 0011 rule 1 says the release key is never in CI, because a key a workflow can reach is a key held
by everyone who can change a workflow file. This does not break that rule; it sidesteps it by making
the CI key worth nothing. Everybody has it, so holding it grants nothing, so there is nothing to
steal and no secret to configure — which is also why this channel works on a fresh runner with no
setup at all.
What it costs is stated rather than buried: anyone can build an APK this channel's phones will accept
as an update. Reaching one still means being the thing they fetch from, which is a release on
git.dodotech.cloud over TLS — so the practical set is whoever can write a release here, the same set
ADR 0013 names for the desktop. That is an acceptable trade for a nightly and is not one for the
release channel, which is the whole reason there are two.
-->
<AndroidKeyStore>true</AndroidKeyStore>
<AndroidSigningKeyStore>$(MSBuildThisFileDirectory)../../build/nightly.keystore</AndroidSigningKeyStore>
<AndroidSigningKeyAlias>dodossh-nightly</AndroidSigningKeyAlias>
<AndroidSigningStorePass>nightly</AndroidSigningStorePass>
<AndroidSigningKeyPass>nightly</AndroidSigningKeyPass>
</PropertyGroup>
<ItemGroup>
<!--
Which feed this build asks. Assembly metadata rather than a compile-time constant, because the
updater needs the string and not a branch — and because a value baked into the assembly is one a
crash report can be asked for. See AndroidUpdateChannel.
-->
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
</ItemGroup>
<!--
versionName, which is the string a person sees in Android's app info, taken from the same v* tag
as everything else so the two heads cannot claim different versions of one product.
@@ -45,11 +124,41 @@
is still the SDK's default 1.0.0 — an ApplicationDisplayVersion set in a PropertyGroup would read
as though it were derived and would ship 1.0.0 forever. Running after MinVer is what makes it true.
Major.Minor.Patch, without MinVer's prerelease or build-metadata parts: versionName is a free string
to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is not a version anybody can
read back to you over a support conversation.
Major.Minor.Patch on the release channel, without MinVer's prerelease or build-metadata parts:
versionName is a free string to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is
not a version anybody can read back to you over a support conversation.
The nightly channel keeps the whole thing, prerelease height and all, and for the opposite reason.
Every nightly between two tags shares one Major.Minor.Patch, so trimming it would put the same three
numbers on every build for weeks — on the one channel where "which build is this?" is the entire
question being asked. Nobody reads a nightly's version aloud; they paste it.
-->
<Target Name="UseTheDerivedVersionForAndroid" AfterTargets="MinVer" DependsOnTargets="MinVer">
<!--
◆ BeforeTargets on the stub generator, and it assigns an underscore-prefixed property. Both are
deliberate, and the shape this replaced looked more correct while shipping 1.0.0 on every build.
ApplicationDisplayVersion is read by Xamarin.Android.Common.targets in a plain top-level
PropertyGroup — `<_AndroidVersionName>$(ApplicationDisplayVersion)</_AndroidVersionName>` — which is
*evaluation*, not a target. Every project property is already final by the time any target runs, so a
target that sets ApplicationDisplayVersion sets it after the only thing that reads it has finished.
MinVer cannot run at evaluation time, so there is no arrangement of the public property that works.
Measured rather than reasoned about: aapt2 dump badging on the packaged APK said versionName='1.0.0'
while `-getProperty:ApplicationDisplayVersion` said 0.0.0-alpha.0.124. The property was right and the
manifest was not, which is exactly the shape of bug a -getProperty check cannot catch.
So this reaches for _AndroidVersionName, which is the value GenerateMainAndroidManifest is actually
handed, at the last moment before _GenerateJavaStubs runs it. An internal name is a real cost and it
is the smaller one: the alternative is passing -p:ApplicationDisplayVersion from CI and from the
release script, which makes an ordinary `dotnet build` of this head go on lying about its version and
puts the number in two places that can disagree.
ApplicationDisplayVersion is still assigned, and first, so that a caller passing it as a global
property wins — a global beats a target assignment, and _AndroidVersionName then follows theirs.
-->
<Target Name="UseTheDerivedVersionForAndroid"
BeforeTargets="_GenerateJavaStubs"
DependsOnTargets="MinVer">
<PropertyGroup>
<!--
Guarded, because the failure without it is silent and absurd: MinVerMajor and its two siblings
@@ -57,7 +166,10 @@
characters that are a legal Android versionName and are what the phone would then show. Measured,
not imagined; invoking this target on its own produced exactly that.
-->
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != ''">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != '' and '$(DodoChannel)' != 'nightly'">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
<ApplicationDisplayVersion Condition="'$(MinVerVersion)' != '' and '$(DodoChannel)' == 'nightly'">$(MinVerVersion)</ApplicationDisplayVersion>
<_AndroidVersionName Condition="'$(ApplicationDisplayVersion)' != ''">$(ApplicationDisplayVersion)</_AndroidVersionName>
</PropertyGroup>
</Target>