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"
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.
#