diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e95817..8a9b780 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -787,6 +787,278 @@ jobs:
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:
name: api image
# 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'
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
-# and packaged here — the two steps at the end of the build job — and what is withheld is only the
-# upload.
+# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
+# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
+# 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.
#
diff --git a/README.md b/README.md
index d180afc..a4be75d 100644
--- a/README.md
+++ b/README.md
@@ -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
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
Three commands, in order. The first is once per machine.
diff --git a/docs/adr/0013-desktop-distribution-and-updates.md b/docs/adr/0013-desktop-distribution-and-updates.md
index 1b6e5eb..f87a922 100644
--- a/docs/adr/0013-desktop-distribution-and-updates.md
+++ b/docs/adr/0013-desktop-distribution-and-updates.md
@@ -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
diff --git a/docs/adr/0014-android-updates.md b/docs/adr/0014-android-updates.md
index 0b8bb78..1b13ebe 100644
--- a/docs/adr/0014-android-updates.md
+++ b/docs/adr/0014-android-updates.md
@@ -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.
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index dfb98f2..590641b 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -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
diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index ca5bfa3..5cbde70 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -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.**
diff --git a/scripts/release-windows.ps1 b/scripts/release-windows.ps1
index fcc5577..377a668 100644
--- a/scripts/release-windows.ps1
+++ b/scripts/release-windows.ps1
@@ -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
# 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.
+#
+# 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'
$RepoRoot = Split-Path -Parent $PSScriptRoot
@@ -100,11 +105,20 @@ Push-Location $RepoRoot
try {
# ---- What is being released -----------------------------------------------------------------------
- # -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no
- # 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
- # tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both
- # say so, because this is the version that ends up in the package a client compares against.
+ # Restored before the version is read, and both halves of that sentence are load-bearing.
+ #
+ # -t:MinVer, because -getProperty on its own evaluates the project and runs no 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 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', ''
if ([string]::IsNullOrWhiteSpace($version)) {
Stop-With 'Could not read the version from MSBuild.'
diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs
index 1b70943..aecc675 100644
--- a/src/DodoSSH.Client.App/App.axaml.cs
+++ b/src/DodoSSH.Client.App/App.axaml.cs
@@ -92,7 +92,10 @@ internal sealed partial class DodoSshApp : Application
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);
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
index 6b6cfc2..4efba22 100644
--- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
+++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
@@ -42,6 +42,30 @@
-->
Assets/dodossh.ico
+
+ release
+
+
+
+
-
+