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
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env bash
#
# Builds, packages and publishes the macOS desktop client.
#
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
# capability to ship somebody a build on a machine which is not a runner, and
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
# it, so whoever can write a release can ship an update every install runs.
#
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
# except the notarization submission, which Apple sees and users do not.
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
#
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
#
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
# notarization round trip are the price of the package being installable at all, not an improvement to
# be bought later.
#
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
#
# Three values are read from the environment, and none of them is itself a secret — they name things the
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
#
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
#
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
# third is created once per machine:
#
# xcrun notarytool store-credentials DodoSSH \
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
#
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
# minutes a credential that can publish an update spends in a shell's memory the better.
#
# Usage:
# bash scripts/release-macos.sh
# bash scripts/release-macos.sh --upload
# bash scripts/release-macos.sh --skip-tests
set -euo pipefail
UPLOAD=0
SKIP_TESTS=0
for arg in "$@"; do
case "$arg" in
--upload) UPLOAD=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
exit 1
;;
esac
done
# ---- The contract with every installed client ---------------------------------------------------------
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
# script states — it is what an installed client matches an update against.
#
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
#
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
#
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
PACK_ID='DodoSSH.Desktop'
PACK_TITLE='DodoSSH'
PACK_AUTHORS='DodoTech'
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
# upload does not.
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
# leaving it unsaid on both sides would work — 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.
CHANNEL='osx'
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
#
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
# repository that reached users unverified.
#
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
RUNTIME='osx-arm64'
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
RELEASES_DIR="$REPO_ROOT/Releases"
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
# ---- Is this machine able to do the job at all? -------------------------------------------------------
if [ "$(uname -s)" != 'Darwin' ]; then
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
# cannot be produced anywhere but here.
stop_with 'This builds a signed macOS package and has to run on macOS.'
fi
for tool in dotnet git xcrun codesign; do
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
done
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
# this script does and the slowest, and discovering there that a profile name was never exported means
# throwing away a full build and test run.
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
if [ -z "${!required-}" ]; then
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
fi
done
cd "$REPO_ROOT"
# ---- What is being released ---------------------------------------------------------------------------
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
# tag. 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.
write_step 'Restoring the desktop head, so the version can be read'
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
TAG="v$VERSION"
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
#
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
# care that the string parses and not what it says. See build/macos/Info.plist.template.
PLIST_VERSION="${VERSION%%-*}"
PLIST_VERSION="${PLIST_VERSION%%+*}"
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
if [ "$UPLOAD" -eq 1 ]; then
# The installer package is the artefact a person downloads, so its absence is the honest test of
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
fi
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
# -s so the token is never echoed and never lands in the shell's history.
printf 'Gitea token (write:repository): '
read -r -s TOKEN
echo
[ -n "$TOKEN" ] || stop_with 'No token given.'
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
# also already hold the Windows package for the same tag, which is the case --merge is really doing
# the work for: without it the second platform to publish a given version fails on a release that
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
UPLOAD_ARGS=(
upload gitea
--repoUrl "$REPO_URL"
--token "$TOKEN"
--outputDir "$RELEASES_DIR"
--channel "$CHANNEL"
--releaseName "$TAG"
--tag "$TAG"
--merge
--publish
)
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
# release candidate is a prerelease in every channel or in none.
case "$VERSION" in
*-*) UPLOAD_ARGS+=(--pre) ;;
esac
write_step 'Uploading'
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
write_step "Published $TAG."
exit 0
fi
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
# somebody pins a version by hand this is the guard that notices.
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
write_step 'Restoring tools'
dotnet tool restore || stop_with 'dotnet tool restore failed.'
write_step 'Restoring packages (locked, exactly as CI does)'
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
write_step 'Building'
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
if [ "$SKIP_TESTS" -eq 0 ]; then
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
write_step 'Testing'
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
fi
write_step "Publishing $RUNTIME"
rm -rf "$PUBLISH_DIR"
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
# per RID and a self-extracting bundle breaks delta updates.
#
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
# build. The gate that matters is the locked solution restore above, which is untouched.
dotnet publish "$PROJECT" \
--configuration Release \
--runtime "$RUNTIME" \
--self-contained true \
--output "$PUBLISH_DIR" \
-p:RestoreLockedMode=false \
|| stop_with 'Publish failed.'
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
# tree, so anything modified here is its own.
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
# application that never checks for updates — which looks completely normal until the next release goes out
# and nobody receives it.
for required in DodoSSH Velopack.dll; do
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
done
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
#
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
# pass and applies the outer entitlements to everything it touches.
#
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
# notarization rejection that does not name the offending file, on a submission that took its time getting
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
# an already correctly signed binary with -f is a no-op in effect.
#
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
write_step 'Signing native libraries'
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
# people forget because it has no extension to grep for.
NATIVE_COUNT=0
while IFS= read -r -d '' binary; do
codesign --force --verbose=0 --timestamp --options runtime \
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|| stop_with "codesign failed on $binary"
NATIVE_COUNT=$((NATIVE_COUNT + 1))
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
echo " signed $NATIVE_COUNT native binaries"
# ---- The bundle's Info.plist --------------------------------------------------------------------------
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
# so a committed one would carry whatever version it was written with into every release afterwards.
# See the header of build/macos/Info.plist.template.
write_step "Rendering Info.plist for $PLIST_VERSION"
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
trap 'rm -f "$RENDERED_PLIST"' EXIT
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
mkdir -p "$RELEASES_DIR"
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
write_step 'Fetching the previous release, for deltas'
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
fi
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
# part is not local: it builds the .app from the published files, signs it with the Developer ID
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
#
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
#
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
# reads like a keychain problem rather than like the wrong certificate.
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
dotnet vpk pack \
--packId "$PACK_ID" \
--packVersion "$VERSION" \
--packDir "$PUBLISH_DIR" \
--packTitle "$PACK_TITLE" \
--packAuthors "$PACK_AUTHORS" \
--mainExe 'DodoSSH' \
--icon "$ICON" \
--plist "$RENDERED_PLIST" \
--entitlements "$ENTITLEMENTS" \
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
--runtime "$RUNTIME" \
--channel "$CHANNEL" \
--outputDir "$RELEASES_DIR" \
|| stop_with 'vpk pack failed.'
# ---- Did the notarization actually take? --------------------------------------------------------------
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
# certificate.
write_step 'Verifying the notarization the way another Mac will'
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
if ! spctl --assess --type install --verbose=4 "$PKG"; then
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
fi
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
write_step 'Built, notarized, and deliberately not uploaded'
ls -lh "$RELEASES_DIR" | tail -n +2
cat <<EOF
Next:
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
2. Then: bash scripts/release-macos.sh --upload
EOF