#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.'