Files
DodoSSH/.github/workflows/ci.yml
jaap-jan 890a5f2246
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s
Give the desktop a macOS head, signed from the first release
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
2026-08-10 10:43:28 +02:00

1456 lines
76 KiB
YAML

name: ci
on:
push:
branches: [main]
# Release tags run the whole workflow, not only the image job that gates on it. A tag
# is the one build nobody is watching, so it is the last place to take the tests on
# trust.
tags: ['v*']
pull_request:
branches: [main]
# Actions are pinned to commit SHAs, not tags: a tag can be moved to point at new code,
# which would let a compromised action run with this workflow's permissions.
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
CI: true
jobs:
build:
name: build and test
runs-on: [linux]
steps:
# act_runner runs every `uses:` action with node inside the job container, and this
# runner's image has none — the run died on the first line of actions/checkout with
# "Cannot find: node in PATH". A `run:` step is shell rather than node, so this one
# can go first and unblock the rest.
#
# This is a workaround and the real fix is one line of the runner's own config.yaml:
# point container.image at an image that ships node, the way Gitea's default
# catthehacker/ubuntu:act-latest does. Kept anyway, because a pipeline that depends
# on a runner being configured correctly somewhere else fails confusingly when it is
# not, and because it costs nothing on a runner that is.
#
# git as well as node, and said in the step name rather than smuggled in: checkout
# shells out to git the moment node has loaded it, so an image thin enough to lack
# one usually lacks the other, and learning that costs a whole second CI round trip.
#
# Repeated verbatim in all three jobs, which is not laziness. It cannot be a local
# composite action — that would need the checkout it exists to unblock — and YAML
# anchors, which would deduplicate it, are rejected by GitHub's parser and would make
# this file portable to nothing. Change one copy, change all three.
- 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)"
# Warned about rather than failed on. Distributions pin their nodejs package to
# the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
# of life and older than the runtime these actions declare. It generally runs
# them anyway, since act_runner uses whichever node is on PATH regardless of what
# the action asked for, so this is a note for when one of them misbehaves in a
# way that makes no sense, not a reason to stop a build that is probably fine.
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, and it is load-bearing rather than tidy. MinVer derives the version from the
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
# answer here a client that never updates.
#
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
# copy, change all three.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# cache: true is actions/cache underneath, keyed on the lock files. That needs a cache server,
# which act_runner ships and can be turned off — and when it is off, this does nothing at all and
# says nothing about it. See the step below, which is how you find out.
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: '**/packages.lock.json'
# ◆ ONE LINE, AND IT EXISTS BECAUSE THE ALTERNATIVE IS ASSUMING.
#
# A restore that is cold every single run and a cache working perfectly look identical from
# outside: both end in a green build, and the only difference is minutes. This job's whole
# package cache is the step above, so whether that step works is worth knowing as a fact rather
# than as a belief — and it is one `find` before anything has written to the folder.
#
# It does not fail the build. A runner with no cache server is slow rather than wrong, and a
# pipeline that refused to run on one would be a worse trade than the minutes.
- name: what the package cache brought
run: |
set -eu
root="${NUGET_PACKAGES:-$HOME/.nuget/packages}"
if [ -d "$root" ]; then
echo "$root holds $(find "$root" -mindepth 1 -maxdepth 1 -type d | wc -l) package(s) already."
else
echo "$root is not there, so this restore is cold."
echo "If every run says that, the cache above is doing nothing: check that this runner's"
echo "config.yaml has the cache server enabled, and that ACTIONS_CACHE_URL reaches it."
fi
# Locked mode fails if packages.lock.json does not match the project files, so a
# dependency cannot change without the lock file change being reviewed.
- name: restore
run: dotnet restore DodoSSH.slnx --locked-mode
# The version comes from the tag, so on a tag build there are two ways to say the same number
# and they can disagree — a moved tag, a tag on the wrong commit, or a checkout that somehow
# still lost its history. What makes that worth a step of its own is that the disagreement is
# silent everywhere else: MinVer answers 0.0.0-alpha.0.N rather than failing, the build goes
# green, the package is cut, and the symptom arrives weeks later as clients that never update.
#
# -getProperty evaluates without building, so this costs a second and runs before the build.
#
# ◆ -t:MinVer IS NOT OPTIONAL, AND WITHOUT IT THIS STEP FAILED EVERY TAG BUILD.
#
# -getProperty on its own evaluates the project and runs no targets, and MinVer sets Version
# from inside a target — so what came back was the SDK's default 1.0.0, for any tag, on any
# commit, in a clean checkout with every tag present. The step would have failed the first
# release ever cut and blamed fetch-depth for it, which is the one explanation that is not it.
# Naming a target makes -getProperty report the value after that target has run.
#
# Never exercised, because no v* tag exists yet: this whole step is `if:` a tag ref. It is the
# shape of bug a guard is prone to — the guard is the thing nothing tests, and it only speaks
# on the one build nobody is watching, which is the reason this step exists at all.
- name: the tag and the version agree
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/v}"
declared="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
if [ "$tag" != "$declared" ]; then
echo "The tag says v$tag and MinVer computed $declared." >&2
echo >&2
echo "These come from the same place, so a mismatch means the checkout did not see the" >&2
echo "tag it is building — most likely fetch-depth, which must stay 0 in every job here." >&2
exit 1
fi
echo "v$declared"
# No `dotnet format --verify-no-changes` step. It re-analysed the whole solution before
# the build did, for minutes, to check something the build already checks: IDE0055 is an
# error in .editorconfig and TreatWarningsAsErrors is on, so a misformatted file fails
# the build step below on its own. What the separate step added was the ability to say
# so a few minutes earlier, and it cost more than that on every run.
# Avalonia's headless renderer is still Skia, and libSkiaSharp.so — which the layout
# test project copies into its own output — links against libfontconfig. Without that
# one library every test in DodoSSH.Client.App.Layout.Tests dies inside
# HeadlessUnitTestSession before it measures anything, and 69 tests fail for a reason
# none of their names or assertions mention.
#
# The library, and not fonts. Verified in a container where fc-list returns zero and
# the suite passes anyway: the application carries Inter itself, so nothing here needs
# a typeface installed — only the thing that would have gone looking for one.
- name: ensure skia's native dependency
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
if ldconfig -p 2>/dev/null | grep -q 'libfontconfig\.so\.1'; then
echo "libfontconfig present"
exit 0
fi
echo "Installing fontconfig"
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends libfontconfig1
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache fontconfig
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y fontconfig
else
echo "No package manager here, so Skia cannot be given its dependency and the" >&2
echo "layout suite will fail to start. Add fontconfig to the runner's image." >&2
exit 1
fi
- name: build
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
- name: test
run: dotnet test DodoSSH.slnx --no-build --configuration Release
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
# failure mode is a restore graph or a native asset that resolves for net10.0 and not for
# net10.0/win-x64, which nobody would see until a person was halfway through cutting a release
# on a Windows machine.
#
# RestoreLockedMode=false for this command only, and it is not a loosened gate. The committed
# lock files are deliberately RID-free: declaring win-x64 on the desktop head writes a
# net10.0/win-x64 target into every project it references transitively, which includes
# DodoSSH.Contracts and DodoSSH.Crypto — and the API's Dockerfile restores those with no RID
# under locked mode, so the image job would fail NU1004. The gate is the locked solution
# restore at the top of this job, which is unchanged.
#
# It rewrites the lock files as it goes; nothing after it reads them, and the runner's
# checkout is thrown away. The release script does the same thing and puts them back, because
# there the tree is somebody's working copy.
#
# After the tests rather than before them, so a red suite does not first spend a hundred
# megabytes pulling a win-x64 runtime pack. main and tags only, for the same reason: a break
# found by the person about to release is found early enough.
#
# The output directory is read by the packaging step below, so the two names have to agree.
- name: publish the windows desktop client
if: github.event_name != 'pull_request'
run: >
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
--configuration Release --runtime win-x64 --self-contained true
-p:RestoreLockedMode=false
--output "$RUNNER_TEMP/win-x64"
# ◆ THE WINDOWS INSTALLER IS BUILT HERE, ON LINUX, AND IS DELIBERATELY THROWN AWAY.
#
# Packaging rather than only publishing, for the same reason the android job packages an APK
# nobody installs: the failures a release is most exposed to are the ones only the packager
# finds. vpk opens the published binaries and verifies that the main executable really calls
# VelopackApp.Build().Run() — so a refactor that drops that call, which compiles, tests green
# and produces an application that silently never updates itself, fails here instead of
# shipping. A publish alone cannot see that, and the person who would otherwise see it first is
# the one midway through a release.
#
# ◆ NOT WITH scripts/release-windows.ps1. That script is a person's release procedure and holds
# things a runner must not have and must not skip — it refuses a dirty tree, insists HEAD is
# tagged, downloads the previous release to build deltas against, and in its second phase asks
# for the forge token. Calling it from here would mean either weakening it with CI switches or
# having CI meet conditions that only make sense at a desk. So the packaging arguments below
# are written out again in shell, and the duplication is accepted and stated: the pack id, the
# title, the authors, the channel and the icon are a contract with VelopackUpdateChannel and
# with every installed client. Change one, change the other.
#
# ◆ AND CROSS-COMPILING NEEDS THE OS DIRECTIVE, WHICH IS EASY TO MISS.
#
# vpk picks its target from the host: on Linux, plain `vpk pack` builds a Linux release and
# refuses a Windows RID outright — "the target rid must be Linux (actually was Windows)". The
# bracketed [win] before the verb is what turns it into a cross-compile, and it must be quoted
# or the shell reads it as a glob matching any one of w, i and n. With it, the runner writes
# DodoSSH.Desktop-win-Setup.exe, the portable zip, the full .nupkg and releases.win.json,
# exactly as a Windows machine does. Only signing needs Windows tooling, and nothing here is
# signed — see ADR 0013 rule 8 for the trigger that changes.
#
# ◆ NOTHING IS UPLOADED, AND NOTHING IS EVEN OFFERED FOR DOWNLOAD.
#
# The output goes to RUNNER_TEMP and dies with the job. That is the whole point rather than an
# omission: ADR 0013 rule 3 puts the capability to ship somebody a build on a machine which is
# not a runner, because Velopack clients apply what their feed serves without verifying a
# signature. Publishing a CI-built package as a workflow artefact would not cross that line by
# itself, and it would put an installer nobody has run in a place that invites passing it on —
# so it is not done either. What reaches users is built, installed and walked through Phase 16
# of docs/manual-checks.md by a person first.
- name: package the windows desktop client
id: winpack
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# vpk comes from the tool manifest, at the version committed there, which is the same one
# the release script uses. Restored here rather than in a step of its own because this is
# the only job that needs a tool at all.
dotnet tool restore
# The same value the release script packs with, read the same way. A version vpk disagrees
# with the assemblies about is a client that compares wrongly, so it is read from MSBuild
# rather than reconstructed from the ref — a main build has no tag to reconstruct from
# anyway, and MinVer's answer there carries the prerelease height that tells two of them
# apart.
#
# -t:MinVer for the reason spelled out on the tag check above: without a target named,
# -getProperty answers 1.0.0 and every package would carry the same version forever, which
# Velopack reads as "no update available".
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
# ◆ AND A FLOOR UNDER IT, BECAUSE MINVER'S PRE-FIRST-TAG ANSWER IS ONE vpk REFUSES.
#
# Until a v* tag exists MinVer answers 0.0.0-alpha.0.N — correct, documented, and rejected
# outright by vpk: "Invalid package version '0.0.0-alpha.0.143', it must be >= 0.0.1". So
# today, and on every main build until the first release, packing the true version cannot
# work at all.
#
# The patch digit is lifted and the prerelease part kept, so the stand-in still sorts below
# anything real and still differs between commits. Sound only because this package is
# thrown away: what is being proved here is that the desktop client packages, and no client
# will ever see this number. The release script is not given a floor and must not be — its
# version is the one users compare against, and if MinVer ever answered 0.0.0 there the
# right outcome is the refusal.
packVersion="$version"
case "$packVersion" in
0.0.0*)
packVersion="0.0.1${packVersion#0.0.0}"
echo "MinVer says $version, which vpk will not pack; packaging as $packVersion."
echo "This disappears the moment a v* tag exists, and never reaches a release."
;;
esac
releases="$RUNNER_TEMP/win-releases"
# --skip-updates because vpk otherwise asks nuget.org whether a newer vpk exists on every
# run, which is a network call whose answer this build must not act on: the version that
# packs is the one in the tool manifest.
#
# No `vpk download`, where the release script has one. That fetches the previous release so
# deltas can be built against it, which needs the feed and produces an artefact nobody
# applies. A full package proves the packaging.
dotnet vpk '[win]' pack \
--skip-updates \
--packId DodoSSH.Desktop \
--packVersion "$packVersion" \
--packDir "$RUNNER_TEMP/win-x64" \
--packTitle DodoSSH \
--packAuthors DodoTech \
--mainExe DodoSSH.exe \
--icon src/DodoSSH.Client.App/Assets/dodossh.ico \
--runtime win-x64 \
--channel win \
--outputDir "$releases"
# Asked for rather than assumed. vpk fails loudly and this costs a line, but the artefact
# whose existence is the entire question — a Windows setup stub built on a machine that is
# not Windows — is worth naming rather than inferring from an exit code.
setup="$releases/DodoSSH.Desktop-win-Setup.exe"
if [ ! -s "$setup" ]; then
echo "vpk reported success and there is no setup executable at $setup." >&2
ls -la "$releases" >&2 || true
exit 1
fi
ls -la "$releases"
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
# Handed to the macOS step below rather than worked out again there. The floor logic above
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
# is a second thing to keep in step — while two desktop packages built from one commit
# carrying different version numbers is precisely the confusion this file spends that
# reasoning to avoid.
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
#
# Same argument as the Windows step above, one platform along: the failures a release is most
# exposed to are the ones only the packager finds, and the person who would otherwise find them
# is the one midway through a release on the one Mac that can cut one.
#
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
# nobody can retry without a Mac.
#
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
#
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
# where the platform does.
#
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
# the machine that can also check the result; passing a rendered plist here would mean copying
# the substitution out of that script to no end, since nothing looks at what this produces.
#
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
#
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
# would open — so publishing it would be handing out a file whose only possible use is confusion.
- name: publish and bundle the macos desktop client
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
# long note there for why the committed lock files are deliberately RID-free. This runner's
# checkout is thrown away, so the lock files it rewrites go nowhere.
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime osx-arm64 --self-contained true \
-p:RestoreLockedMode=false \
--output "$RUNNER_TEMP/osx-arm64"
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
# rather than globbed, because a publish that produced no apphost at all would otherwise
# bundle happily and produce an .app that launches nothing.
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
echo "The osx-arm64 publish produced no apphost." >&2
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
exit 1
fi
bundles="$RUNNER_TEMP/osx-bundle"
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
# the shell reads it as a glob matching any one of o, s and x.
dotnet vpk '[osx]' bundle \
--skip-updates \
--packId DodoSSH.Desktop \
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
--packDir "$RUNNER_TEMP/osx-arm64" \
--packTitle DodoSSH \
--packAuthors DodoTech \
--mainExe DodoSSH \
--bundleId dev.dodotech.dodossh \
--runtime osx-arm64 \
--channel osx \
--outputDir "$bundles"
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
# macOS will not treat as an application at all, and it is the one part of the .app that
# vpk composes rather than copies.
app="$bundles/DodoSSH.Desktop.app"
if [ ! -s "$app/Contents/Info.plist" ]; then
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
find "$bundles" -maxdepth 3 >&2 || true
exit 1
fi
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
# dominate a cold run.
# A failing run says only that tests failed and names a log file on a machine nobody
# has a shell on. Every diagnostic thing — exception type, message, stack — is inside
# that file, so a red build was a filename and a guess. This prints it.
#
# head rather than tail, and that is the whole trick: when a suite fails wholesale it
# writes one stack per test and they are all the same stack. The first is the one that
# explains it, and the last two hundred lines are the same sentence repeated.
- name: what actually failed
if: failure()
run: |
set +e
echo "=== distro ==="
cat /etc/os-release 2>/dev/null | head -3
id
echo "=== what Skia needs, and whether it is here ==="
# ldd against the copy the test project carries. Its unresolved rows are the
# answer whenever the layout suite dies in HeadlessUnitTestSession, and asking
# here beats inferring it from a managed TypeInitializationException.
skia="$(find tests -name 'libSkiaSharp.so' 2>/dev/null | head -1)"
if [ -n "$skia" ]; then
echo "$skia"
ldd "$skia" 2>&1 | grep -Ei 'not found|fontconfig|freetype' || echo " all resolved"
else
echo " libSkiaSharp.so was not in the test output at all"
fi
ldconfig -p 2>/dev/null | grep -ci fontconfig | sed 's/^/fontconfig entries in ldconfig: /'
echo "=== docker, for the Testcontainers suites ==="
docker version --format '{{.Server.Version}}' 2>&1 | head -2
echo "=== test logs ==="
find tests -path '*/TestResults/*.log' 2>/dev/null | while read -r log; do
echo "----- $log"
head -n 120 "$log"
done
exit 0
android:
name: android head
runs-on: [linux]
# Writes the nightly release at the end of the job; see that step for what the capability is and why
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
permissions:
contents: write
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three.
- 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)"
# Warned about rather than failed on. Distributions pin their nodejs package to
# the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
# of life and older than the runtime these actions declare. It generally runs
# them anyway, since act_runner uses whichever node is on PATH regardless of what
# the action asked for, so this is a note for when one of them misbehaves in a
# way that makes no sense, not a reason to stop a build that is probably fine.
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, and it is load-bearing rather than tidy. MinVer derives the version from the
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
# answer here a client that never updates.
#
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
# copy, change all three.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# A job of its own, because DodoSSH.Client.Android is deliberately not in DodoSSH.slnx.
# Adding it there would make the android workload and a full Android SDK a prerequisite of
# `dotnet build DodoSSH.slnx` for everyone — including the build job above, which needs
# neither and would grow several minutes for a head it does not compile.
#
# The cost of keeping it out is that nothing in the main job would notice this head
# breaking, which for a project sharing view models with the desktop one is a matter of
# when rather than whether. This job is that notice.
#
# ============ ◆ WHY THIS HEAD IS BUILT IN A CONTAINER AND THE OTHER TWO ARE NOT ============
#
# .NET for Android cannot build on this runner. Not "needs setting up" — cannot. The runner is
# Alpine, and the SDK's own MSBuild tasks load glibc shared libraries out of the workload pack
# into the build process, which a musl-linked dotnet will not do:
#
# error XARLP7000: Error relocating …/libZipSharpNative-3-3.so: __snprintf_chk: symbol not found
#
# There is no musl variant of that pack and no shim for a library loaded in-process — gcompat
# gets a glibc *executable* started and is no help whatsoever here. Everything this job used to
# do on the host to make Android work was therefore treatment of symptoms: an aapt2 reported
# missing while being present, an "unsupported version" of a binary that had never run. All of
# them were this one sentence in a different accent, and docs/platform-flags.md has the history
# so the next reader does not repeat the three rounds it took to hear it.
#
# So the toolchain moves into an image that is glibc, and the job keeps on the host only what the
# host is good at: checkout, git, and publishing. The daemon needs no arranging — the image job
# builds with it and every Testcontainers suite in the build job reaches it over the socket.
#
# There is no setup-dotnet here any more, and its absence is the point: nothing outside the
# container compiles anything, so a dotnet on the host would be a second toolchain with nothing
# to do and a version nobody checks.
- name: ensure the docker cli
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
# Only the client. The socket is already there and a daemon is already answering on it, so
# installing an engine would start a second one beside the one in use. The image job carries
# the same step and the same reasoning; change one, look at the other.
if ! command -v docker >/dev/null 2>&1; then
echo "Installing the docker cli"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache docker-cli
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends docker.io
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y docker-cli
else
echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2
echo "inside the job. Add it to the runner's image." >&2
exit 1
fi
fi
docker --version
# Tagged by the digest of the Dockerfile that made it, so the tag moves exactly when the toolchain
# does and never otherwise. This runner is persistent, so the first build pays for a JDK, an
# Android SDK and the android workload — several minutes — and every run after it is a cache hit
# that prints two lines. A change to that Dockerfile is what buys a new one.
# ◆ BUILT ONCE PER DOCKERFILE, NOT ONCE PER RUN.
#
# The tag is the Dockerfile's own digest, so a tag that exists is by construction an image built
# from exactly this Dockerfile — and docker applies a tag only when the build succeeded, so it
# cannot be a half-built one. That makes `docker image inspect` a sound cache check rather than a
# guess, and it skips the context upload and the layer walk that a no-op `docker build` still does.
#
# The staleness this trades away is real and bounded. Everything that decides what is *in* the
# image — the command-line tools zip, the build-tools version, the workload — is pinned in the
# Dockerfile, so changing any of them changes the digest and rebuilds. What drifts is the JDK from
# apt. This is a build tool rather than something shipped, so that is the right way round; the
# image job takes the opposite trade with --pull, because what it builds is what users run.
#
# Deleting the image, or editing the Dockerfile, is how you force it.
- name: the android toolchain image
run: |
set -eu
tag="dodossh-android-build:$(sha256sum build/android-build.Dockerfile | cut -c1-16)"
if docker image inspect "$tag" >/dev/null 2>&1; then
echo "$tag is already on this runner"
else
docker build -t "$tag" -f build/android-build.Dockerfile build
fi
echo "ANDROID_BUILD_IMAGE=$tag" >> "$GITHUB_ENV"
echo "$tag"
# ◆ COPIED IN AND COPIED OUT, NOT BIND-MOUNTED, AND THAT IS NOT A PREFERENCE.
#
# `-v "$PWD:/build"` fails here, and it fails quietly: this runner is itself a container with the
# host's Docker socket handed to it, so the workspace path it reports — /root/.cache/act/<hash>/…
# — is a path inside the runner, not on the daemon's host. Docker resolves a bind source on the
# daemon side, finds nothing there, helpfully creates an empty directory and mounts that. The
# container then starts perfectly and answers:
#
# MSBUILD : error MSB1009: Project file does not exist.
#
# Nothing in that says "empty mount", and nothing earlier in the job would have caught it: the
# image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so
# neither of them proves a bind mount would work.
#
# `docker cp` goes over the same API and so does not care where the daemon lives. The repository
# is well under a megabyte packed, so copying the whole checkout in — .git included, because
# MinVer and the versionCode both read it — costs a moment.
#
# The NuGet cache is a named volume for the same reason. It lives on the daemon, needs no path
# either side can agree on, and turns the restore into a cache hit on every run after the first.
- name: build and package the nightly
id: nightly
run: |
set -eu
staged=artifacts/android-nightly
rm -rf "$staged"
# Created rather than run, because the copy has to happen between creating and starting: the
# script being executed arrives with it.
cid="$(docker create \
-e CI=true \
-v dodossh-nuget:/root/.nuget/packages \
"$ANDROID_BUILD_IMAGE" bash /build/scripts/ci-android.sh)"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
# ./. rather than . — with the trailing dot it is the directory's *contents* that land in
# /build, and without it the whole directory lands as /build/<name> and nothing is where the
# script expects it.
docker cp ./. "$cid:/build"
# --attach streams the build log and exits with the container's own status, so a failure in
# there is a failure here.
docker start --attach "$cid"
mkdir -p artifacts
docker cp "$cid:/build/$staged" artifacts/
# Read out of the manifest the container wrote rather than out of anything it printed. A
# container's stdout is the build log as well as its result, so a value parsed from it is one
# stray MSBuild line away from being wrong.
name="$(sed -n 's/.*"versionName":"\([^"]*\)".*/\1/p' "$staged/android-nightly.json")"
if [ -z "$name" ]; then
echo "The container produced no usable manifest." >&2
exit 1
fi
echo "staged=$staged" >> "$GITHUB_OUTPUT"
echo "version=$name" >> "$GITHUB_OUTPUT"
ls -la "$staged"
# This runner is Alpine, whose image carries busybox wget and no curl at all — and the step below
# talks to the forge six times, so it died on the first call that was not written to tolerate a
# failure: `curl: command not found`, exit 127, after the whole APK had been built.
#
# It took that long to say so because the two lookups before it end in `|| true` with stderr
# discarded, which is the right shape for "no nightly release exists yet" and indistinguishable
# from "there is no curl on this machine". Installing it here is what makes those two different
# again; the alternative of dropping the `|| true` would fail the step for a first-ever run.
#
# Not busybox wget instead. The asset upload is a multipart -F, which busybox wget cannot send,
# so the channel would publish a release with no APK on it — the exact failure the verification
# at the end of the step below exists to catch.
#
# Conditioned exactly like the step it serves, since nothing else in this job wants curl. The two
# conditions have to stay the same: loosen that one without loosening this one and the publish is
# back to exit 127, several minutes into the job.
- name: ensure curl
if: github.ref == 'refs/heads/main'
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
if ! command -v curl >/dev/null 2>&1; then
echo "Installing curl"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache curl
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends curl
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y curl
else
echo "No apk, apt-get or dnf here, so curl cannot be installed from inside the job." >&2
echo "Add curl to the runner's image; the nightly cannot be published without it." >&2
exit 1
fi
fi
curl --version | head -1
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
#
# Whoever can write a release here can put a build on every nightly phone, because the client fetches
# from this feed and Android's only check is that the signature matches — and this channel's key is
# in the repository for everybody. That is the same capability as the signing key, reached through a
# different door, and it is exactly what ADR 0011 rule 1 keeps off runners.
#
# It is acceptable here for one reason: this is not that channel. A nightly is signed by a key with
# no secrecy to lose, installs under its own package id, and cannot update the application anybody
# is trusting with their credentials. The release channel has none of this — no job, no token, no
# key on a runner — and the two are separate applications so that no mistake here can reach it.
#
# main only. A tag build must not touch this: a v* tag is the release channel's, and cutting it is a
# person's job.
- name: publish the nightly
if: github.ref == 'refs/heads/main'
env:
FORGE: https://git.dodotech.cloud
REPO: DodoTech-Public/DodoSSH
TOKEN: ${{ secrets.GITHUB_TOKEN }}
STAGED: ${{ steps.nightly.outputs.staged }}
VERSION: ${{ steps.nightly.outputs.version }}
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" IN THE DOCUMENT, AND NOT THE LAST ONE A GREEDY MATCH LANDS ON.
#
# This was `sed -n 's/.*"id":\([0-9]*\).*/\1/p'`, and the leading .* is greedy, so it walked past
# the release's own id to the last "id": in the response — which belongs to the embedded author
# object and is -2. [0-9]* then matched no digits at all and the answer was the empty string. A
# release had been created and the step failed saying none had.
#
# It broke both readings, and the other one silently: the existing-release lookup below got the
# same empty id, so the delete never fired, so the second run would have failed to create a
# release for a tag that already had one. A rolling channel that works exactly once.
#
# grep matches left to right and [0-9]\+ needs a digit, so the author's -2 cannot match at all
# and head -1 takes the release's. jq would say this in four characters and is not on this
# runner; anything parsing JSON with a regex should say which assumption it is making, and this
# one is: the release id is the first "id": with digits after it.
release_id() {
grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
}
# Deleted and recreated rather than updated in place. A rolling tag has to move, and moving one
# through this API is two calls with no atomic form either way — so the shape with the fewest
# states is to remove both 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: try later.
existing="$(curl -fsS -H "$auth" "$api/releases/tags/nightly" 2>/dev/null || true)"
if [ -n "$existing" ]; then
id="$(printf '%s' "$existing" | release_id)"
if [ -n "$id" ]; then
echo "Removing the previous nightly release $id"
curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
else
echo "A nightly 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/nightly" >/dev/null 2>&1 || true
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(printf '{"tag_name":"nightly","target_commitish":"%s","name":"Nightly %s","prerelease":true,"body":"Built from %s by CI, signed with the public nightly key. Installs beside the release build, never over it. See docs/adr/0014-android-updates.md."}' \
"$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
"$api/releases")"
release="$(printf '%s' "$created" | release_id)"
if [ -z "$release" ]; then
echo "Gitea accepted the release call and returned no id:" >&2
printf '%s\n' "$created" >&2
exit 1
fi
# The APK first and the manifest last, which is the ordering the client depends on: it reads the
# manifest and then fetches what the manifest names, so a manifest published before its APK is a
# few seconds in which every phone is told to download something that is not there yet.
names=""
for file in "$STAGED"/*.apk "$STAGED"/android-nightly.json; do
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$name" >/dev/null
names="$names $name"
done
# Asked for back rather than assumed, because the failure this whole channel is exposed to is a
# release that exists and carries nothing — which is what a phone sees as a feed it can read and
# never update from, and what a person sees as a release page offering source tarballs. That is
# exactly what the run before this one left behind, and it reported success for every upload it
# never made.
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $names; 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 $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
# clock on every main commit and buys the thing worth having: no image reaches the
# registry from a commit whose tests were red. An image is not a build artefact anyone
# inspects — it is the thing that gets deployed.
needs: [build]
runs-on: [linux]
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three.
- 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)"
# Warned about rather than failed on. Distributions pin their nodejs package to
# the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
# of life and older than the runtime these actions declare. It generally runs
# them anyway, since act_runner uses whichever node is on PATH regardless of what
# the action asked for, so this is a note for when one of them misbehaves in a
# way that makes no sense, not a reason to stop a build that is probably fine.
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, and it is load-bearing rather than tidy. MinVer derives the version from the
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
# answer here a client that never updates.
#
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
# copy, change all three.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# The daemon and the client are two different things to have, and this runner had only
# one of them. Testcontainers reaches Docker straight over /var/run/docker.sock from a
# .NET library, so every integration suite in the build job passed while `docker` was
# not a command here at all — which surfaced as exit 127 from the build step below,
# after the tags had been worked out and everything looked healthy.
#
# Only the CLI. The socket is already there and a daemon is already answering on it;
# installing an engine would start a second one beside the one being used.
- name: ensure the docker cli
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
if ! command -v docker >/dev/null 2>&1; then
echo "Installing the docker cli"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache docker-cli
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends docker.io
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y docker-cli
else
echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2
echo "inside the job. Add it to the runner's image." >&2
exit 1
fi
fi
docker --version
# buildx after the client, and wanted rather than required. Without the plugin
# `docker build` falls back to the legacy builder, which still produces the image
# and says on every run that it will not do so forever; with it the same command
# routes through BuildKit and the Dockerfile's independent stages stop being
# serialised. Alpine's docker-cli package does not carry it, which is why a job
# that had just been given a working client still built the deprecated way.
#
# A distribution with no package for it should get a warning and an image, not a
# failed release — so every branch here ends in `|| true` and the check below
# reports rather than exits.
if ! docker buildx version >/dev/null 2>&1; then
echo "Installing buildx"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache docker-cli-buildx || true
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq || true
$SUDO apt-get install -y --no-install-recommends docker-buildx || true
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y docker-buildx || true
fi
fi
if docker buildx version >/dev/null 2>&1; then
docker buildx version
else
echo "::warning::buildx is unavailable, so this image was built by the legacy" \
"builder Docker has deprecated. Add a buildx package to the runner image."
fi
# No docker/* actions here, deliberately. The build is single-architecture, so it
# wants the daemon this runner already has, a client, and BuildKit — all of which the
# step above arranges with two packages. What it does not want is QEMU, a builder
# instance to create and tear down, or a third-party action whose SHA has to be
# audited and re-pinned on a schedule. Adding linux/arm64 later is where that trade
# changes, and where setup-buildx-action starts earning its place.
- name: work out the tags
id: tags
env:
REGISTRY: registry-docker.dodotech.cloud
IMAGE: dodotech/dodossh-api
run: |
set -euo pipefail
repo="$REGISTRY/$IMAGE"
short="$(git rev-parse --short HEAD)"
# sha- prefixed, because a bare hex tag is ambiguous with a digest to both a human
# and a fair amount of tooling. This one is on every build and never moves, which
# makes it the only tag safe to pin a deployment to.
tags="$repo:sha-$short"
version="$short"
case "$GITHUB_REF" in
refs/tags/v*)
v="${GITHUB_REF#refs/tags/v}"
version="$v"
tags="$tags $repo:$v"
# The moving major.minor tag and :latest, but only for a release proper.
# v1.3.0-rc1 sorts after v1.2.9 and would otherwise take :latest with it,
# which is how a release candidate ends up on somebody's server.
case "$v" in
*-*) ;;
*)
tags="$tags $repo:${v%.*}"
tags="$tags $repo:latest"
;;
esac
;;
refs/heads/main)
tags="$tags $repo:main"
version="main-$short"
;;
esac
# The version MSBuild is allowed to see, which is not the same string as the one above.
# `version` is a docker tag and is `main-<short sha>` on a main build; handing that to
# -p:Version fails the publish with NETSDK1018. So this is set only when it is a real
# version, and the Dockerfile leaves the SDK default alone when it is empty.
assembly_version=""
case "$GITHUB_REF" in
refs/tags/v*) assembly_version="${GITHUB_REF#refs/tags/v}" ;;
esac
echo "tags=$tags" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "assemblyVersion=$assembly_version" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
echo "Tagging: $tags"
# Every value from a step output or the event goes through env rather than being
# interpolated into the script text. A git tag may contain a semicolon, and
# `${{ }}` is a textual substitution performed before the shell ever sees the line —
# so an interpolated tag name is a command the workflow agreed to run.
- name: build the image
env:
TAGS: ${{ steps.tags.outputs.tags }}
VERSION: ${{ steps.tags.outputs.version }}
ASSEMBLY_VERSION: ${{ steps.tags.outputs.assemblyVersion }}
REVISION: ${{ github.sha }}
CREATED: ${{ steps.tags.outputs.created }}
run: |
set -euo pipefail
args=()
for tag in $TAGS; do
args+=(--tag "$tag")
done
# --pull rather than whatever the runner happens to have cached: the base images
# are floating tags, and a runner that has held aspnet:10.0-noble-chiseled for a
# month is a month of unapplied CVE fixes shipping in every image built on it.
docker build \
--pull \
--file src/DodoSSH.Api/Dockerfile \
--build-arg "VERSION=$VERSION" \
--build-arg "ASSEMBLY_VERSION=$ASSEMBLY_VERSION" \
--build-arg "REVISION=$REVISION" \
--build-arg "CREATED=$CREATED" \
"${args[@]}" \
.
# Everything above runs on a pull request too. Building a fork's Dockerfile is safe —
# nothing is pushed and no credential is in scope — and it means a change that breaks
# the image fails on the PR rather than on main. Only these last two steps are held
# back, and the condition is on the event rather than on the branch so that a PR
# targeting main cannot reach them.
- name: log in to the registry
if: github.event_name != 'pull_request'
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -eu
# Checked before use, because an unset secret is not an error anywhere upstream of
# here: an expression that resolves to nothing renders as the empty string, so
# docker is handed --username "" and answers with something about credentials,
# which sends people to the registry to debug a value that never left the
# settings page.
#
# Note for anyone editing this comment: an expression delimiter written literally
# here is interpolated even though this is a shell comment. The runner substitutes
# the whole script before any shell sees it, so an empty one fails the step with a
# parse error and no line number — which is how this very block broke the release
# it was added to protect.
#
# Reported by length, and never by value. Gitea masks known secret values in logs,
# but a mask is only as good as the runner's bookkeeping and a length answers the
# only question being asked: did anything arrive.
missing=""
[ -n "${REGISTRY_USERNAME:-}" ] || missing="$missing REGISTRY_USERNAME"
[ -n "${REGISTRY_PASSWORD:-}" ] || missing="$missing REGISTRY_PASSWORD"
if [ -n "$missing" ]; then
echo "Empty or unset:$missing" >&2
echo >&2
echo "Both come from repository secrets, which in Gitea are at" >&2
echo " Settings -> Actions -> Secrets" >&2
echo "and are a different page from Settings -> Actions -> Variables. A value" >&2
echo "added as a variable is invisible to the secrets context and arrives here" >&2
echo "as an empty string, which is exactly what this message means." >&2
exit 1
fi
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
printf '%s' "$REGISTRY_PASSWORD" \
| docker login registry-docker.dodotech.cloud \
--username "$REGISTRY_USERNAME" --password-stdin
- name: push
if: github.event_name != 'pull_request'
env:
TAGS: ${{ steps.tags.outputs.tags }}
run: |
set -euo pipefail
for tag in $TAGS; do
docker push "$tag"
done
# The daemon is shared with every other job on this runner, and a credential left in
# ~/.docker/config.json outlives the job that created it. always(), so a failed push
# does not leave it behind.
- name: log out
if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud
# 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.
#
# It used to say that vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows tooling and
# so could not run on a Linux runner, and offered that as the smaller of two reasons. It was wrong, and
# worth recording as wrong because it is the kind of claim that discourages anybody from trying: vpk
# cross-compiles when told to, `vpk [win] pack` on this runner builds the setup stub, the portable zip
# and the .nupkg, and only signing needs Windows — which this repository does not do yet. That is what
# the packaging step now does on every main and tag build.
#
# The reason that stands is the one that was never mechanical. Velopack clients fetch from the release
# feed and do not verify a package signature when they apply it, so whoever can write a release on this
# repository can publish an update that every installed client downloads and runs. That is the same
# capability as the signing key, reached through a different door — and docs/adr/0011 rule 1 puts that
# capability on a machine which is not a runner, because a workflow secret is held by everyone who can
# change a workflow file. A Windows runner would never have answered it, which is why fixing the
# mechanical half changes nothing about the division of labour.
# See docs/adr/0013-desktop-distribution-and-updates.md.
#
# What cuts a release is scripts/release-windows.ps1, run by a person. What this file does is prove the
# thing still builds and packages, which is the same division of labour the android job above already
# has: it packages an APK nobody installs, so that a link-time break fails here rather than later.