From 94912241bc10c6ce5cfd524742cce43c3136e862 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 03:57:21 +0100 Subject: [PATCH] feat(desktop): sign-macos.sh signs, notarizes and staples the app and its DMG The release DMG is signed with the owner's Developer ID and notarized, so a downloaded copy opens after macOS's usual confirmation instead of stopping at Gatekeeper. The work is a script of its own, so CI and a Mac run it the same way: SIGN_IDENTITY=... NOTARY_PROFILE= \ packages/desktop/scripts/sign-macos.sh It re-signs the app with the hardened runtime, a secure timestamp and macos/entitlements.plist, checks the signature is a Developer ID one with the runtime flag, has notarytool notarize it and staples the ticket, builds the DMG around the stapled app, signs it, notarizes and staples it too, and has spctl assess both. Codeman.dmg and Codeman.dmg.sha256 reach only after every check passed; a failed run removes an older pair there rather than leave one to upload by mistake. A submission Apple does not accept stops the script with its id and Apple's log. The notary login is either a notarytool keychain profile (NOTARY_PROFILE, NOTARY_KEYCHAIN) or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID; `sign-macos.sh --check` tests that without doing anything. The DMG layout (the app next to an /Applications link, UDZO, volume "Codeman") moves to make-dmg.sh, which both scripts call, and gains a retry: hosted macOS runners now and then fail hdiutil create with "Resource busy". bundle-macos.sh keeps its SIGN_IDENTITY path as it was; its new --notarize checks the environment before building, then hands the app to sign-macos.sh. packages/desktop/README.md says the release DMG is notarized and how to do the same by hand, and drops the CI build and notarization from its ideas list. All three scripts are bash 3.2 clean, and shellcheck reports nothing. With the macOS tools stubbed, sign-macos.sh was run under bash 3.2 through a full signing, a notarization rejected for the app and for the DMG, two hdiutil failures then a pass, three failures, a relative out-dir and every missing setting. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/README.md | 12 +- packages/desktop/scripts/bundle-macos.sh | 32 +++-- packages/desktop/scripts/make-dmg.sh | 42 +++++++ packages/desktop/scripts/sign-macos.sh | 144 +++++++++++++++++++++++ 4 files changed, 218 insertions(+), 12 deletions(-) create mode 100755 packages/desktop/scripts/make-dmg.sh create mode 100755 packages/desktop/scripts/sign-macos.sh diff --git a/packages/desktop/README.md b/packages/desktop/README.md index cf7e1c17..b5259e81 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -213,10 +213,12 @@ and the Dock icon bounces. Anyway* in System Settings > Privacy & Security (it asks for an administrator password). On macOS 14 and earlier, right-click it and choose *Open* once. Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the - copy in Applications works too. Shipping it to other people needs a Developer - ID signature and notarization: - `SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`, - then `xcrun notarytool submit` and `xcrun stapler staple`. + copy in Applications works too. The `Codeman.dmg` on each GitHub release is + signed with a Developer ID and notarized by CI + (`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the + same by hand: + `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= ./scripts/bundle-macos.sh --universal --notarize`, + where `scripts/sign-macos.sh` lists what it needs. - **Local Network permission (macOS 15 and later).** The first connection to an address on the local network (a LAN IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that first attempt can fail @@ -278,4 +280,4 @@ and the Dock icon bounces. - Trusting a self-signed server certificate by its fingerprint. - Asking before a download that a rendered HTML file or a web tab starts. - A way out of a page that keeps opening dialogs (a "block further dialogs" option). -- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update. +- Windows and Linux builds, auto-update. diff --git a/packages/desktop/scripts/bundle-macos.sh b/packages/desktop/scripts/bundle-macos.sh index ac1b5e6d..6fbdabdf 100755 --- a/packages/desktop/scripts/bundle-macos.sh +++ b/packages/desktop/scripts/bundle-macos.sh @@ -4,6 +4,8 @@ # packages/desktop/scripts/bundle-macos.sh # this Mac's architecture # packages/desktop/scripts/bundle-macos.sh --universal --dmg # SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg +# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= \ +# packages/desktop/scripts/bundle-macos.sh --universal --notarize # # Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built # it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15 @@ -11,6 +13,15 @@ # Privacy & Security; on macOS 14 and earlier, right-click it and choose Open. # Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy # in /Applications. A Developer ID signature + notarization avoids all of this. +# +# --dmg writes dist/Codeman-.dmg, signed with SIGN_IDENTITY when it is +# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it +# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does +# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied). +# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE +# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow +# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and +# sign-macos.sh in another, so the job that runs cargo holds no secret. set -euo pipefail cd "$(dirname "$0")/.." @@ -18,14 +29,21 @@ cd "$(dirname "$0")/.." UNIVERSAL=0 DMG=0 +NOTARIZE=0 for arg in "$@"; do case "$arg" in --universal) UNIVERSAL=1 ;; --dmg) DMG=1 ;; + --notarize) NOTARIZE=1 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac done +if [ "$NOTARIZE" = 1 ]; then + # Checked before the build, so a missing secret costs seconds, not a build. + ./scripts/sign-macos.sh --check +fi + VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1) [ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } @@ -98,13 +116,13 @@ codesign --verify --strict "$APP" echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)" -if [ "$DMG" = 1 ]; then - STAGE="$WORK/dmg" - mkdir -p "$STAGE" - cp -R "$APP" "$STAGE/" - ln -s /Applications "$STAGE/Applications" +if [ "$NOTARIZE" = 1 ]; then + ./scripts/sign-macos.sh "$APP" dist +elif [ "$DMG" = 1 ]; then DMG_PATH="dist/Codeman-$VERSION.dmg" - rm -f "$DMG_PATH" - hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH" + ./scripts/make-dmg.sh "$APP" "$DMG_PATH" + if [ -n "${SIGN_IDENTITY:-}" ]; then + codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH" + fi echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))" fi diff --git a/packages/desktop/scripts/make-dmg.sh b/packages/desktop/scripts/make-dmg.sh new file mode 100755 index 00000000..22eea160 --- /dev/null +++ b/packages/desktop/scripts/make-dmg.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Put Codeman.app in a compressed disk image next to a link to /Applications, +# so opening the image shows the app and the folder to drag it into. +# +# packages/desktop/scripts/make-dmg.sh +# +# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG +# and the release DMG are laid out the same way. It signs nothing. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; } +[ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } + +APP=$1 +DMG=$2 +[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +STAGE="$WORK/dmg" +mkdir -p "$STAGE" +# ditto keeps what cp -R can lose: extended attributes, and with them a +# stapled notarization ticket. +ditto "$APP" "$STAGE/$(basename "$APP")" +ln -s /Applications "$STAGE/Applications" + +# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the +# same command a few seconds later passes. +rm -f "$DMG" +tries=1 +until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do + if [ "$tries" -ge 3 ]; then + echo "hdiutil create failed $tries times" >&2 + exit 1 + fi + tries=$((tries + 1)) + echo "hdiutil create failed, trying again in 10 seconds" >&2 + rm -f "$DMG" + sleep 10 +done +hdiutil verify -quiet "$DMG" diff --git a/packages/desktop/scripts/sign-macos.sh b/packages/desktop/scripts/sign-macos.sh new file mode 100755 index 00000000..46266321 --- /dev/null +++ b/packages/desktop/scripts/sign-macos.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the +# ticket to it, then build Codeman.dmg around the stapled app and do the same +# for the DMG. On macOS. +# +# packages/desktop/scripts/sign-macos.sh +# packages/desktop/scripts/sign-macos.sh --check # only check the environment below +# +# is re-signed and stapled in place. receives +# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it, +# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both +# appear only once every check passed; a failed run leaves neither, not even an +# older pair, so nothing half-done can be uploaded by mistake. +# +# The environment names who signs: +# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain +# on the search list, by name or by the SHA-1 hash that +# `security find-identity -v -p codesigning` prints +# and how to reach Apple's notary service, either through a profile saved with +# `xcrun notarytool store-credentials --apple-id ... --team-id ...`: +# NOTARY_PROFILE the profile's name +# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login) +# or with the Apple ID itself: +# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID +# +# By hand, on a Mac with the certificate in the login keychain: +# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \ +# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist +# +# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that +# builds nothing: the job that runs cargo, and with it the build script of every +# crate in Cargo.lock, never holds a signing secret. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; } + +DESKTOP=$(cd "$(dirname "$0")/.." && pwd) +ENTITLEMENTS="$DESKTOP/macos/entitlements.plist" + +# Fails with the name of what is missing, before any work, so a missing secret +# costs seconds rather than a build. +check_env() { + if [ -z "${SIGN_IDENTITY:-}" ]; then + echo "sign-macos.sh needs SIGN_IDENTITY" >&2 + exit 2 + fi + if [ -n "${NOTARY_PROFILE:-}" ]; then + return 0 + fi + local name + for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do + if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then + echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2 + exit 2 + fi + done +} + +if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then + check_env + exit 0 +fi +[ $# -eq 2 ] || { echo "usage: $0 | --check" >&2; exit 2; } +check_env + +APP=$1 +OUT=$2 +[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; } +mkdir -p "$OUT" +OUT=$(cd "$OUT" && pwd) +rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256" +NAME=$(basename "$APP" .app) + +if [ -n "${NOTARY_PROFILE:-}" ]; then + NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE") + if [ -n "${NOTARY_KEYCHAIN:-}" ]; then + NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN") + fi +else + # On notarytool's command line, which the job's log never shows. + NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID") +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Submit a file to the notary service and wait. Anything but Accepted stops the +# script with the submission id and Apple's log, which names the file and the +# reason (an unsigned binary, a missing timestamp, no hardened runtime...). +notarize() { + local file=$1 result="$WORK/notary.plist" rc=0 status id + echo "Submitting $(basename "$file") to Apple's notary service" + xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \ + --output-format plist > "$result" || rc=$? + status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true) + id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true) + if [ "$status" != Accepted ]; then + echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2 + cat "$result" >&2 || true + if [ -n "$id" ]; then + xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true + fi + exit 1 + fi + echo "Notarized $(basename "$file") (submission $id)" +} + +# The app: hardened runtime (which notarization requires), a secure timestamp, +# and the entitlements the hardened runtime needs for microphone and camera. +codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_IDENTITY" "$APP" +codesign --verify --strict --verbose=2 "$APP" +DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1) +printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true +case "$DETAILS" in + *"Authority=Developer ID Application: "*) ;; + *) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;; +esac +case "$DETAILS" in + *"(runtime)"*) ;; + *) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;; +esac + +ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip" +notarize "$WORK/$NAME.zip" +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=2 "$APP" + +# The DMG around the stapled app, signed and notarized in its own right, so the +# image opens without a warning and the app inside opens offline. +DMG="$WORK/Codeman.dmg" +"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG" +codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG" +codesign --verify --strict --verbose=2 "$DMG" +notarize "$DMG" +xcrun stapler staple "$DMG" +xcrun stapler validate "$DMG" +spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG" + +(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256) +mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/" +echo "Signed and notarized $APP" +echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")"