Give the desktop a macOS head, signed from the first release
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

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.
This commit is contained in:
2026-08-10 10:43:28 +02:00
parent e936ab4646
commit 890a5f2246
17 changed files with 2219 additions and 39 deletions
+88
View File
@@ -291,6 +291,7 @@ jobs:
# 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
@@ -374,6 +375,93 @@ jobs:
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