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
+275
View File
@@ -0,0 +1,275 @@
#Requires -Version 7.0
<#
.SYNOPSIS
Builds, signs and publishes the Android release channel.
.DESCRIPTION
The phone's counterpart of release-windows.ps1, and the same two-phase shape for the same reason: the
thing that is uploaded must be the thing that was installed and checked, so nothing is rebuilt between
the two phases.
Phase 1 (no token, no upload) builds and signs the APK with the project's release keystore and stops,
printing where it is. Phase 2 (-Upload) attaches it to the tag's release on the project's own forge.
── WHY THIS IS A SCRIPT AND NOT A CI JOB ──────────────────────────────────────────────────────────
docs/adr/0011-android-distribution.md rule 1 puts the release key on a machine that is not a runner,
because a key a workflow can reach is a key held by everyone who can change a workflow file. That is
the whole of it, and docs/adr/0014-android-updates.md explains why the convenience of a CI release
went to a separate nightly channel with a deliberately public key instead of coming here.
The forge token is the second half of the same capability and is treated the same way: prompted for,
never stored, never a workflow secret. Whoever can write a release can publish an update every phone
on this channel will install, which is the signing key reached through a different door.
.PARAMETER Upload
Runs phase 2 against the package phase 1 produced. Prompts for a forge token.
.PARAMETER KeystorePath
The release keystore. Defaults to the DODOSSH_ANDROID_KEYSTORE environment variable.
.PARAMETER KeyAlias
The key inside it. Defaults to DODOSSH_ANDROID_ALIAS, then to 'dodossh'.
.EXAMPLE
./scripts/release-android.ps1
./scripts/release-android.ps1 -Upload
#>
[CmdletBinding()]
param(
[switch] $Upload,
[string] $KeystorePath = $env:DODOSSH_ANDROID_KEYSTORE,
[string] $KeyAlias = $(if ($env:DODOSSH_ANDROID_ALIAS) { $env:DODOSSH_ANDROID_ALIAS } else { 'dodossh' })
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj'
$Staging = Join-Path $RepoRoot 'artifacts/android'
$Forge = 'https://git.dodotech.cloud'
$Repo = 'DodoTech/DodoSSH'
$Api = "$Forge/api/v1/repos/$Repo"
function Write-Step([string] $Message) {
Write-Host ''
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Stop-With([string] $Message) {
Write-Host ''
Write-Host $Message -ForegroundColor Red
exit 1
}
# ============ what the tag says, which is the version of record ============
function Get-TagVersion {
$tag = & git -C $RepoRoot describe --exact-match --tags HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or -not $tag) {
Stop-With @'
HEAD is not on a tag, so there is no version to release.
The tag is the version of record here — MinVer derives every assembly version from it, and a build
from an untagged commit answers 0.0.0-alpha.0.N rather than failing. Tag first:
git tag v0.1.0
'@
}
if ($tag -notmatch '^v\d+\.\d+\.\d+') {
Stop-With "HEAD is on '$tag', which is not a v* release tag."
}
return $tag.Substring(1)
}
# ============ what is already published, so a versionCode cannot go backwards ============
function Get-PublishedVersionCode {
# Android refuses an install whose versionCode is not higher than the installed one, and
# ApplicationVersion in the csproj is a hand-bumped literal on this channel by decision — see the
# comment there. This is the guard that comment promises: forgetting the bump fails here rather than
# on somebody's phone, where it presents as an install that simply will not go on.
try {
$latest = Invoke-RestMethod -Uri "$Api/releases/latest" -Method Get -ErrorAction Stop
}
catch {
Write-Host ' no published release yet, so any versionCode will do' -ForegroundColor DarkGray
return 0
}
$manifest = $latest.assets | Where-Object { $_.name -eq 'android-release.json' } | Select-Object -First 1
if (-not $manifest) {
Write-Host ' the latest release carries no android manifest' -ForegroundColor DarkGray
return 0
}
$published = Invoke-RestMethod -Uri $manifest.browser_download_url -Method Get
return [int] $published.versionCode
}
if (-not $Upload) {
# ================================ phase 1 ================================
Write-Step 'Checking the working tree'
if (& git -C $RepoRoot status --porcelain) {
Stop-With 'The working tree has changes. A release is built from a commit, not from a desk.'
}
$version = Get-TagVersion
Write-Host " v$version"
if (-not $KeystorePath) {
Stop-With @'
No keystore. Pass -KeystorePath, or set DODOSSH_ANDROID_KEYSTORE.
This is the key ADR 0011 rule 1 keeps off runners and out of this repository. It is the application's
identity for its whole life: losing it means no installed copy can ever be updated again.
'@
}
if (-not (Test-Path $KeystorePath)) {
Stop-With "No keystore at $KeystorePath."
}
Write-Step 'Reading what is already published'
$publishedCode = Get-PublishedVersionCode
$declaredCode = [int] (& dotnet msbuild $Project -getProperty:ApplicationVersion -nologo `
| ForEach-Object { $_.Trim() } | Where-Object { $_ })
Write-Host " published versionCode $publishedCode, this build declares $declaredCode"
if ($declaredCode -le $publishedCode) {
Stop-With @"
ApplicationVersion in DodoSSH.Client.Android.csproj is $declaredCode and the published channel is
already at $publishedCode. Android refuses an install that does not increase it, so this package
could be uploaded and would not install over anything.
Bump ApplicationVersion, commit, move the tag, and run this again.
"@
}
Write-Step 'Restoring'
& dotnet restore $Project --locked-mode
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
Write-Step 'Building and signing'
# The keystore's passwords are prompted for and held only for this call. They are not parameters,
# because a parameter is a thing that ends up in shell history.
$storePass = Read-Host -Prompt 'Keystore password' -AsSecureString
$keyPass = Read-Host -Prompt "Password for key '$KeyAlias' (blank to reuse)" -AsSecureString
$storePlain = [System.Net.NetworkCredential]::new('', $storePass).Password
$keyPlain = [System.Net.NetworkCredential]::new('', $keyPass).Password
if (-not $keyPlain) { $keyPlain = $storePlain }
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
New-Item -ItemType Directory -Path $Staging | Out-Null
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
& dotnet build $Project --no-restore --configuration Release `
-t:SignAndroidPackage `
-p:DodoChannel=release `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$KeystorePath `
-p:AndroidSigningKeyAlias=$KeyAlias `
-p:AndroidSigningStorePass=$storePlain `
-p:AndroidSigningKeyPass=$keyPlain
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
$signed = Get-ChildItem -Path (Join-Path $RepoRoot 'src/DodoSSH.Client.Android/bin/Release') `
-Recurse -Filter '*-Signed.apk' | Select-Object -First 1
if (-not $signed) { Stop-With 'The build produced no signed APK.' }
$apkName = "DodoSSH-$version.apk"
Copy-Item $signed.FullName (Join-Path $Staging $apkName)
# The manifest the client reads. versionCode is the comparison and versionName is what a person sees;
# see docs/adr/0014-android-updates.md for why it is not the other way round.
$manifest = [ordered] @{
versionCode = $declaredCode
versionName = $version
apk = $apkName
}
$manifest | ConvertTo-Json -Compress `
| Set-Content -Path (Join-Path $Staging 'android-release.json') -Encoding utf8 -NoNewline
Write-Step 'Built'
Get-ChildItem $Staging | Format-Table Name, Length
Write-Host @"
Install this on a phone and walk docs/manual-checks.md phase 17 before uploading anything. The APK in
artifacts/android is what phase 2 uploads nothing is rebuilt so what you check is what ships.
./scripts/release-android.ps1 -Upload
"@ -ForegroundColor Yellow
exit 0
}
# ================================ phase 2 ================================
Write-Step 'Uploading'
$version = Get-TagVersion
$apkPath = Join-Path $Staging "DodoSSH-$version.apk"
$manifestPath = Join-Path $Staging 'android-release.json'
if (-not (Test-Path $apkPath) -or -not (Test-Path $manifestPath)) {
Stop-With "No package for v$version in $Staging. Run phase 1 first."
}
$token = Read-Host -Prompt 'Forge token with release write' -AsSecureString
$tokenPlain = [System.Net.NetworkCredential]::new('', $token).Password
if (-not $tokenPlain) { Stop-With 'No token, so nothing was uploaded.' }
$headers = @{ Authorization = "token $tokenPlain" }
# --merge in spirit: the tag push may already have created a release entry, and creating a second one for
# the same tag fails. Reused where it exists.
try {
$release = Invoke-RestMethod -Uri "$Api/releases/tags/v$version" -Headers $headers -Method Get
Write-Host " reusing the existing release for v$version"
}
catch {
$body = @{
tag_name = "v$version"
name = "DodoSSH $version"
prerelease = $version -match '-'
} | ConvertTo-Json
$release = Invoke-RestMethod -Uri "$Api/releases" -Headers $headers -Method Post `
-ContentType 'application/json' -Body $body
Write-Host " created the release for v$version"
}
# The APK first and the manifest last, which is the order the client depends on: it reads the manifest
# and then fetches what the manifest names, so a manifest published before its APK is a window in which
# every phone is told to download something that is not there.
foreach ($file in @($apkPath, $manifestPath)) {
$name = Split-Path -Leaf $file
# Replaced rather than added beside. Gitea will happily hold two assets with one name, and the client
# takes the first — which after a re-upload is whichever the API happens to list first.
$existing = $release.assets | Where-Object { $_.name -eq $name } | Select-Object -First 1
if ($existing) {
Invoke-RestMethod -Uri "$Api/releases/$($release.id)/assets/$($existing.id)" `
-Headers $headers -Method Delete | Out-Null
}
Write-Host " $name"
Invoke-RestMethod -Uri "$Api/releases/$($release.id)/assets?name=$name" `
-Headers $headers -Method Post -Form @{ attachment = Get-Item $file } | Out-Null
}
Write-Step "Published v$version"
Write-Host 'Phones on the release channel will see it within six hours, or on the next CHECK NOW.'