Files
DodoSSH/scripts/release-android.ps1
T
laurensandClaude Opus 5 597e5ae016
ci / build and test (push) Successful in 3m17s
ci / android head (push) Successful in 3m45s
ci / api image (push) Successful in 24s
ci / desktop nightly (push) Successful in 1m19s
Take the release credentials off the command line, where anyone could read them
Three scripts prompted for a secret with Read-Host -AsSecureString or read -s,
said in a comment that it therefore never lands in shell history, and then wrote
it straight into the arguments of the next process. The comment was true and was
answering the wrong question. A command line is not private: on Windows one call
to Get-CimInstance Win32_Process hands every argument of every process running as
this user to anything that asks, /proc/<pid>/cmdline is world-readable on Linux,
and process-creation auditing and EDR agents keep their own copy either way. The
values were the Android release signing key's passwords and the forge token —
between them the whole capability to ship an update that every installed copy
will fetch and run, which ADR 0011 rule 1 and ADR 0013 go to some length to keep
off a runner and which was then left in a process listing on a desk.

Both replacements are documented features of the tools rather than tricks.

The Android passwords use .NET for Android's env: prefix, which puts the name of
a variable on the command line and lets MSBuild read the value out of the
environment itself. Set immediately before the build and cleared in a finally, so
a failed signing does not leave them in the shell for whatever runs next, and
-nodeReuse:false so no persistent MSBuild worker outlives the call still holding
the inherited copy in its own environment.

That prefix is unsupported when AndroidPackageFormat is aab, and the failure is
silent — the package would be signed with the literal string
"env:DODOSSH_ANDROID_STOREPASS" as its password. So the format is read back and
the build stops if it is ever changed, in the same shape as the versionCode guard
a few lines above: a wrong release refused here rather than discovered later on
somebody's phone.

vpk documents VPK_TOKEN as --token's environment fallback, so the flag is simply
gone from both desktop scripts and the token never becomes an argument at all.
Unset afterwards on the failure path too, which on macOS needs the exit status
captured rather than a bare || stop_with.

release-macos.sh had the same fault and was missed when the other two were first
reported. Its Apple credentials were already right — a notarytool keychain profile
rather than --password — and only the forge token was exposed.

What this does not claim: an environment block is readable by a process holding
rights over this one, the same as its memory is. The comments say so rather than
implying the values are now safe. What is bought is real and narrower — off the
command line, out of every process listing, out of the audit trail, and out of any
log that echoes the invocation.

Both PowerShell files parse, all three shell scripts pass bash -n, the
AndroidPackageFormat probe evaluates and answers empty so the guard passes, and
dotnet build accepts -nodeReuse:false. The signing itself is not exercised here:
it needs the release keystore, which by design is not on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:31:02 +02:00

326 lines
14 KiB
PowerShell

#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-Public/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
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
#
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
# not. A process's command line is not a secret on Windows: any process running as this user can read
# every argument of every other with one call —
#
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
#
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
# comment above says the passwords are never stored, and with the arguments written that way it was
# true of the disk and false of everything watching the machine.
#
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
# fails loudly rather than silently signing with the literal string if that ever changes.
#
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
# echoes the build invocation, which is the whole of the difference being bought here.
#
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
# The finally clears them from this process either way, and a persistent node would have kept its own
# inherited copy for the next quarter of an hour regardless of what happens here.
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($packageFormat -eq 'aab') {
Stop-With @'
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
that file removes it again.
'@
}
try {
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
# 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 `
-nodeReuse:false `
-t:SignAndroidPackage `
-p:DodoChannel=release `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyStore=$KeystorePath `
-p:AndroidSigningKeyAlias=$KeyAlias `
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
}
finally {
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
# and this runs on the failure path, where it may well not be.
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
}
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.'