<# .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%\ 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 }