Files
DodoSSH/scripts/release-windows.ps1
T
jaap-jan 3ead865f01 Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
2026-08-04 17:52:57 +02:00

283 lines
13 KiB
PowerShell

<#
.SYNOPSIS
Builds, packages and publishes the Windows desktop client.
.DESCRIPTION
Run by a person, on a Windows machine that is not a CI runner. That is not an accident of tooling —
docs/adr/0011-android-distribution.md rule 1 puts the capability to ship somebody a build on a machine
which is not a runner, and docs/adr/0013-desktop-distribution-and-updates.md explains why the token that
writes a Gitea release is that capability: Velopack clients trust their feed and do not verify a package
signature when they apply it, so whoever can write a release can ship an update every install runs.
Two phases, and the split is the design rather than a convenience.
1. Without -Upload: builds, packs, and stops. Nothing has left this machine.
Install the Setup.exe it names, and walk Phase 16 of docs/manual-checks.md.
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
so the bytes that reach users are the bytes that were installed and checked.
The token is prompted for rather than read from a file or an environment variable, and only in the phase
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
in a shell's memory the better.
.PARAMETER Upload
Publish the packages already in Releases/ instead of building.
.PARAMETER SkipTests
Skip the test run. For a re-pack of a tag CI has already gone green on.
.EXAMPLE
pwsh -File scripts/release-windows.ps1
pwsh -File scripts/release-windows.ps1 -Upload
#>
#Requires -Version 7.0
# PowerShell 7, and stated so the failure is a clear message rather than a confusing one: this script reads
# $IsWindows, which does not exist in Windows PowerShell 5.1 and under Set-StrictMode would throw about an
# unset variable — sending the reader after a typo rather than after the shell they are using.
[CmdletBinding()]
param(
[switch] $Upload,
[switch] $SkipTests
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Velopack's identity for this application, and it is effectively irreversible.
#
# It is what an installed client matches an update against and the directory it installs into, so changing
# it later orphans every existing install — still running, never updated, invisible to the new one.
#
# DodoSSH.Desktop and not DodoSSH, for a specific reason worth keeping next to the value: Velopack installs
# to %LOCALAPPDATA%\<packId> and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
# where ClientPaths keeps the encrypted cache, the outbox of changes not yet pushed, and the device key.
# Sharing the directory would mean the uninstaller silently taking a user's un-synced work with it.
$PackId = 'DodoSSH.Desktop'
# What a person sees, in the Start menu and in Add/Remove Programs. The distinct pack id costs nothing here.
$PackTitle = 'DodoSSH'
$PackAuthors = 'DodoTech'
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
$RepoUrl = 'https://git.dodotech.cloud/DodoTech/DodoSSH'
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
# error at all: the client checks, finds nothing, and reports itself up to date forever.
$Channel = 'win'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.App/DodoSSH.Client.App.csproj'
$PublishDir = Join-Path $RepoRoot 'publish/win-x64'
$ReleasesDir = Join-Path $RepoRoot 'Releases'
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
}
if (-not $IsWindows) {
# vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows tooling. This is the smaller of
# the two reasons a runner cannot do this job; see the comment at the foot of .github/workflows/ci.yml
# for the larger one.
Stop-With 'This builds a Windows package and has to run on Windows.'
}
Push-Location $RepoRoot
try {
# ---- What is being released -----------------------------------------------------------------------
$version = (& dotnet msbuild $Project -getProperty:Version -nologo) -replace '\s', ''
if ([string]::IsNullOrWhiteSpace($version)) {
Stop-With 'Could not read the version from MSBuild.'
}
$tag = "v$version"
Write-Step "DodoSSH $version ($PackId, channel $Channel)"
if ($Upload) {
# ---- Phase 2: publish what phase 1 built ------------------------------------------------------
$setup = Get-ChildItem $ReleasesDir -Filter '*Setup*.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $setup) {
Stop-With "Nothing to upload: $ReleasesDir has no Setup executable. Run this without -Upload first."
}
Write-Host "About to publish the contents of $ReleasesDir to $RepoUrl as $tag."
Write-Host 'Only do this once you have installed it and walked Phase 16 of docs/manual-checks.md.'
# Read-Host -AsSecureString so the token is never echoed and never lands in the shell's history.
$secure = Read-Host -Prompt 'Gitea token (write:repository)' -AsSecureString
$token = [System.Net.NetworkCredential]::new('', $secure).Password
if ([string]::IsNullOrWhiteSpace($token)) {
Stop-With 'No token given.'
}
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
# same tag, so a release candidate is a prerelease in both channels or in neither.
$uploadArgs = @(
'upload', 'gitea',
'--repoUrl', $RepoUrl,
'--token', $token,
'--outputDir', $ReleasesDir,
'--channel', $Channel,
'--releaseName', $tag,
'--tag', $tag,
'--merge',
'--publish'
)
if ($version -match '-') {
$uploadArgs += '--pre'
}
Write-Step 'Uploading'
& dotnet vpk @uploadArgs
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
Write-Step "Published $tag."
return
}
# ---- Phase 1: build and pack ----------------------------------------------------------------------
if ((git status --porcelain) -ne $null) {
Stop-With 'The working tree is not clean. A release is cut from a commit, not from a desk.'
}
$headTag = git describe --exact-match --tags HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($headTag)) {
Stop-With "HEAD is not tagged. Tag it $tag first, or change the version and tag that."
}
if ($headTag -ne $tag) {
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the
# day somebody pins a version by hand this is the guard that notices.
Stop-With "HEAD is tagged $headTag but the computed version is $version."
}
Write-Step 'Restoring tools'
& dotnet tool restore
if ($LASTEXITCODE -ne 0) { Stop-With 'dotnet tool restore failed.' }
Write-Step 'Restoring packages (locked, exactly as CI does)'
& dotnet restore (Join-Path $RepoRoot 'DodoSSH.slnx') --locked-mode
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed. A lock file that only works on Linux fails here.' }
Write-Step 'Building'
& dotnet build (Join-Path $RepoRoot 'DodoSSH.slnx') --no-restore --configuration Release
if ($LASTEXITCODE -ne 0) { Stop-With 'Build failed.' }
if (-not $SkipTests) {
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
# on trust from CI, because a tag is the one build nobody is watching — the same argument ci.yml
# already makes for running the whole workflow on a tag.
Write-Step 'Testing'
& dotnet test (Join-Path $RepoRoot 'DodoSSH.slnx') --no-build --configuration Release
if ($LASTEXITCODE -ne 0) { Stop-With 'Tests failed.' }
}
Write-Step 'Publishing win-x64'
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force }
# Self-contained: .NET 10 is recent enough that almost no machine has the runtime, and the usual
# objection — that runtime patches then need an application update — is answered by the updater this
# very script exists to feed. Not single-file: the native libraries ship per RID, deltas would stop
# working, and a self-extracting bundle puts the executable under a temp path deep enough to break
# WebView2 (docs/platform-flags.md).
# RestoreLockedMode=false, and the lock files put back straight afterwards. Both halves need saying.
#
# A RID-specific publish resolves a graph the committed lock files do not describe, because they are
# deliberately kept RID-free: declaring win-x64 on the project writes a net10.0/win-x64 target into
# every project it references transitively, including DodoSSH.Contracts and DodoSSH.Crypto — and the
# API's Dockerfile then restores those with no RID under locked mode and fails NU1004. Packaging the
# desktop client would have broken the server's image build. See the comment in the head's csproj.
#
# So this one command restores unlocked. It is a supervised build, from a tag, run by a person; the
# gate that matters is the locked solution restore two steps above, which is untouched and is the same
# command CI runs.
& dotnet publish $Project `
--configuration Release `
--runtime win-x64 `
--self-contained true `
--output $PublishDir `
-p:RestoreLockedMode=false
if ($LASTEXITCODE -ne 0) { Stop-With 'Publish failed.' }
# An unlocked restore rewrites the lock files it walked, adding the win-x64 target. Left there, the
# next commit would carry exactly the change that breaks the image build — so they go back. Safe to do
# bluntly because this script refuses to run on a dirty tree, so anything modified here is its own.
& git checkout -- '*packages.lock.json'
if ($LASTEXITCODE -ne 0) { Stop-With 'Could not restore the lock files after publishing.' }
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for
# an application that never checks for updates — which looks completely normal until the next release
# goes out and nobody receives it.
foreach ($required in @('DodoSSH.exe', 'Velopack.dll')) {
if (-not (Test-Path (Join-Path $PublishDir $required))) {
Stop-With "$required is missing from $PublishDir."
}
}
$sizeMb = [math]::Round(((Get-ChildItem $PublishDir -Recurse -File | Measure-Object Length -Sum).Sum / 1MB), 1)
Write-Host " $sizeMb MB in $((Get-ChildItem $PublishDir -Recurse -File).Count) files"
New-Item -ItemType Directory -Force -Path $ReleasesDir | Out-Null
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
# release has no predecessor, and a hard failure here would make cutting it impossible.
Write-Step 'Fetching the previous release, for deltas'
& dotnet vpk download gitea --repoUrl $RepoUrl --outputDir $ReleasesDir --channel $Channel
if ($LASTEXITCODE -ne 0) {
Write-Host ' Nothing came down. This package will be full-only, which is right for a first release.' -ForegroundColor Yellow
}
Write-Step 'Packing'
# No --signParams. Every installer therefore raises SmartScreen's "Windows protected your PC" on first
# run, once per user — Mark-of-the-Web is applied by the browser that downloads Setup.exe, so in-app
# updates, which this application fetches itself and applies from a local file, never trip it.
#
# This is the one line that changes when a certificate is bought. See ADR 0013 for what it costs and
# what the trigger for buying one is.
& dotnet vpk pack `
--packId $PackId `
--packVersion $version `
--packDir $PublishDir `
--packTitle $PackTitle `
--packAuthors $PackAuthors `
--mainExe 'DodoSSH.exe' `
--icon (Join-Path $RepoRoot 'src/DodoSSH.Client.App/Assets/dodossh.ico') `
--channel $Channel `
--outputDir $ReleasesDir
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk pack failed.' }
Write-Step 'Built, and deliberately not uploaded'
Get-ChildItem $ReleasesDir -File |
Sort-Object Length -Descending |
Select-Object Name, @{ n = 'MB'; e = { [math]::Round($_.Length / 1MB, 1) } } |
Format-Table -AutoSize
Write-Host 'Next:'
Write-Host " 1. Install the Setup executable above and walk Phase 16 of docs/manual-checks.md."
Write-Host ' 2. Then: pwsh -File scripts/release-windows.ps1 -Upload'
}
finally {
Pop-Location
}