From 781c4c015fa67cc3d401b951ccb5d4251ffc7b6b Mon Sep 17 00:00:00 2001 From: Stephen Cooper <1970249+scooper4711@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:05:28 +0200 Subject: [PATCH] Sign with the Developer ID and notarize releases --- .github/workflows/release.yml | 14 +++++++++++ README.md | 11 +++++---- docs/specs/library-manager/design.md | 18 +++++++++++--- scripts/add-signing-certificate.sh | 32 +++++++++++++++++++++++++ scripts/build-app.sh | 3 +-- scripts/notarize.sh | 36 ++++++++++++++++++++++++++++ scripts/sign-app.sh | 24 +++++++++++++++++++ 7 files changed, 129 insertions(+), 9 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 6c28b74..0145bc5 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/Scrollkeeper-$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 f5ebdc9..0f97ee1 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ Download the disk image from the [releases page](https://github.com/scooper4711/scrollkeeper/releases), open it and drag Scrollkeeper 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 Scrollkeeper once. macOS says it cannot be opened; click Done. 2. Open System Settings › Privacy & Security and scroll down to the message @@ -103,9 +105,10 @@ make coverage # runs the tests and enforces the coverage threshold make dmg # packages the app into a disk image ``` -Building needs Xcode 16 or later (Swift 6 toolchain). The app is ad-hoc signed; -after a rebuild, macOS may ask again for permission to use the saved Keychain -item. +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; after an ad-hoc rebuild, macOS +may ask again for permission to use the saved Keychain item. ## Building for iPad diff --git a/docs/specs/library-manager/design.md b/docs/specs/library-manager/design.md index 700b9c6..d5edbaf 100644 --- a/docs/specs/library-manager/design.md +++ b/docs/specs/library-manager/design.md @@ -10,9 +10,21 @@ A Swift package with three targets, plus an Xcode project for the iPad app: | `ScrollkeeperUI` | library | SwiftUI views and the `App` itself. They render `LibraryStore` state and forward user actions to it. Builds for macOS and iPadOS; the few differences are behind `#if os(...)`. | | `Scrollkeeper` | executable | The macOS entry point: one file that starts the shared `App`. | -`scripts/build-app.sh` wraps the macOS executable in an ad-hoc signed `.app` -bundle. `ios/Scrollkeeper.xcodeproj` builds the iPad app from the same -package: its only source file starts the shared `App`. +`scripts/build-app.sh` wraps the macOS executable in a `.app` bundle. +`ios/Scrollkeeper.xcodeproj` builds the iPad app from the same package: its only +source file starts the shared `App`. + +`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. The package depends on ZIPFoundation for unpacking archives, because iPadOS has no command-line tools to call. 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 264e227..266a903 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -94,6 +94,5 @@ cat > "$APP/Contents/Info.plist" < PLIST -# Ad-hoc signature: enough to run locally; Gatekeeper will still ask on other Macs. -codesign --force --sign - "$APP" +scripts/sign-app.sh "$APP" echo "Built $APP ($VERSION)" 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"