#!/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 # # 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/ 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 # --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 <