Merge branch 'claude/android-release'
ci / build and test (push) Successful in 1m57s
ci / android head (push) Successful in 3m13s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 23s

This commit is contained in:
2026-08-05 22:49:51 +02:00
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
@@ -116,6 +116,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.
+23
View File
@@ -2005,6 +2005,29 @@ is wrong. Reinstalling then asks for the passphrase rather than for a server.
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
### 16.10 The nightly installs beside the release build and not over it · **the one CI cannot check**
Needs both: a release build installed by walking 16.1, and `DodoSSH.Desktop.Nightly-win-nightly-Setup.exe`
from the `nightly-desktop` release. Install the nightly second, and then start **both**.
**Pass:** two entries in the Start menu, one *DodoSSH* and one *DodoSSH Nightly*; two directories,
`%LOCALAPPDATA%\DodoSSH.Desktop` and `%LOCALAPPDATA%\DodoSSH.Desktop.Nightly`; two profiles,
`%LOCALAPPDATA%\DodoSSH` and `%LOCALAPPDATA%\DodoSSH.Nightly`, each with its own `cache.db` and
`device.key`. The nightly's titlebar says **DodoSSH Nightly** and the release build's says **DodoSSH**
which is the only difference visible while somebody is typing a passphrase into one of them. Signing in to
the nightly leaves the release build signed in and untouched, and the account shows a second device.
**Failure means:** if there is one profile directory, the two builds are sharing a cache and a device key,
and the next nightly carrying a schema migration will upgrade the database the release build opens. If
there is one install directory, the pack ids collide and the nightly has replaced the release build
outright — which is the thing ADR 0013 decision 9 is constructed to make impossible, so it means one of the
four separations has been undone.
**And the direction that matters most:** on the release build, PREFERENCES → UPDATES → CHECK NOW must not
offer a nightly, ever, however many have been published since. It reads a different index and refuses
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
rather than as a bug in the nightly.
---
## Phase 17 — Installing and updating the phone
+17 -3
View File
@@ -595,9 +595,23 @@ targeted, not which RID. Only signing needs Windows tooling, which is why `ci.ym
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
that target has run, and both readers of it — the tag check in `ci.yml` and `scripts/release-windows.ps1`
— pass it. Neither had, and neither had ever run: the CI check is `if:` a tag ref and there are no tags
yet, so the first release would have been refused by its own guard, which would have blamed `fetch-depth`.
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
check is `if:` a tag ref and there are no tags yet, so the first release would have been refused by its own
guard, which would have blamed `fetch-depth`.
**And naming that target requires a restore first, which is a second failure wearing a very different
face.** MinVer arrives as a package, so its target is imported from `obj/*.nuget.g.targets` and does not
exist at all on a clean checkout:
```
error MSB4057: The target "MinVer" does not exist in the project.
```
That reads like a typo in the workflow rather than like a missing restore, and it does not reproduce on any
machine that has built the project before — which is every developer machine and no fresh runner. The
build job's tag check is safe because it runs after that job's own restore; the desktop nightly job and the
release script each restore before reading, deliberately and with a comment saying why.
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**