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:
2026-08-04 17:04:41 +02:00
parent 176df67861
commit 6728a0a597
66 changed files with 3190 additions and 44 deletions
+113 -1
View File
@@ -91,7 +91,17 @@ jobs:
"give the runner an image with node 20 or newer if actions misbehave."
fi
# fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
# answer here a client that never updates.
#
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
# copy, change all three.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
@@ -104,6 +114,31 @@ jobs:
- name: restore
run: dotnet restore DodoSSH.slnx --locked-mode
# The version comes from the tag, so on a tag build there are two ways to say the same number
# and they can disagree — a moved tag, a tag on the wrong commit, or a checkout that somehow
# still lost its history. What makes that worth a step of its own is that the disagreement is
# silent everywhere else: MinVer answers 0.0.0-alpha.0.N rather than failing, the build goes
# green, the package is cut, and the symptom arrives weeks later as clients that never update.
#
# -getProperty evaluates without building, so this costs a second and runs before the build.
- name: the tag and the version agree
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/v}"
declared="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
-getProperty:Version -nologo | tr -d '[:space:]')"
if [ "$tag" != "$declared" ]; then
echo "The tag says v$tag and MinVer computed $declared." >&2
echo >&2
echo "These come from the same place, so a mismatch means the checkout did not see the" >&2
echo "tag it is building — most likely fetch-depth, which must stay 0 in every job here." >&2
exit 1
fi
echo "v$declared"
# No `dotnet format --verify-no-changes` step. It re-analysed the whole solution before
# the build did, for minutes, to check something the build already checks: IDE0055 is an
# error in .editorconfig and TreatWarningsAsErrors is on, so a misformatted file fails
@@ -150,6 +185,34 @@ jobs:
- name: test
run: dotnet test DodoSSH.slnx --no-build --configuration Release
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
# failure mode is a restore graph or a native asset that resolves for net10.0 and not for
# net10.0/win-x64, which nobody would see until a person was halfway through cutting a release
# on a Windows machine. vpk can pack a Windows package from Linux; only signing needs Windows,
# and this repository signs nothing yet, so proving the publish here is worth the minutes.
#
# RestoreLockedMode=false for this command only, and it is not a loosened gate. The committed
# lock files are deliberately RID-free: declaring win-x64 on the desktop head writes a
# net10.0/win-x64 target into every project it references transitively, which includes
# DodoSSH.Contracts and DodoSSH.Crypto — and the API's Dockerfile restores those with no RID
# under locked mode, so the image job would fail NU1004. The gate is the locked solution
# restore at the top of this job, which is unchanged.
#
# It rewrites the lock files as it goes; nothing after this step reads them, and the runner's
# checkout is thrown away. The release script does the same thing and puts them back, because
# there the tree is somebody's working copy.
#
# After the tests rather than before them, so a red suite does not first spend a hundred
# megabytes pulling a win-x64 runtime pack. main and tags only, for the same reason: a break
# found by the person about to release is found early enough.
- name: the windows publish still resolves
if: github.event_name != 'pull_request'
run: >
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
--configuration Release --runtime win-x64 --self-contained true
-p:RestoreLockedMode=false
--output "$RUNNER_TEMP/win-x64-check"
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
@@ -241,7 +304,17 @@ jobs:
"give the runner an image with node 20 or newer if actions misbehave."
fi
# fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
# answer here a client that never updates.
#
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
# copy, change all three.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
@@ -281,7 +354,7 @@ jobs:
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
#
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
# docs/adr/0010-android-distribution.md puts the release key on a machine that is not a runner,
# docs/adr/0011-android-distribution.md puts the release key on a machine that is not a runner,
# because a signing key reachable from a workflow is a key held by everyone who can change one.
# This APK is a build check. It is not something anybody installs.
- name: package
@@ -343,7 +416,17 @@ jobs:
"give the runner an image with node 20 or newer if actions misbehave."
fi
# fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
# answer here a client that never updates.
#
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
# copy, change all three.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# The daemon and the client are two different things to have, and this runner had only
# one of them. Testcontainers reaches Docker straight over /var/run/docker.sock from a
@@ -449,8 +532,18 @@ jobs:
;;
esac
# The version MSBuild is allowed to see, which is not the same string as the one above.
# `version` is a docker tag and is `main-<short sha>` on a main build; handing that to
# -p:Version fails the publish with NETSDK1018. So this is set only when it is a real
# version, and the Dockerfile leaves the SDK default alone when it is empty.
assembly_version=""
case "$GITHUB_REF" in
refs/tags/v*) assembly_version="${GITHUB_REF#refs/tags/v}" ;;
esac
echo "tags=$tags" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "assemblyVersion=$assembly_version" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
echo "Tagging: $tags"
@@ -462,6 +555,7 @@ jobs:
env:
TAGS: ${{ steps.tags.outputs.tags }}
VERSION: ${{ steps.tags.outputs.version }}
ASSEMBLY_VERSION: ${{ steps.tags.outputs.assemblyVersion }}
REVISION: ${{ github.sha }}
CREATED: ${{ steps.tags.outputs.created }}
run: |
@@ -477,6 +571,7 @@ jobs:
--pull \
--file src/DodoSSH.Api/Dockerfile \
--build-arg "VERSION=$VERSION" \
--build-arg "ASSEMBLY_VERSION=$ASSEMBLY_VERSION" \
--build-arg "REVISION=$REVISION" \
--build-arg "CREATED=$CREATED" \
"${args[@]}" \
@@ -547,3 +642,20 @@ jobs:
- name: log out
if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud
# There is no job here that publishes the desktop client, and there is not going to be one. Two
# independent reasons, and both need saying because someone will fix one and think they are done.
#
# The smaller one is mechanical: vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows
# tooling, and every job in this file is runs-on: [linux]. A Windows runner would answer that.
#
# The larger one is that a Windows runner would not answer the other. Velopack clients fetch from the
# release feed and do not verify a package signature when they apply it, so whoever can write a release
# on this repository can publish an update that every installed client downloads and runs. That is the
# same capability as the signing key, reached through a different door — and docs/adr/0011 rule 1 puts
# that capability on a machine which is not a runner, because a workflow secret is held by everyone who
# can change a workflow file. See docs/adr/0012-desktop-distribution-and-updates.md.
#
# What cuts a release is scripts/release-windows.ps1, run by a person. What this file does is prove the
# thing still builds and packages, which is the same division of labour the android job above already
# has: it packages an APK nobody installs, so that a link-time break fails here rather than later.