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
+276 -3
View File
@@ -787,6 +787,278 @@ jobs:
echo "Published nightly $VERSION with$names" echo "Published nightly $VERSION with$names"
desktop:
name: desktop nightly
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
# commit whose suite was red.
needs: [build]
runs-on: [linux]
# main only, and the whole job rather than its last step. A v* tag belongs to the release channel,
# which no runner may publish — ADR 0013 rule 3 — and the desktop head is already built and packed
# on tags by the build job above, so there is nothing here a tag build would gain.
if: github.ref == 'refs/heads/main'
# Writes the rolling nightly release at the end of the job. Job-scoped, so no other job in this file
# gains it; see the publish step for what the capability is and why this channel may hold it.
permissions:
contents: write
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored out. There
# are four copies now, and any change has to be made in all four.
- name: ensure node and git
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
missing=""
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
command -v git >/dev/null 2>&1 || missing="$missing git"
if [ -z "$missing" ]; then
echo "node $(node --version), git $(git --version)"
exit 0
fi
echo "Installing:$missing"
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends $missing
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache $missing
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y $missing
else
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
echo "job. Point the runner's container.image at something that ships node." >&2
exit 1
fi
echo "node $(node --version), git $(git --version)"
major="$(node --version | sed 's/^v//; s/\..*//')"
if [ "$major" -lt 20 ]; then
echo "::warning::node $major is older than the runtime these actions target;" \
"give the runner an image with node 20 or newer if actions misbehave."
fi
# fetch-depth 0 for the reason the other three jobs give, and here it decides what gets published:
# MinVer's answer is this build's version and the number a nightly client compares against.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
# Before the version is read, and that ordering is not tidiness. MinVer arrives as a package, so its
# target does not exist until a restore has written obj/*.nuget.g.targets — and `-t:MinVer` on an
# unrestored project fails MSB4057 "the target does not exist", which reads like a typo in this file
# rather than like a missing restore. The build job's tag check is safe because it runs after that
# job's own restore; this job has none, so it needs this one.
#
# Locked, like the solution restore in the build job. The RID-specific restore the publish needs is
# unlocked and asks for that itself, exactly as the build job's publish does.
- name: restore
run: dotnet restore src/DodoSSH.Client.App/DodoSSH.Client.App.csproj --locked-mode
# ◆ THE VERSION IS DECIDED ONCE HERE AND THEN FORCED ON EVERYTHING.
#
# MinVer's own answer is not usable as it stands: until a v* tag exists it is 0.0.0-alpha.0.N, and
# vpk refuses to pack anything below 0.0.1. The floor is applied to the *whole build* rather than to
# the packaging alone, through MinVerVersionOverride, and that is the part worth understanding.
#
# Packing a version the assemblies disagree with would put one number in the installer and another
# on the preferences screen — the screen a person reads when asked which nightly they are on, and
# the number they would then quote into an issue that nobody can match to a build. MinVer sets both
# Version and InformationalVersion from the override, so the two cannot drift.
#
# Monotonic across the boundary, which is what a feed needs: heights keep rising within a floored
# version, and the first real tag moves the whole number up past every floored one.
- name: the nightly version
id: version
run: |
set -euo pipefail
# -t:MinVer for the reason the tag check in the build job spells out: without a target named,
# -getProperty answers the SDK default and every nightly would claim the same version forever.
version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
if [ -z "$version" ]; then
echo "Could not read the version from MSBuild." >&2
exit 1
fi
case "$version" in
0.0.0*)
floored="0.0.1${version#0.0.0}"
echo "MinVer says $version, which vpk will not pack; this nightly is $floored."
version="$floored"
;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "$version"
# DodoChannel=nightly is what makes this a different application rather than the same one built
# again: it puts the channel in the assembly, which is where DesktopChannel reads it to pick the
# feed to poll, whether to accept prereleases, and which profile directory to keep a cache in.
#
# RestoreLockedMode=false for the RID, exactly as the build job's publish does — see the long note
# there. This checkout is thrown away, so the lock files it rewrites go nowhere.
- name: publish the nightly
env:
VERSION: ${{ steps.version.outputs.version }}
run: >
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
--configuration Release --runtime win-x64 --self-contained true
-p:RestoreLockedMode=false
-p:DodoChannel=nightly
-p:MinVerVersionOverride="$VERSION"
--output "$RUNNER_TEMP/nightly-win-x64"
# ◆ A DIFFERENT PACK ID, A DIFFERENT CHANNEL, A DIFFERENT TITLE. ALL THREE, AND NONE IS COSMETIC.
#
# packId decides where Velopack installs and what an installed client matches an update against, so
# DodoSSH.Desktop.Nightly is what makes this install *beside* the release build rather than over it,
# and what stops either feed's package being applied to the other's install.
#
# channel decides the name of the index file on the feed — releases.win-nightly.json — and it is a
# contract with VelopackUpdateChannel.NightlyChannel. Disagree on this word and the channel answers
# nothing, forever, with no error anywhere.
#
# title is what a person reads in the Start menu and in Add/Remove Programs, and it is the only one
# of the three they will ever see. Two entries both called DodoSSH would be the whole benefit of
# installing side by side, thrown away at the last step.
#
# No `vpk download` and so no deltas: this channel deletes its previous release on every push, so
# there would be nothing on the feed for a delta to be applied against. A nightly update is a full
# download, which is the honest cost of a rolling channel that keeps exactly one build.
- name: package the nightly
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
dotnet tool restore
dotnet vpk '[win]' pack \
--skip-updates \
--packId DodoSSH.Desktop.Nightly \
--packVersion "$VERSION" \
--packDir "$RUNNER_TEMP/nightly-win-x64" \
--packTitle 'DodoSSH Nightly' \
--packAuthors DodoTech \
--mainExe DodoSSH.exe \
--icon src/DodoSSH.Client.App/Assets/dodossh.ico \
--runtime win-x64 \
--channel win-nightly \
--outputDir "$RUNNER_TEMP/nightly-releases"
ls -la "$RUNNER_TEMP/nightly-releases"
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
#
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
# from this feed and applies what it finds without verifying a signature. That is the same capability
# as a signing key reached through a different door, and ADR 0013 rule 3 keeps it off runners.
#
# It is acceptable here for the reason the android nightly gives, and only for that reason: this is
# not that channel. A nightly is a separate application with its own pack id, its own install
# directory and its own profile, and it cannot update the build anybody is trusting with their
# credentials — the release channel reads a different index and refuses prereleases, so it cannot
# even see this one. Anybody installing a nightly is trusting everyone who can write to this
# repository, which is a thing to know rather than a thing to discover; the README says so.
#
# ◆ DELETED AND RECREATED RATHER THAN ADDED TO.
#
# A rolling channel has to keep exactly one build, and every asset here is version-named, so merging
# would grow the release by a hundred and twenty megabytes per push until the forge said no. There is
# no atomic form of this in the API, so the shape with the fewest states is to remove both the
# release and its tag and make them again. The window where no nightly exists is a few seconds, and
# the client's answer to it is the same as to an unreachable forge: the timer swallows it and tries
# later; a pressed CHECK NOW says so.
- name: publish the nightly release
env:
FORGE: https://git.dodotech.cloud
REPO: DodoTech-Public/DodoSSH
TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TAG: nightly-desktop
run: |
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "No token, so the nightly was built and not published." >&2
echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
echo "without it, and the job's contents: write permission is what asks for it." >&2
exit 1
fi
api="$FORGE/api/v1/repos/$REPO"
auth="Authorization: token $TOKEN"
# The first "id" with digits after it, for the reason the android job records at length: a
# greedy .* walks past the release's own id to the author's, which is -2, and yields nothing.
release_id() {
grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
}
existing="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null || true)"
if [ -n "$existing" ]; then
id="$(printf '%s' "$existing" | release_id)"
if [ -n "$id" ]; then
echo "Removing the previous $TAG release $id"
curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
else
echo "A $TAG release exists and its id could not be read:" >&2
printf '%s\n' "$existing" >&2
exit 1
fi
fi
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
# vpk rather than curl, unlike the android job above, and the difference is what is being
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
# index by hand would be reimplementing the tool that is already here.
#
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
# channel reads: 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 update check
# against a release carrying no android manifest.
dotnet vpk upload gitea \
--skip-updates \
--repoUrl "$FORGE/$REPO" \
--token "$TOKEN" \
--outputDir "$RUNNER_TEMP/nightly-releases" \
--channel win-nightly \
--tag "$TAG" \
--releaseName "Nightly desktop $VERSION" \
--targetCommitish "$GITHUB_SHA" \
--pre \
--publish
# Asked for back rather than assumed, and the android job's history is why: it once created a
# release, uploaded nothing, and reported success for every upload it never made. A nightly
# desktop feed that exists and carries no index is a client that checks, finds nothing, and
# reports itself up to date forever.
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$published" >&2
exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG"
image: image:
name: api image name: api image
# Gated on the tests rather than parallel with them, which costs a few minutes of wall # Gated on the tests rather than parallel with them, which costs a few minutes of wall
@@ -1067,9 +1339,10 @@ jobs:
if: always() && github.event_name != 'pull_request' if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud 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. It is built # No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
# and packaged here — the two steps at the end of the build job — and what is withheld is only the # nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
# upload. # install directory and profile — and the distance between those two sentences is the whole design. What
# the build job does for the release channel is prove it still builds and packs; the upload is withheld.
# #
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE. # ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
# #
+17
View File
@@ -155,6 +155,23 @@ this machine's device key from your account.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux. CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
### The nightly desktop build
There is a second Windows build, published by CI from `main` on every push, on the `nightly-desktop`
release. It installs **beside** the release build rather than over it — its own entry called *DodoSSH
Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\DodoSSH.Nightly` — so trying it
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
because it genuinely is a second installation.
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
and not one for a build holding your infrastructure credentials, which is the entire reason the two
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
decision 9.
## Running it ## Running it
Three commands, in order. The first is once per machine. Three commands, in order. The first is once per machine.
@@ -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 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. 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 ## Consequences
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be **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 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. 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 **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 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. 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 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. 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 ## 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 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 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 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` that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
— pass it. Neither had, and neither had ever run: the CI check is `if:` a tag ref and there are no tags `scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
yet, so the first release would have been refused by its own guard, which would have blamed `fetch-depth`. 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 **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.** — and the android job was broken for an unrelated reason for the whole of the release that went stale.**
+19 -5
View File
@@ -71,6 +71,11 @@ $RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it # 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 # 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. # error at all: the client checks, finds nothing, and reports itself up to date forever.
#
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
$Channel = 'win' $Channel = 'win'
$RepoRoot = Split-Path -Parent $PSScriptRoot $RepoRoot = Split-Path -Parent $PSScriptRoot
@@ -100,11 +105,20 @@ Push-Location $RepoRoot
try { try {
# ---- What is being released ----------------------------------------------------------------------- # ---- What is being released -----------------------------------------------------------------------
# -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no # Restored before the version is read, and both halves of that sentence are load-bearing.
# targets, while MinVer sets Version from inside one — so this read answered the SDK's default #
# 1.0.0 regardless of the tag, and the check below would then have refused to build anything not # -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
# tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both # sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
# say so, because this is the version that ends up in the package a client compares against. # and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
# the same line and the same fault.
#
# And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
# clone, where obj/ has no MinVer targets to import yet. It costs seconds on a machine that has built
# before, which is every machine except the one this would otherwise fail on.
Write-Step 'Restoring the desktop head, so the version can be read'
& dotnet restore $Project --locked-mode
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', '' $version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
if ([string]::IsNullOrWhiteSpace($version)) { if ([string]::IsNullOrWhiteSpace($version)) {
Stop-With 'Could not read the version from MSBuild.' Stop-With 'Could not read the version from MSBuild.'
+4 -1
View File
@@ -92,7 +92,10 @@ internal sealed partial class DodoSshApp : Application
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop) private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{ {
var paths = ClientPaths.Default; // ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
// worth spending a directory on.
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
var caches = ClientCacheFactory.ForFile(paths.CacheFile); var caches = ClientCacheFactory.ForFile(paths.CacheFile);
// Known hosts live in the vault, so trust survives a restart and follows the user to every device. // Known hosts live in the vault, so trust survives a restart and follows the user to every device.
@@ -42,6 +42,30 @@
--> -->
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon> <ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
<!--
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
The same split the Android head has, for the same reason and with one difference worth stating up
front: Android gets separation for free, because the platform 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, so the separation has to be built:
two pack ids, two Velopack channels, two feeds, and a profile directory each.
release — DodoSSH.Desktop, Velopack channel win, read from the newest non-prerelease release.
Cut from a v* tag by scripts/release-windows.ps1, by a person. See ADR 0013 rule 3.
nightly — DodoSSH.Desktop.Nightly, Velopack channel win-nightly, read from a prerelease release
CI replaces on every push to main.
Default release, so an unqualified `dotnet build` is the real application and the nightly is the
one you have to ask for.
What this property does *not* decide is the pack id or the title. Those are arguments to vpk and
live where the packaging happens — in ci.yml for the nightly and in the release script for the
release. Putting them here would suggest the build knows which package it will end up inside,
and it does not.
-->
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
<!-- <!--
False here, unlike every server project. The root Directory.Build.props sets it true because False here, unlike every server project. The root Directory.Build.props sets it true because
the API is container-hosted, UTC-only and has no business formatting anything for a human. the API is container-hosted, UTC-only and has no business formatting anything for a human.
@@ -60,6 +84,16 @@
<AvaloniaResource Include="Assets/dodossh.ico" /> <AvaloniaResource Include="Assets/dodossh.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!--
Which channel this build belongs to, carried in the assembly the same way the Android head carries
it. Metadata rather than a compile-time constant for the reason stated there: 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. See DesktopChannel, which is the only thing that reads it.
-->
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
</ItemGroup>
<!-- <!--
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB. Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
@@ -0,0 +1,66 @@
using System.Reflection;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Which of the two desktop channels this build belongs to.
/// </summary>
/// <remarks>
/// <para>
/// The counterpart of the Android head's <c>DodoChannel</c> metadata, read the same way and for the same
/// reasons. Three things depend on the answer and they are listed here rather than discovered one at a
/// time: which Velopack channel the updater reads, whether that read considers prereleases, and which
/// profile directory this copy keeps its cache and device key in.
/// </para>
/// <para>
/// <b>Android gets this separation from the platform and this head has to build it.</b> Two Android
/// channels cannot replace one another because the installer refuses a package signed by a different key.
/// Nothing refuses anything on Windows: Velopack applies what its feed serves, without verifying a
/// signature. So the two channels are kept apart by construction here — a pack id each, so they install
/// in different directories; a Velopack channel each, so neither ever reads the other's release index; and
/// a profile directory each, so a nightly cannot migrate the schema of a cache the release build is using.
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c> rule 9.
/// </para>
/// <para>
/// Resolved once, at first use. The channel of a build does not change while it runs, and a value read
/// per call site is one that eventually gets read differently in two places.
/// </para>
/// </remarks>
internal static class DesktopChannel
{
/// <summary>The build this channel is not: the one a person cuts from a tag.</summary>
internal const string Release = "release";
/// <summary>The build CI publishes from main, which installs beside the release one.</summary>
internal const string Nightly = "nightly";
/// <summary>
/// What this build says it is, defaulting to the release channel.
/// </summary>
/// <remarks>
/// The csproj always writes the metadata, so the fallback covers a build that reached here some other
/// way — an assembly loaded by a test host, a designer, a trimmed-down copy. <see cref="Release"/> is
/// the safe answer for all of them: it is what the value was before there were two channels, and every
/// consequence of being wrong about it is inert. A build that is not installed has no updater at all
/// (see <c>UpdateChannels.ForThisMachine</c>), and the profile directory it names is the one every
/// existing install already uses.
/// </remarks>
internal static string Name { get; } = Read();
/// <summary>Whether this build belongs to the nightly channel.</summary>
internal static bool IsNightly => string.Equals(Name, Nightly, StringComparison.Ordinal);
private static string Read()
{
var declared = typeof(DesktopChannel).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
?.Value;
// Only the two the csproj declares are honoured. An unrecognised value is a build made by
// something nobody here wrote, and answering "release" to it is the same inert default as
// answering it to no value at all — rather than pointing an updater at a feed named by a string
// of unknown origin.
return string.Equals(declared, Nightly, StringComparison.Ordinal) ? Nightly : Release;
}
}
@@ -105,6 +105,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// </remarks> /// </remarks>
private const string ReleaseChannel = "win"; private const string ReleaseChannel = "win";
/// <summary>
/// The nightly channel, which is a different name rather than the same one on a different tag.
/// </summary>
/// <remarks>
/// <para>
/// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
/// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
/// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
/// </para>
/// <para>
/// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
/// channels are published to the same repository, so a client that read the other's index could be
/// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
/// </para>
/// </remarks>
private const string NightlyChannel = "win-nightly";
private readonly UpdateManager manager; private readonly UpdateManager manager;
/// <summary> /// <summary>
@@ -138,10 +157,34 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
/// </remarks> /// </remarks>
public string CurrentVersion => ClientVersion.Current; public string CurrentVersion => ClientVersion.Current;
internal static UpdateManager CreateManager() => /// <summary>
new( /// The updater for this build's channel.
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false), /// </summary>
new UpdateOptions { ExplicitChannel = ReleaseChannel }); /// <remarks>
/// <para>
/// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
/// left to the installed layout — Velopack records what a package was built with — but whether to
/// consider prereleases is a property of the feed rather than of the install, and it decides more
/// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
/// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
/// stable release would become the newest release in this repository and the phone's release channel
/// would start finding no Android manifest on it. One flag here keeps the two heads out of each
/// other's way.
/// </para>
/// <para>
/// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
/// that matters most, because that is the direction where a mistake would put an unsigned CI build on
/// a machine somebody trusts with their credentials.
/// </para>
/// </remarks>
internal static UpdateManager CreateManager()
{
var nightly = DesktopChannel.IsNightly;
return new UpdateManager(
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel });
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
+7 -1
View File
@@ -1,5 +1,6 @@
using Avalonia; using Avalonia;
using Avalonia.Media; using Avalonia.Media;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
using Velopack; using Velopack;
@@ -69,7 +70,12 @@ internal static class Program
return; return;
} }
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2"); // The same profile directory the rest of the application resolves, channel and all — a nightly
// pointing WebView2 at the release build's profile would put two browser profiles in one folder
// and hand the pair of them whichever process opened first.
var folder = Path.Combine(
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
"WebView2");
try try
{ {
+5 -1
View File
@@ -37,7 +37,11 @@
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
sans face while the address, the account and the fingerprint beside it stay monospaced. sans face while the address, the account and the fingerprint beside it stay monospaced.
--> -->
<TextBlock Text="DodoSSH" FontSize="14" FontWeight="SemiBold" <!--
Named, because a nightly says so here. See the code-behind: the release build is what this
markup says and nothing changes for it.
-->
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center" /> Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<!-- <!--
The design puts an organisation here — "dodotech / platform". There are no organisations: the The design puts an organisation here — "dodotech / platform". There are no organisations: the
+19 -1
View File
@@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -21,7 +22,24 @@ namespace DodoSSH.Client.App.Views;
/// </remarks> /// </remarks>
internal sealed partial class TitleBar : UserControl internal sealed partial class TitleBar : UserControl
{ {
public TitleBar() => InitializeComponent(); public TitleBar()
{
InitializeComponent();
// ◆ A nightly says which one it is, in the one place a person is always looking at.
//
// The two channels install side by side on purpose, so both windows are on screen at once, both
// are called DodoSSH, and both ask for a passphrase the same way. Everything else that
// distinguishes them — the Start menu entry, the install directory, the version on the
// preferences screen — is somewhere nobody is looking while typing into one of them.
//
// Set here rather than in the markup so the release build is exactly what the XAML says, and so
// the layout harness, which hosts this control directly, measures the shipping string.
if (DesktopChannel.IsNightly)
{
ProductName.Text = "DodoSSH Nightly";
}
}
private Window? Host => TopLevel.GetTopLevel(this) as Window; private Window? Host => TopLevel.GetTopLevel(this) as Window;
+62 -5
View File
@@ -23,8 +23,60 @@ public sealed record ClientPaths(string DataDirectory)
private const string WindowsFolderName = "DodoSSH"; private const string WindowsFolderName = "DodoSSH";
private const string UnixFolderName = "dodossh"; private const string UnixFolderName = "dodossh";
/// <summary>
/// The channel whose profile is kept apart, named here because this type is the one that acts on it.
/// </summary>
/// <remarks>
/// The string itself is the desktop head's <c>DesktopChannel.Nightly</c>. It is repeated rather than
/// referenced because the dependency runs the wrong way — this project is shared with the Android
/// head, which must never acquire a desktop updater — and because a value that reaches an assembly as
/// build metadata is a string by the time anybody here sees it.
/// </remarks>
private const string NightlyChannelName = "nightly";
/// <summary>
/// What the nightly's directory is called: the release one, with this on the end.
/// </summary>
/// <remarks>
/// A sibling rather than a subdirectory of the release profile, so that neither install's uninstaller
/// or reset can reach the other's, and so a person looking in <c>%LOCALAPPDATA%</c> sees two things
/// with two names rather than one thing with a surprise inside it.
/// </remarks>
private const string NightlySuffix = ".Nightly";
/// <summary>The conventional location for this platform.</summary> /// <summary>The conventional location for this platform.</summary>
public static ClientPaths Default { get; } = new(ResolveDataDirectory()); public static ClientPaths Default { get; } = new(ResolveDataDirectory(suffix: null));
/// <summary>
/// Where a build on the given channel keeps its profile.
/// </summary>
/// <remarks>
/// <para>
/// <b>A nightly may not share a profile with the release build, and the reason is the cache rather
/// than the secrets.</b> The schema is migrated on every launch, before unlock; a nightly carrying a
/// migration the release build has not shipped yet would upgrade a database the release build then
/// opens. Both are installed at once by design — that is the whole point of a channel that installs
/// beside rather than over — so this is an ordinary Tuesday rather than a corner case. Two of them
/// running at the same time on one SQLite file and one outbox is the second reason and would be
/// enough on its own.
/// </para>
/// <para>
/// It costs a nightly its sign-in and its known hosts, which is the honest trade: a nightly is a
/// second installation of the application, and treating it as one is what stops it damaging the first.
/// The device key is per install too, so the deployment sees a new device — which is exactly what
/// happened, and what the trust model expects to be told about.
/// </para>
/// <para>
/// Only the nightly channel is answered specially. Anything else, including the release channel and
/// anything unrecognised, gets <see cref="Default"/> — the directory every existing install already
/// uses, which must not move for any reason.
/// </para>
/// </remarks>
/// <param name="channel">The build channel, as the head's own metadata reports it.</param>
public static ClientPaths ForChannel(string? channel) =>
string.Equals(channel, NightlyChannelName, StringComparison.Ordinal)
? new ClientPaths(ResolveDataDirectory(NightlySuffix))
: Default;
/// <summary>The encrypted local cache.</summary> /// <summary>The encrypted local cache.</summary>
public string CacheFile => Path.Combine(DataDirectory, "cache.db"); public string CacheFile => Path.Combine(DataDirectory, "cache.db");
@@ -68,20 +120,25 @@ public sealed record ClientPaths(string DataDirectory)
/// here is one line versus depending on whether the runtime happens to. /// here is one line versus depending on whether the runtime happens to.
/// </para> /// </para>
/// </remarks> /// </remarks>
private static string ResolveDataDirectory() private static string ResolveDataDirectory(string? suffix)
{ {
// Appended to the folder name rather than added as a path segment, on every platform, so the two
// profiles are siblings everywhere. The Unix name is lower-cased with the rest of its folder.
var windows = WindowsFolderName + suffix;
var unix = UnixFolderName + suffix?.ToLowerInvariant();
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
return Path.Combine( return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
WindowsFolderName); windows);
} }
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (OperatingSystem.IsMacOS()) if (OperatingSystem.IsMacOS())
{ {
return Path.Combine(home, "Library", "Application Support", WindowsFolderName); return Path.Combine(home, "Library", "Application Support", windows);
} }
var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
@@ -90,6 +147,6 @@ public sealed record ClientPaths(string DataDirectory)
? Path.Combine(home, ".local", "share") ? Path.Combine(home, ".local", "share")
: xdgDataHome; : xdgDataHome;
return Path.Combine(root, UnixFolderName); return Path.Combine(root, unix);
} }
} }
@@ -61,6 +61,63 @@ public sealed class ClientPathsTests
} }
} }
/// <summary>
/// The nightly channel keeps its own profile, and everything else keeps the existing one.
/// </summary>
/// <remarks>
/// Both halves are load-bearing and they fail in opposite directions. A nightly sharing the release
/// build's directory would migrate the cache schema of a database the release build then opens — the
/// two are installed at once by design, so that is an ordinary Tuesday. And the release channel's
/// directory moving even slightly would orphan every existing install's cache, outbox and device key:
/// the application would start, find nothing, and ask for a server. See ADR 0013 decision 9.
/// </remarks>
[Theory]
[InlineData("release")]
[InlineData("")]
[InlineData(null)]
[InlineData("something nobody wrote")]
public void EveryChannelButTheNightlyKeepsTheExistingProfile(string? channel)
{
var resolved = ClientPaths.ForChannel(channel).DataDirectory;
// string.Equals with an explicit comparison rather than ShouldBe, here and below: these are paths
// built from the same constants, so the comparison that means anything is the exact one.
string.Equals(resolved, ClientPaths.Default.DataDirectory, StringComparison.Ordinal)
.ShouldBeTrue($"'{channel}' resolved to '{resolved}' rather than to the default profile");
}
[Fact]
public void TheNightlyProfileIsASiblingOfTheReleaseOne()
{
var release = ClientPaths.Default.DataDirectory;
var nightly = ClientPaths.ForChannel("nightly").DataDirectory;
string.Equals(nightly, release, StringComparison.Ordinal)
.ShouldBeFalse($"the nightly and the release build would share '{release}'");
// A sibling rather than a child, so neither install's uninstaller or reset can reach the other's.
string.Equals(
Path.GetDirectoryName(nightly),
Path.GetDirectoryName(release),
StringComparison.Ordinal)
.ShouldBeTrue($"'{nightly}' should sit beside '{release}'");
// And still identifiably ours, on either platform's spelling — the same property the default is
// checked for above, for the same reason.
nightly.Contains("dodossh", StringComparison.OrdinalIgnoreCase)
.ShouldBeTrue($"'{nightly}' should be identifiable as ours");
// The files follow the directory. A nightly writing the release build's cache or device key is the
// whole failure this separation exists to prevent, so it is asserted rather than inferred.
var separated = ClientPaths.ForChannel("nightly");
string.Equals(separated.CacheFile, ClientPaths.Default.CacheFile, StringComparison.Ordinal)
.ShouldBeFalse("the two builds would write one cache.db");
string.Equals(separated.DeviceKeyFile, ClientPaths.Default.DeviceKeyFile, StringComparison.Ordinal)
.ShouldBeFalse("the two builds would share one device key");
}
[Fact] [Fact]
public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne() public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne()
{ {