Public Access
Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.
**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.
**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.
**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. 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. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.
**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.
Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.
The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.
----
Three defects surfaced on the way, none of them in the feature being built.
**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.
**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.
**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.
All three are in docs/platform-flags.md, which is where the next person will look.
----
Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.
Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<#
|
||||
.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/0012-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 15 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 15 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 0012 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 15 of docs/manual-checks.md."
|
||||
Write-Host ' 2. Then: pwsh -File scripts/release-windows.ps1 -Upload'
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
Reference in New Issue
Block a user