Public Access
Three scripts prompted for a secret with Read-Host -AsSecureString or read -s, said in a comment that it therefore never lands in shell history, and then wrote it straight into the arguments of the next process. The comment was true and was answering the wrong question. A command line is not private: on Windows one call to Get-CimInstance Win32_Process hands every argument of every process running as this user to anything that asks, /proc/<pid>/cmdline is world-readable on Linux, and process-creation auditing and EDR agents keep their own copy either way. The values were the Android release signing key's passwords and the forge token — between them the whole capability to ship an update that every installed copy will fetch and run, which ADR 0011 rule 1 and ADR 0013 go to some length to keep off a runner and which was then left in a process listing on a desk. Both replacements are documented features of the tools rather than tricks. The Android passwords use .NET for Android's env: prefix, which puts the name of a variable on the command line and lets MSBuild read the value out of the environment itself. Set immediately before the build and cleared in a finally, so a failed signing does not leave them in the shell for whatever runs next, and -nodeReuse:false so no persistent MSBuild worker outlives the call still holding the inherited copy in its own environment. That prefix is unsupported when AndroidPackageFormat is aab, and the failure is silent — the package would be signed with the literal string "env:DODOSSH_ANDROID_STOREPASS" as its password. So the format is read back and the build stops if it is ever changed, in the same shape as the versionCode guard a few lines above: a wrong release refused here rather than discovered later on somebody's phone. vpk documents VPK_TOKEN as --token's environment fallback, so the flag is simply gone from both desktop scripts and the token never becomes an argument at all. Unset afterwards on the failure path too, which on macOS needs the exit status captured rather than a bare || stop_with. release-macos.sh had the same fault and was missed when the other two were first reported. Its Apple credentials were already right — a notarytool keychain profile rather than --password — and only the forge token was exposed. What this does not claim: an environment block is readable by a process holding rights over this one, the same as its memory is. The comments say so rather than implying the values are now safe. What is bought is real and narrower — off the command line, out of every process listing, out of the audit trail, and out of any log that echoes the invocation. Both PowerShell files parse, all three shell scripts pass bash -n, the AndroidPackageFormat probe evaluates and answers empty so the guard passes, and dotnet build accepts -nodeReuse:false. The signing itself is not exercised here: it needs the release keystore, which by design is not on this machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
22 KiB
Bash
423 lines
22 KiB
Bash
#!/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 an
|
|
# ambient environment variable, 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.
|
|
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
|
|
# alternative is --token on a command line that is not private. See the comment at the upload itself.
|
|
#
|
|
# 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.
|
|
#
|
|
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
|
|
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
|
|
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
|
|
# keeps its own copy. The comment above is about shell history and was never the whole story.
|
|
#
|
|
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
|
|
# Exported for the one call and unset straight after; an environment block is still readable by a
|
|
# process with rights over this one, but it is out of every process listing and audit log.
|
|
UPLOAD_ARGS=(
|
|
upload gitea
|
|
--repoUrl "$REPO_URL"
|
|
--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'
|
|
|
|
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
|
|
# sitting in this shell for whatever runs next.
|
|
export VPK_TOKEN="$TOKEN"
|
|
vpk_status=0
|
|
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
|
|
unset VPK_TOKEN
|
|
|
|
[ "$vpk_status" -eq 0 ] || 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
|