Public Access
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.
276 lines
11 KiB
PowerShell
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.'
|