Files
DodoSSH/scripts/release-android.ps1
T
jaap-jan 253c72d2b7
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
Name the organisation the repository actually lives in
It moved to DodoTech-Public, and every address in the product still said
DodoTech. That looked like it worked, which is the part worth writing down:
Gitea leaves a 301 at the old path and HttpClient follows a redirect on a GET, so
both update channels would have kept polling through it.

What a 301 does not survive is a POST. `vpk upload gitea` publishes the desktop
release by POSTing to that URL, so the stale address would have failed at the one
step the whole feature depends on — and a redirect is a thing an operator can
delete, which turns "works today" into the same silent outage this session has
already spent two commits on.

So both channel constants, both release scripts, the workflow's REPO, the image's
source label and the curl in phase 16 all name the live path. The local remote
too, which had been printing a redirect warning on every push.

Measured after the move: the org, the repo and the nightly release all answer 200
anonymously, and that release now carries both assets — the manifest and a 54 MB
APK. The upload going through also answers the open question about the reverse
proxy's body-size limit, which nothing local could test.
2026-08-05 12:51:19 +02:00

276 lines
11 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
# 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.'