Package the Linux desktop head as an RPM, and publish a nightly one
ci / build and test (push) Successful in 3m1s
ci / android head (push) Successful in 3m39s
ci / desktop nightly (push) Successful in 1m7s
ci / api image (push) Successful in 22s

The third desktop platform shipped as nothing: a checkout somebody built.
VelopackUpdateChannel already named the two platforms it serves rather than
negating the one it does not, so that a Linux package would be an addition
there rather than something that had silently half-happened. This is it, and
it is deliberately not a Velopack channel.

There is no updater in the package at all. On this platform dnf is already an
updater the user trusts, audits and controls, and a second one answering to
this project instead is the capability ADR 0013 rule 4 spends a page keeping
unsettable. The cost is real and is written down rather than glossed: nothing
tells a Linux user that a newer build exists, because there is no repository
to tell them.

Rule 3 is untouched. The build job packages the release-shaped RPM on every
main build and every tag and uploads nothing, exactly as it does for Windows
and macOS; the desktop job packages the nightly-shaped one and puts it on the
rolling nightly-desktop release beside the Windows feed. Two RPMs with
different names owning different paths cannot replace one another, so rule 9's
separations come almost free here — the profile split still belongs to the
build, through DodoChannel.

vpk verifies that what it packs actually calls VelopackApp.Build().Run(), and
nothing here does that for us, so scripts/ci-linux-rpm.sh makes its own
assertions: it asks the finished package what it requires, what it provides,
what it holds and what modes those carry. Two of those checks exist because
the first realistic payload tripped them.

  - .NET's libmscordbi.so links libmscordaccore.so. With bundled provides
    excluded — which they must be, or this package would advertise libcoreclr
    to the whole system — that produced a dependency no host on earth can
    satisfy. The requires exclusion is computed from the payload rather than
    listed, because a list of Microsoft's internal file names goes stale in
    the direction that yields an uninstallable package.

  - cp -a inherits the publish directory's modes, and off the wrong filesystem
    that meant a world-writable /opt/DodoSSH: any local account able to
    replace the SSH client every other account runs. For an application
    holding a team's credentials in plaintext that is the trust model walked
    around rather than attacked. Every mode is now set absolutely.

Verified as far as a runner can: both packages install side by side on Fedora
44, resolve webkit2gtk4.1 2.52.5 — the backend MainWindow records as measured
— keep separate launchers, icons and profiles, and dnf remove takes one tree
and leaves the other. 0.0.1~alpha.0.143 sorts below both 0.0.1 and the next
height. What no runner can answer is Phase 19, which is new and unwalked.

RHEL 9 is deliberately not claimed. It ships WebKitGTK's 4.0 line under
libwebkit2gtk-4.0.so.37, and an or-branch for it was written and then removed:
nobody has run the head there, and dnf refusing with "nothing provides
libwebkit2gtk-4.1.so.0" is a better answer than an install that draws a blank
terminal. Same sentence, and the same reason, as the missing Intel Mac.

x86_64 only, for that reason and not a different one.
This commit is contained in:
jaap-jan committed 2026-08-22 16:43:38 +02:00
1 parent 1970e20e56
commit 1bf79c7c26
12 files changed
+1015 -5

No files matched your search

@@ -316,6 +316,66 @@ preferences screen, which is the screen somebody reads when asked which nightly
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.
### 11. Linux is a third desktop platform, and it is a package rather than an updater
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
addition, and it is not an AppImage.
**The package is an RPM and there is no updater in it.** That is the decision, and everything else follows
from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been repeated a
third time — one more channel, one more index, one more client polling the forge. It should not be, because
this platform already has the mechanism that whole apparatus is a substitute for. `dnf` is an updater the
user already trusts, already audits and already controls, and it updates the runtime's own dependencies
besides. Adding a second one that answers to this project instead would be building the capability decision
4 spends a page keeping unsettable, on the one platform where nobody had to.
The honest cost, recorded because it is the part that will be argued with: **discovery is worse here than on
either other desktop.** Windows and macOS clients learn about a new build within six hours without anybody
deciding to look. A Linux user learns when their repository does, and this project publishes no repository,
so today they learn when they next go and look. That is a real regression against the sharpest thing this
ADR bought, accepted because the alternative regression — a second update path with a different authority
behind it — is the one the trust model cannot afford. A package repository is the fix and is not built.
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
packaging without putting an installer anybody has run in a place that invites passing it on.
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
plaintext credentials.
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
**Two things this platform does not have, and they are stated rather than left to be discovered.** There is
no hardware-backed device key store — `DesktopDeviceKeyStores` returns `UnavailableDeviceKeyStore`, so the
passphrase is asked for at every unlock, where Windows has a TPM and a Mac has the Secure Enclave. And there
is no signature on the package, which is not decision 8's "unsigned for now": nothing on this platform
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
address. An RPM repository would change that, because repository metadata is signed and a repository is a
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
and it is the reason building one is a decision and not a chore.
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
## Consequences
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
@@ -369,6 +429,10 @@ ADR 0011 changes.
genuinely the nicest onboarding available and will be proposed again.
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
inherit it.
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
because this is the one desktop platform that already has an updater the user controls, and a second one
answering to this project is the capability decision 4 exists to make unsettable.
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
matters here: the tag is already the version of record for the container image, and two places to write
+109
View File
@@ -2841,3 +2841,112 @@ Register a device, sync something, then remove the application.
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
## Phase 19 — Installing the Linux client, and the things dnf does not check
The Linux counterpart of phases 16 and 18, and the shortest of the three because the platform takes two of
their questions off the table. There is no updater here, so nothing in this phase asks whether an update
arrives; and there is no signing, because nothing on this platform refuses an unsigned package — a distinct
statement from "unsigned is fine", and `build/linux/dodossh.spec` says what stands in for a signature: the
package comes from the project's own forge over TLS and from nowhere else.
What that leaves is the set nobody else can answer. **CI builds this package and installs nothing**, and
every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its file
list, its modes. None of them start the application, and the failures below are all of the shape "installs
perfectly and then does not work".
Needs a Fedora 44 machine or newer with a graphical session. Build the package first:
```bash
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false --output /tmp/linux-x64
scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
sudo dnf install /tmp/rpm/dodossh-1.2.3-1.x86_64.rpm
```
### 19.1 It launches from the menu, and not only from a shell · **do this one first**
Log out and back in, or wait for the desktop to notice the new `.desktop` file, then start DodoSSH from the
applications menu rather than from a terminal.
**Pass:** the window opens, and the running application's icon in the dock or task list is the same entry
that launched it — one icon, not two.
**Failure means:** a second unlabelled icon appearing beside the launcher is `StartupWMClass` not matching
what Avalonia sets `WM_CLASS` to. The spec sets it to `DodoSSH`, which is the assembly name and is the same
for both channels; if Avalonia's answer has changed, `xprop WM_CLASS` on the window is what to compare
against. Nothing at all in the menu is the desktop database not having been rebuilt — `%post` runs
`update-desktop-database` and swallows its failure on purpose, so try it by hand before concluding anything.
### 19.2 The terminal draws, which is the WebKitGTK question
Connect to a host and use the shell: type, run something that scrolls, resize the window.
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
**Failure means:** **a blank pane where everything else works is the known one**, and it is the reason
`MainWindow.OnTerminalEnvironmentRequested` exists — the GTK WebView adapter in its default mode can only
open a window of its own, so a control asked to host it in place has nothing to draw. If that method has
stopped setting `ExperimentalOffscreen`, or the vendor has withdrawn the flag, this is what it looks like.
A pane that reports a renderer timeout instead is the loopback WebSocket rather than the backend.
### 19.3 The icon is the mark, at the sizes the desktop actually asks for
Look at it in the applications menu, in the dock, and in the window switcher.
**Pass:** the accent tile and the `>_` mark, filling its square, legible at the small end.
**Failure means:** a generic icon is `Icon=` in the desktop entry not matching an installed file name — both
are the package's binary name, so they can only disagree if one was edited. A blurry small icon is the
48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
installs four.
### 19.4 The passphrase is asked for, and no device key is offered
Open the security settings page.
**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time.
**Failure means:** being offered it is `DesktopDeviceKeyStores.ForThisMachine` having returned something
other than `UnavailableDeviceKeyStore` on a platform with nothing to back it. Accepting the offer would put
a wrap on the server that nothing can ever open.
### 19.5 The nightly installs beside the release rather than over it
With the release package installed, install the nightly one too.
**Pass:** `dnf` installs it as a second package rather than as an upgrade. Both entries appear in the menu,
named *DodoSSH* and *DodoSSH Nightly*. Both run at once. Signing into the nightly asks for the passphrase
again and appears on the account as a separate device.
**Failure means:** `dnf` offering to *upgrade* rather than install is the two packages sharing a name. A
nightly that opens the release's vault already unlocked is the profile separation gone — that comes from
`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
`~/.local/share/dodossh.nightly` exists rather than looking at the spec.
### 19.6 Nothing in the installed tree is writable by anyone but root
```bash
find /opt/DodoSSH ! -user root -o -perm -o+w
```
**Pass:** no output.
**Failure means:** any line here is a local account being able to replace the SSH client every other account
on the machine runs, which for an application holding a team's credentials in plaintext is the whole trust
model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce such a package and the spec
sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
from a publish directory.
### 19.7 Removing it leaves the vault, and leaves the other channel alone
Register a device, sync something, then `sudo dnf remove dodossh`.
**Pass:** `~/.local/share/dodossh` still holds the cache and the outbox. `/opt/DodoSSH` is gone entirely.
`/opt/DodoSSH.Nightly` and its menu entry are untouched.
**Failure means:** a missing cache is `ClientPaths` having been pointed somewhere under `/opt`, which would
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
something wrote into the install directory at runtime and would survive a reinstall.