Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions docs/specs/library-manager/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions scripts/add-signing-certificate.sh
Original file line number Diff line number Diff line change
@@ -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 '"')
3 changes: 1 addition & 2 deletions scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,5 @@ cat > "$APP/Contents/Info.plist" <<PLIST
</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)"
36 changes: 36 additions & 0 deletions scripts/notarize.sh
Original file line number Diff line number Diff line change
@@ -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 <disk image>
# NOTARY_PROFILE the notarytool keychain profile to use (default tableside-notary), made once with
# xcrun notarytool store-credentials tableside-notary --apple-id <email> --team-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"
24 changes: 24 additions & 0 deletions scripts/sign-app.sh
Original file line number Diff line number Diff line change
@@ -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 <app> [<entitlements>]
# 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"
Loading