Give the desktop a nightly channel, the way the phone has one

ADR 0014 gave the phone a nightly and ADR 0013 rule 3 gave the desktop none, so
the two heads had different answers to the same question — how does somebody try
what is on main? — for no reason except the order the work happened in. This is
the desktop's answer: CI publishes a build from main on every push, and it
installs beside the release one rather than over it.

The phone gets its separation from the platform. Android refuses an update signed
by a different key, so its two channels cannot replace one another whatever
anybody does. Nothing refuses anything here: Velopack applies what its feed serves
and verifies no signature. So all of it is construction, and there are four
separations because each closes a different door.

A pack id each, so the two install in different directories and neither feed's
package can be applied to the other's install. A Velopack channel each —
win and win-nightly — so neither build ever reads the other's release index; the
name reaches the wire as releases.win-nightly.json, which is why the constant in
VelopackUpdateChannel and the argument in ci.yml have to agree or the channel
answers nothing forever with no error. A prerelease flag, so the release channel
cannot see the nightly even by accident. And a profile directory each, which is
the one that is easy to skip and would hurt most: the cache schema is migrated on
every launch, before unlock, so a shared profile means a nightly quietly upgrading
a database the release build then opens. Both are installed at once by design, so
that is an ordinary Tuesday rather than a corner case.

The prerelease flag turns out to be load-bearing across heads as well. The phone's
release channel reads releases/latest, which skips prereleases — so a desktop
nightly published as a stable release would become the newest release in this
repository and every phone on the release channel would start failing its check
against a release carrying no Android manifest.

Which build this is arrives as assembly metadata, the same mechanism and the same
reasoning as the Android head: the updater needs the string rather than a branch,
and a value baked into the assembly is one a crash report can be asked for. Three
things read it — the feed, the prerelease flag, and the profile — and one more
shows it: the titlebar says DodoSSH Nightly. Everything else that distinguishes
the two is somewhere nobody is looking while typing a passphrase into one of them.

The version needed a floor and it is applied to the whole build rather than to the
packaging. MinVer answers 0.0.0-alpha.0.N until the first v* tag and vpk refuses
anything below 0.0.1, so the job lifts the patch digit and keeps the height —
through MinVerVersionOverride, so the assemblies carry the same number the
installer does. Packing a version the assembly disagreed with would put one string
on the preferences screen and another in the feed, which is the screen somebody
reads when asked which nightly they are on.

Two things found by running it rather than reading it. -t:MinVer needs a restore
first, because the target arrives with the package and MSB4057 on a clean checkout
reads like a typo in the workflow rather than a missing restore; the release
script had the same gap and now restores before it reads. And vpk rejects an empty
--packVersion loudly, which is how a broken version handoff announces itself
rather than shipping a package called 1.0.0.

Rule 3 is untouched. The release channel still has no job, no token and no runner,
and the two channels cannot see each other. What a nightly costs is written where
somebody reads it before installing one: whoever can write a release here can put
a build on every nightly machine, which is fine for a build being tried and is not
fine for a build holding somebody's infrastructure credentials.

Verified by running the job's own steps against a clone in a Linux container:
DodoSSH.Desktop.Nightly-win-nightly-Setup.exe, and an index naming pack id
DodoSSH.Desktop.Nightly at 0.0.1-alpha.0.144. The upload itself is the one step
not exercised — it needs a real forge and a write token, and check 16.10 is what
walks the half no runner can.
This commit is contained in:
2026-08-05 22:35:51 +02:00
parent 3d3d0bc95f
commit af0e29a98b
16 changed files with 712 additions and 24 deletions
@@ -228,6 +228,56 @@ changes.
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
layer down, declined there for reasons that do not stop applying because the vendor changed.
### 9. There is a second desktop channel, published by CI, and it is a second application
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
none, which left the two heads with different answers to the same question — *how does somebody try what
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
it is the phone's arrangement with one difference that changes how much of it has to be built.
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
one another because the installer refuses a package signed by a different key; a mistake there is loud.
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
construction:
| | release | nightly |
| --- | --- | --- |
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
| Velopack channel | `win` | `win-nightly` |
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
| cut by | a person, from a `v*` tag | CI, on every push to main |
Four separations rather than one, because each closes a different door. The pack id decides the install
directory and what an installed client matches an update against, so it is what makes a nightly install
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
release build then opens.
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
reads `releases/latest`, which skips prereleases. A desktop nightly published as a stable release would
become the newest release in this repository, and every phone on the release channel would start failing
its check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
sake and for that one.
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
this repository can put a build on every nightly desktop, because the client applies what the feed serves
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
— packing a number the assemblies disagree with would put one version in the installer and another on the
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
## Consequences
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
+9
View File
@@ -104,6 +104,15 @@ is the entire reason there are two.
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
caches are encrypted under keys held by two package identities the platform keeps apart.
**The desktop has since taken this arrangement, and it had to build what Android is given.**
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
CI publishes from main, installing beside the release build rather than over it. The difference worth
carrying back here is that every separation this ADR gets from the platform — different package identity,
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
a nightly install and an arbitrary build.
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
that an installed copy can now learn a newer one exists.