From 16f10e2af47467dc255b84d7c297770069c08274 Mon Sep 17 00:00:00 2001 From: Stephen Cooper <1970249+scooper4711@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:05:32 +0200 Subject: [PATCH] Sign with the Developer ID and notarize releases --- .github/workflows/release.yml | 14 ++++++++++++ README.md | 8 ++++--- docs/specs/app-sandbox/design.md | 13 ++++++++++- scripts/add-signing-certificate.sh | 32 ++++++++++++++++++++++++++ scripts/build-app.sh | 5 ++--- scripts/notarize.sh | 36 ++++++++++++++++++++++++++++++ scripts/sign-app.sh | 24 ++++++++++++++++++++ 7 files changed, 125 insertions(+), 7 deletions(-) create mode 100755 scripts/add-signing-certificate.sh create mode 100755 scripts/notarize.sh create mode 100755 scripts/sign-app.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08db1b2..21a8fdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,12 +30,26 @@ jobs: - name: Test run: scripts/coverage.sh + - name: Add the Developer ID certificate to a temporary keychain + env: + DEVELOPER_ID_CERTIFICATE: ${{ secrets.DEVELOPER_ID_CERTIFICATE }} + DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + run: scripts/add-signing-certificate.sh + - name: Build the universal app run: UNIVERSAL=1 scripts/build-app.sh - name: Make the disk image run: scripts/make-dmg.sh + - name: Notarize the disk image + if: env.RELEASING == 'true' + env: + NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} + NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} + NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} + run: scripts/notarize.sh "build/Mapsmith-$VERSION.dmg" + - name: Keep the disk image of a trial build if: env.RELEASING != 'true' uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 6063a0c..05bd2d4 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ macOS 15 or later. Download the disk image from the [releases page](https://github.com/scooper4711/mapsmith/releases), open it and drag Mapsmith to Applications. -The app is not notarized by Apple, so macOS blocks it the first time: +Releases are signed with the Developer ID and notarized by Apple, so macOS opens them like any other app. Older +releases aren't, and macOS blocks them the first time: 1. Open Mapsmith once. macOS says it cannot be opened; click Done. 2. Open System Settings › Privacy & Security and scroll down to the message about Mapsmith. @@ -108,8 +109,9 @@ make dmg # packages the app into a disk image make icon # redraws Resources/AppIcon.icns ``` -Building needs Xcode 16 or later (Swift 6 toolchain). The app is ad-hoc signed. Open `Package.swift` in Xcode -to run and debug it. +Building needs Xcode 16 or later (Swift 6 toolchain). `scripts/sign-app.sh` signs the app with the Developer ID when +the keychain holds a Developer ID Application certificate, and ad hoc otherwise. Open `Package.swift` in Xcode to +run and debug it. Tests that read real maps use PDFs in `TestData/`, which is not committed because the maps are copyrighted. Copy or link your own Flip-Mat PDFs there to run them; they are skipped when the folder is empty. diff --git a/docs/specs/app-sandbox/design.md b/docs/specs/app-sandbox/design.md index 9261878..fd01425 100644 --- a/docs/specs/app-sandbox/design.md +++ b/docs/specs/app-sandbox/design.md @@ -5,11 +5,22 @@ opened from Finder, the Dock, Open Recent and by Scrollkeeper come through Launch Services, which grants access to them; Open Recent keeps that access across launches. No network access: Mapsmith makes no requests. -- `scripts/build-app.sh` signs ad hoc with the entitlements and copies +- `scripts/build-app.sh` signs with the entitlements and copies `Resources/container-migration.plist` into the bundle's Resources. On the first sandboxed launch macOS moves the preferences (`com.github.scooper4711.Mapsmith.plist`, window layouts only) into `~/Library/Containers/com.github.scooper4711.Mapsmith`. +- **Signing.** `scripts/sign-app.sh` signs the app with the first Developer ID + Application certificate in the keychain, with the hardened runtime and a + secure timestamp, or ad hoc when there is none (`SIGNING_IDENTITY` overrides + it). On GitHub, `scripts/add-signing-certificate.sh` puts the certificate from + the `DEVELOPER_ID_CERTIFICATE` and `DEVELOPER_ID_CERTIFICATE_PASSWORD` secrets + into a temporary keychain; without it a release fails and a trial build is + signed ad hoc. For a release, `scripts/notarize.sh` signs the disk image, has + Apple's notary service check it (the `NOTARY_APPLE_ID`, `NOTARY_TEAM_ID` and + `NOTARY_PASSWORD` secrets, or a notarytool keychain profile on a Mac) and + staples the ticket to it. The team ID is kept only in the secrets and the + keychain. - `DroppedImageStore` writes dropped images under `temporaryDirectory`, which is inside the container. - SwiftUI doesn't restore document windows in the sandbox: it would need a diff --git a/scripts/add-signing-certificate.sh b/scripts/add-signing-certificate.sh new file mode 100755 index 0000000..3bf5c47 --- /dev/null +++ b/scripts/add-signing-certificate.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# On GitHub, puts the Developer ID certificate into a temporary keychain that codesign searches, so that +# scripts/sign-app.sh signs with it. Without the certificate a trial build is signed ad hoc and a release fails. +# +# DEVELOPER_ID_CERTIFICATE the certificate with its private key, as a base64-encoded .p12 file +# DEVELOPER_ID_CERTIFICATE_PASSWORD the .p12 file's password +# RELEASING "true" when building a release +set -eu + +if [ -z "${DEVELOPER_ID_CERTIFICATE:-}" ]; then + if [ "${RELEASING:-false}" = "true" ]; then + echo "::error::Signing failed: the DEVELOPER_ID_CERTIFICATE secret is missing." + exit 1 + fi + echo "::warning::The DEVELOPER_ID_CERTIFICATE secret is missing, so this trial build is signed ad hoc." + exit 0 +fi + +KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" +KEYCHAIN_PASSWORD="$(uuidgen)" +CERTIFICATE="$RUNNER_TEMP/developer-id.p12" + +printf '%s' "$DEVELOPER_ID_CERTIFICATE" | base64 --decode > "$CERTIFICATE" +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +security import "$CERTIFICATE" -k "$KEYCHAIN" -P "$DEVELOPER_ID_CERTIFICATE_PASSWORD" -T /usr/bin/codesign +rm "$CERTIFICATE" +# Lets codesign use the private key without a prompt no one is there to answer. +security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null +# shellcheck disable=SC2046 # The existing keychains are one quoted path per line, without spaces. +security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') diff --git a/scripts/build-app.sh b/scripts/build-app.sh index b9fe013..cad2997 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -30,9 +30,8 @@ cp Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cp Resources/container-migration.plist "$APP/Contents/Resources/container-migration.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" -c "Set :CFBundleVersion $VERSION" \ "$APP/Contents/Info.plist" -# Ad-hoc signature: enough to run locally; Gatekeeper still asks on other Macs. The entitlements turn on the -# App Sandbox. -codesign --force --sign - --entitlements Resources/Mapsmith.entitlements "$APP" +# The entitlements turn on the App Sandbox. +scripts/sign-app.sh "$APP" Resources/Mapsmith.entitlements echo "Built $APP ($VERSION)" if [[ "${1:-}" == "--install" ]]; then diff --git a/scripts/notarize.sh b/scripts/notarize.sh new file mode 100755 index 0000000..1453e3a --- /dev/null +++ b/scripts/notarize.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Signs a disk image with the Developer ID, has Apple notarize it, and staples the ticket to it, so Gatekeeper +# opens the app inside on any Mac without warning. The app must already be signed with the Developer ID. +# +# Usage: scripts/notarize.sh +# NOTARY_PROFILE the notarytool keychain profile to use (default tableside-notary), made once with +# xcrun notarytool store-credentials tableside-notary --apple-id --team-id +# NOTARY_APPLE_ID, NOTARY_TEAM_ID, NOTARY_PASSWORD used instead when NOTARY_APPLE_ID is set, as on GitHub +set -eu + +DMG="$1" +IDENTITY="${SIGNING_IDENTITY:-$(security find-identity -v -p codesigning \ + | awk '/"Developer ID Application:/ { print $2; exit }')}" + +[ -f "$DMG" ] || { echo "Notarizing failed: $DMG does not exist." >&2; exit 1; } +[ -n "$IDENTITY" ] || { + echo "Notarizing failed: there is no Developer ID Application certificate in the keychain." >&2 + exit 1 +} + +codesign --force --sign "$IDENTITY" --timestamp "$DMG" + +if [ -n "${NOTARY_APPLE_ID:-}" ]; then + set -- --apple-id "$NOTARY_APPLE_ID" --team-id "$NOTARY_TEAM_ID" --password "$NOTARY_PASSWORD" +else + set -- --keychain-profile "${NOTARY_PROFILE:-tableside-notary}" +fi +RESULT="$(xcrun notarytool submit "$DMG" "$@" --wait --output-format json)" +STATUS="$(printf '%s' "$RESULT" | plutil -extract status raw -o - -)" +if [ "$STATUS" != "Accepted" ]; then + xcrun notarytool log "$(printf '%s' "$RESULT" | plutil -extract id raw -o - -)" "$@" >&2 || true + echo "Notarizing failed: Apple answered \"$STATUS\" for $DMG." >&2 + exit 1 +fi +xcrun stapler staple "$DMG" +echo "Notarized $DMG" diff --git a/scripts/sign-app.sh b/scripts/sign-app.sh new file mode 100755 index 0000000..7021cbf --- /dev/null +++ b/scripts/sign-app.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Signs an app bundle with the Developer ID and the hardened runtime when the keychain holds a Developer ID +# Application certificate, as notarizing requires. Without one it signs ad hoc, which runs on this Mac only. +# +# Usage: scripts/sign-app.sh [] +# SIGNING_IDENTITY the identity to sign with instead of the first Developer ID found; "-" signs ad hoc +set -eu + +APP="$1" +ENTITLEMENTS="${2:-}" +IDENTITY="${SIGNING_IDENTITY:-$(security find-identity -v -p codesigning \ + | awk '/"Developer ID Application:/ { print $2; exit }')}" +IDENTITY="${IDENTITY:--}" + +set -- --force --sign "$IDENTITY" +if [ -n "$ENTITLEMENTS" ]; then + set -- "$@" --entitlements "$ENTITLEMENTS" +fi +if [ "$IDENTITY" = "-" ]; then + echo "Signing ad hoc: no Developer ID Application certificate was found or chosen." +else + set -- "$@" --options runtime --timestamp +fi +codesign "$@" "$APP"