diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8982301..51a3881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,11 @@ jobs: - name: Test run: swift test + - name: Install DMG packaging tool + run: | + create_dmg_bin="$(./script/install_create_dmg.sh "$RUNNER_TEMP/create-dmg")" + echo "CREATE_DMG_BIN=$create_dmg_bin" >> "$GITHUB_ENV" + - name: Validate automation run: | bash -n script/*.sh diff --git a/.github/workflows/notarization-continuation.yml b/.github/workflows/notarization-continuation.yml index ef2b29b..fc92e03 100644 --- a/.github/workflows/notarization-continuation.yml +++ b/.github/workflows/notarization-continuation.yml @@ -23,23 +23,22 @@ jobs: runs-on: macos-26 timeout-minutes: 45 - env: - MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - steps: - name: Check out release tooling uses: actions/checkout@v7 with: ref: main + fetch-depth: 0 persist-credentials: false - name: Validate release secrets env: APPCAST_DEPLOY_KEY: ${{ secrets.APPCAST_DEPLOY_KEY }} + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} DAYLINE_SPARKLE_PRIVATE_KEY: ${{ secrets.DAYLINE_SPARKLE_PRIVATE_KEY }} run: | for name in \ @@ -74,6 +73,9 @@ jobs: run: swift build - name: Import Developer ID certificate + env: + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: | certificate_path="$RUNNER_TEMP/developer-id.p12" keychain_path="$RUNNER_TEMP/dayline-signing.keychain-db" @@ -106,6 +108,10 @@ jobs: echo "SIGNING_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV" - name: Write App Store Connect API key + env: + APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} run: | key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" printf '%s' "$APP_STORE_CONNECT_KEY_P8_BASE64" | /usr/bin/base64 -D > "$key_path" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cfb7e6..5f314d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,11 +26,6 @@ jobs: env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} - MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} steps: - name: Check out tagged source @@ -53,6 +48,11 @@ jobs: - name: Validate release secrets env: APPCAST_DEPLOY_KEY: ${{ secrets.APPCAST_DEPLOY_KEY }} + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} DAYLINE_SPARKLE_PRIVATE_KEY: ${{ secrets.DAYLINE_SPARKLE_PRIVATE_KEY }} run: | for name in \ @@ -84,6 +84,9 @@ jobs: echo "APPCAST_DEPLOY_KEY_PATH=$key_path" >> "$GITHUB_ENV" - name: Import Developer ID certificate + env: + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: | certificate_path="$RUNNER_TEMP/developer-id.p12" keychain_path="$RUNNER_TEMP/dayline-signing.keychain-db" @@ -116,6 +119,10 @@ jobs: echo "SIGNING_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV" - name: Write App Store Connect API key + env: + APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} run: | key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" printf '%s' "$APP_STORE_CONNECT_KEY_P8_BASE64" | /usr/bin/base64 -D > "$key_path" diff --git a/README.md b/README.md index 00e46bd..e7b83a4 100644 --- a/README.md +++ b/README.md @@ -513,12 +513,20 @@ CI also runs `swift build` and `swift test` for pull requests and pushes to ### Manual fallback -Create local unsigned or development-signed artifacts: +Install the DMG layout tool with Homebrew before packaging locally: ```sh +brew install create-dmg ./script/package_release.sh ``` +Alternatively, use the checksum-pinned bootstrap used by CI: + +```sh +CREATE_DMG_BIN="$(./script/install_create_dmg.sh /tmp/dayline-create-dmg)" \ + ./script/package_release.sh +``` + Official notarization should run through GitHub Actions so the preserved artifact, submission IDs, and continuation state stay together. The scripts reject dirty, untagged, mismatched, corrupt, or duplicate public releases. diff --git a/Resources/DaylineDMGBackground.svg b/Resources/DaylineDMGBackground.svg new file mode 100644 index 0000000..6be5c53 --- /dev/null +++ b/Resources/DaylineDMGBackground.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/Dayline/Services/UpdateService.swift b/Sources/Dayline/Services/UpdateService.swift index 8ff46e3..489b8c2 100644 --- a/Sources/Dayline/Services/UpdateService.swift +++ b/Sources/Dayline/Services/UpdateService.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation @preconcurrency import Sparkle @@ -20,6 +21,7 @@ final class UpdateService: NSObject, ObservableObject { } private var updaterController: SPUStandardUpdaterController? + private var injectedActivateApplicationAction: (() -> Void)? private var injectedCheckForUpdatesAction: (() -> Void)? private var automaticallyDownloadsObservation: NSKeyValueObservation? private var canCheckForUpdatesObservation: NSKeyValueObservation? @@ -71,8 +73,13 @@ final class UpdateService: NSObject, ObservableObject { } /// Creates an isolated updater action for unit tests without starting Sparkle. - init(canCheckForUpdates: Bool, checkForUpdatesAction: @escaping () -> Void) { + init( + canCheckForUpdates: Bool, + activateApplicationAction: @escaping () -> Void = {}, + checkForUpdatesAction: @escaping () -> Void + ) { self.canCheckForUpdates = canCheckForUpdates + injectedActivateApplicationAction = activateApplicationAction injectedCheckForUpdatesAction = checkForUpdatesAction super.init() } @@ -83,16 +90,28 @@ final class UpdateService: NSObject, ObservableObject { updaterController?.updater.automaticallyDownloadsUpdates = isEnabled } - /// Runs a user-initiated update check, letting Sparkle present its standard UI. + /// Runs a user-initiated update check after the menu-bar sheet finishes dismissing. func checkForUpdates() { guard canCheckForUpdates else { return } - if let injectedCheckForUpdatesAction { - injectedCheckForUpdatesAction() - return + + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + if let injectedActivateApplicationAction { + injectedActivateApplicationAction() + } else { + NSApplication.shared.activate(ignoringOtherApps: true) + } + + if let injectedCheckForUpdatesAction { + injectedCheckForUpdatesAction() + } else { + updaterController?.checkForUpdates(nil) + } } - updaterController?.checkForUpdates(nil) } /// Keeps the footer reminder for an update Sparkle has staged to install on quit. diff --git a/Tests/DaylineTests/AppleCalendarEventCreationTests.swift b/Tests/DaylineTests/AppleCalendarEventCreationTests.swift index 1cbe484..2c14c80 100644 --- a/Tests/DaylineTests/AppleCalendarEventCreationTests.swift +++ b/Tests/DaylineTests/AppleCalendarEventCreationTests.swift @@ -26,8 +26,8 @@ struct AppleCalendarEventCreationTests { let store = StatusStore(mockData: MockData.make()) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current - let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10))) - let inclusiveEnd = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 11))) + let start = calendar.startOfDay(for: Date()) + let inclusiveEnd = try #require(calendar.date(byAdding: .day, value: 1, to: start)) try await store.createAppleCalendarEvent(draft: AppleCalendarEventCreateDraft( title: "Conference", diff --git a/Tests/DaylineTests/UpdateServiceTests.swift b/Tests/DaylineTests/UpdateServiceTests.swift index 568ff19..cb5e82b 100644 --- a/Tests/DaylineTests/UpdateServiceTests.swift +++ b/Tests/DaylineTests/UpdateServiceTests.swift @@ -1,3 +1,4 @@ +import Dispatch import Testing @testable import Dayline @@ -11,18 +12,46 @@ struct UpdateServiceTests { #expect(service.availableVersion == "9.9.9") } - @Test func enabledInjectedUpdaterInvokesConfiguredCheckAction() { - var invocationCount = 0 - let service = UpdateService(canCheckForUpdates: true) { - invocationCount += 1 - } + @Test(.timeLimit(.minutes(1))) + func enabledInjectedUpdaterActivatesThenChecksOnNextMainLoopTurn() async { + var actions: [String] = [] + let (checkEvents, checkEventContinuation) = AsyncStream.makeStream() + let service = UpdateService( + canCheckForUpdates: true, + activateApplicationAction: { + actions.append("activate") + }, + checkForUpdatesAction: { + actions.append("check") + checkEventContinuation.yield() + } + ) #expect(service.isUpdaterAvailable) service.checkForUpdates() - #expect(invocationCount == 1) + #expect(actions.isEmpty) + + let receivedCheck = await withTaskGroup(of: Bool.self) { group in + group.addTask { + var iterator = checkEvents.makeAsyncIterator() + return await iterator.next() != nil + } + group.addTask { + try? await Task.sleep(for: .seconds(1)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + checkEventContinuation.finish() + return result + } + + #expect(receivedCheck) + #expect(actions == ["activate", "check"]) } - @Test func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() { + @Test(.timeLimit(.minutes(1))) + func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() async { var invocationCount = 0 let service = UpdateService(canCheckForUpdates: false) { invocationCount += 1 @@ -30,6 +59,11 @@ struct UpdateServiceTests { #expect(service.isUpdaterAvailable) service.checkForUpdates() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } #expect(invocationCount == 0) } diff --git a/script/app_bundle_contract_test.sh b/script/app_bundle_contract_test.sh index 223a5fc..35c850e 100755 --- a/script/app_bundle_contract_test.sh +++ b/script/app_bundle_contract_test.sh @@ -7,12 +7,23 @@ MOCK_PLIST="$ROOT_DIR/dist/Dayline Mock.app/Contents/Info.plist" RELEASE_PLIST="$ROOT_DIR/dist/release/Dayline.app/Contents/Info.plist" DEV_APP="$ROOT_DIR/dist/Dayline Dev.app" RELEASE_APP="$ROOT_DIR/dist/release/Dayline.app" +RELEASE_DMG="$ROOT_DIR/dist/artifacts/Dayline-0.1.0-dev.dmg" TEST_GOOGLE_CLIENT_ID="1234567890-dayline-dev-contract.apps.googleusercontent.com" # autoreview:allow-secret TEST_GOOGLE_SCHEME="com.googleusercontent.apps.1234567890-dayline-dev-contract" INSTALLER_LOCK_DIR="${TMPDIR:-/tmp}/dayline-dev-installer.lock" LOG_FILE="$(mktemp -t dayline-bundle-contract.XXXXXX)" MALFORMED_PLIST="$(mktemp -t dayline-malformed-plist.XXXXXX)" -trap 'rm -f "$LOG_FILE" "$MALFORMED_PLIST"; rm -rf "$INSTALLER_LOCK_DIR"' EXIT +DMG_MOUNT="" + +cleanup() { + if [[ -n "$DMG_MOUNT" && -d "$DMG_MOUNT" ]]; then + /usr/bin/hdiutil detach "$DMG_MOUNT" >/dev/null 2>&1 || true + rmdir "$DMG_MOUNT" 2>/dev/null || true + fi + rm -f "$LOG_FILE" "$MALFORMED_PLIST" + rm -rf "$INSTALLER_LOCK_DIR" +} +trap cleanup EXIT cd "$ROOT_DIR" @@ -142,4 +153,29 @@ assert_url_scheme "$RELEASE_PLIST" dayline assert_url_scheme "$RELEASE_PLIST" com.googleusercontent.apps.551177930544-9sl0govp6ok205csb939j4p2dhckrgbk assert_eventkit_entitlement "$RELEASE_APP" +DMG_MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/dayline-dmg-contract.XXXXXX")" +/usr/bin/hdiutil attach -readonly -nobrowse -mountpoint "$DMG_MOUNT" "$RELEASE_DMG" >/dev/null +[[ -d "$DMG_MOUNT/Dayline.app" ]] || fail "release DMG is missing Dayline.app" +[[ -L "$DMG_MOUNT/Applications" ]] || fail "release DMG is missing the Applications drop link" +[[ "$(readlink "$DMG_MOUNT/Applications")" == "/Applications" ]] || + fail "release DMG Applications link has the wrong destination" +[[ -f "$DMG_MOUNT/.DS_Store" ]] || fail "release DMG is missing Finder layout metadata" +[[ -f "$DMG_MOUNT/.background/DaylineDMGBackground.png" ]] || + fail "release DMG is missing its arrow background" +[[ "$(/usr/bin/sips -g pixelWidth "$DMG_MOUNT/.background/DaylineDMGBackground.png" 2>/dev/null | awk '/pixelWidth/ { print $2 }')" == "640" ]] || + fail "release DMG background width is not 640 pixels" +[[ "$(/usr/bin/sips -g pixelHeight "$DMG_MOUNT/.background/DaylineDMGBackground.png" 2>/dev/null | awk '/pixelHeight/ { print $2 }')" == "320" ]] || + fail "release DMG background height is not 320 pixels" +strings "$DMG_MOUNT/.DS_Store" | grep -Fq "DaylineDMGBackground.png" || + fail "release DMG Finder metadata does not reference its background" +grep -Fq -- '--window-size 640 390' "$ROOT_DIR/script/package_release.sh" || + fail "release DMG window size changed without updating its layout contract" +grep -Fq -- '--icon "$APP_NAME.app" 160 160' "$ROOT_DIR/script/package_release.sh" || + fail "release DMG app icon position changed without updating its layout contract" +grep -Fq -- '--app-drop-link 480 160' "$ROOT_DIR/script/package_release.sh" || + fail "release DMG Applications position changed without updating its layout contract" +/usr/bin/hdiutil detach "$DMG_MOUNT" >/dev/null +rmdir "$DMG_MOUNT" +DMG_MOUNT="" + echo "app_bundle_contract_test: passed" diff --git a/script/install_create_dmg.sh b/script/install_create_dmg.sh new file mode 100755 index 0000000..d72bb83 --- /dev/null +++ b/script/install_create_dmg.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.3.0" +ARCHIVE_SHA256="c50d2bc97c3d6292642bac55f530d247eaf4bf65ee605f26b4caf339383e381c" +INSTALL_DIR="${1:?usage: install_create_dmg.sh }" +ARCHIVE="$INSTALL_DIR/create-dmg.tar.gz" + +mkdir -p "$INSTALL_DIR" +curl --fail --location --proto '=https' --tlsv1.2 \ + "https://github.com/create-dmg/create-dmg/archive/refs/tags/v$VERSION.tar.gz" \ + --output "$ARCHIVE" +printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE" | shasum -a 256 --check >&2 +tar -xzf "$ARCHIVE" --strip-components=1 --directory "$INSTALL_DIR" +chmod +x "$INSTALL_DIR/create-dmg" +printf '%s\n' "$INSTALL_DIR/create-dmg" diff --git a/script/notarization_release.sh b/script/notarization_release.sh index c2871a9..6c4daa2 100755 --- a/script/notarization_release.sh +++ b/script/notarization_release.sh @@ -412,28 +412,60 @@ advance_accepted_app() { local release_id="$1" local release_json="$2" local state_json="$3" - local version build_number app_asset app_sha app_zip dmg dmg_asset dmg_sha now + local tag commit_sha resolved_tag_sha version build_number app_asset app_sha tag_root accepted_app tag_artifacts create_dmg_bin + local tag_app_zip tag_dmg app_zip dmg dmg_asset dmg_sha now + tag="$(jq -r '.tag' <<< "$state_json")" + commit_sha="$(jq -r '.commit_sha' <<< "$state_json")" version="$(jq -r '.version' <<< "$state_json")" build_number="$(jq -r '.build_number' <<< "$state_json")" app_asset="$(jq -r '.app_submission_asset' <<< "$state_json")" app_sha="$(jq -r '.app_submission_sha256' <<< "$state_json")" rm -rf "$WORK_DIR" "$RELEASE_DIR" "$ARTIFACT_DIR" - mkdir -p "$WORK_DIR" "$RELEASE_DIR" + mkdir -p "$WORK_DIR" download_asset "$release_json" "$app_asset" "$WORK_DIR/$app_asset" || return 1 verify_sha256 "$WORK_DIR/$app_asset" "$app_sha" || return 1 - /usr/bin/ditto -x -k "$WORK_DIR/$app_asset" "$RELEASE_DIR" || return 1 - /usr/bin/codesign --verify --strict --verbose=2 "$APP_BUNDLE" - xcrun stapler staple "$APP_BUNDLE" - xcrun stapler validate "$APP_BUNDLE" + resolved_tag_sha="$(git rev-parse "$tag^{commit}" 2>/dev/null || true)" + if [[ -z "$commit_sha" || "$commit_sha" == "null" || "$resolved_tag_sha" != "$commit_sha" ]]; then + echo "Persisted release commit does not match $tag ($commit_sha != $resolved_tag_sha)." >&2 + return 1 + fi - MARKETING_VERSION="$version" BUILD_NUMBER="$build_number" \ - "$ROOT_DIR/script/package_release.sh" --package-existing + tag_root="$(mktemp -d "$WORK_DIR/tag-source.XXXXXX")" + rmdir "$tag_root" + if ! git worktree add --detach "$tag_root" "$commit_sha" >/dev/null; then + return 1 + fi + if ! ( + trap 'git -C "$ROOT_DIR" worktree remove --force "$tag_root" >/dev/null 2>&1 || true; git -C "$ROOT_DIR" worktree prune' EXIT + mkdir -p "$tag_root/dist/release" + /usr/bin/ditto -x -k "$WORK_DIR/$app_asset" "$tag_root/dist/release" + accepted_app="$tag_root/dist/release/$APP_NAME.app" + + /usr/bin/codesign --verify --strict --verbose=2 "$accepted_app" + xcrun stapler staple "$accepted_app" + xcrun stapler validate "$accepted_app" + + create_dmg_bin="$("$tag_root/script/install_create_dmg.sh" "$WORK_DIR/create-dmg")" + CREATE_DMG_BIN="$create_dmg_bin" MARKETING_VERSION="$version" BUILD_NUMBER="$build_number" \ + "$tag_root/script/package_release.sh" --package-existing + + tag_artifacts="$tag_root/dist/artifacts" + cp "$tag_artifacts/$APP_NAME-$version.app.zip" "$WORK_DIR/tag-app.zip" + cp "$tag_artifacts/$APP_NAME-$version.dmg" "$WORK_DIR/tag.dmg" + ); then + return 1 + fi + tag_app_zip="$WORK_DIR/tag-app.zip" + tag_dmg="$WORK_DIR/tag.dmg" + mkdir -p "$ARTIFACT_DIR" app_zip="$ARTIFACT_DIR/$APP_NAME-$version.app.zip" dmg="$ARTIFACT_DIR/$APP_NAME-$version.dmg" + cp "$tag_app_zip" "$app_zip" + cp "$tag_dmg" "$dmg" dmg_asset="$APP_NAME-$version-${GITHUB_RUN_ID:-manual}-dmg-notary.dmg" cp "$dmg" "$WORK_DIR/$dmg_asset" dmg_sha="$(sha256 "$WORK_DIR/$dmg_asset")" diff --git a/script/package_release.sh b/script/package_release.sh index ae7f90b..b297249 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -25,6 +25,8 @@ SPARKLE_FRAMEWORK_NAME="Sparkle.framework" SPARKLE_PUBLIC_KEY="b7IXyZXo7zqHoVUdwJeOTwxY6gbmJYP/e0NV4i3G/Hk=" SPARKLE_FEED_URL="https://dayline.robin.build/appcast.xml" DMG_ROOT="$DIST_DIR/dmg-root" +DMG_BACKGROUND_SOURCE="$ROOT_DIR/Resources/DaylineDMGBackground.svg" +DMG_BACKGROUND="$DIST_DIR/DaylineDMGBackground.png" NOTARY_ZIP="$DIST_DIR/$APP_NAME-notary.zip" INSTALL_APP=false @@ -71,6 +73,7 @@ Environment: NOTARY_KEY_PATH App Store Connect API key (.p8) for CI notarization. NOTARY_KEY_ID App Store Connect API key ID. NOTARY_ISSUER_ID App Store Connect API issuer ID. + CREATE_DMG_BIN create-dmg executable. Defaults to PATH lookup. Internal CI stages: --prepare-notarization Build and sign the app, then preserve its notarization ZIP. @@ -292,14 +295,39 @@ embed_and_sign_sparkle() { /usr/bin/codesign --verify --strict --verbose=2 "$destination_framework" } -# create_dmg builds the drag-install disk image used for GitHub releases. +# create_dmg builds the minimal app-to-Applications drag-install image used for releases. create_dmg() { + local create_dmg_bin + create_dmg_bin="${CREATE_DMG_BIN:-$(command -v create-dmg || true)}" + if [[ -z "$create_dmg_bin" || ! -x "$create_dmg_bin" ]]; then + echo "create-dmg is required to package Dayline. Install it with: brew install create-dmg" >&2 + exit 2 + fi + if [[ ! -f "$DMG_BACKGROUND_SOURCE" ]]; then + echo "Missing DMG background: $DMG_BACKGROUND_SOURCE" >&2 + exit 2 + fi + + /usr/bin/sips -s format png "$DMG_BACKGROUND_SOURCE" --out "$DMG_BACKGROUND" >/dev/null + rm -rf "$DMG_ROOT" mkdir -p "$DMG_ROOT" /usr/bin/ditto "$APP_BUNDLE" "$DMG_ROOT/$APP_NAME.app" - ln -s /Applications "$DMG_ROOT/Applications" rm -f "$DMG_PATH" - /usr/sbin/diskutil image create from --format UDZO --volumeName "$APP_NAME" "$DMG_ROOT" "$DMG_PATH" + "$create_dmg_bin" \ + --overwrite \ + --volname "$APP_NAME" \ + --background "$DMG_BACKGROUND" \ + --window-pos 200 120 \ + --window-size 640 390 \ + --icon-size 112 \ + --text-size 14 \ + --icon "$APP_NAME.app" 160 160 \ + --hide-extension "$APP_NAME.app" \ + --app-drop-link 480 160 \ + --filesystem APFS \ + "$DMG_PATH" \ + "$DMG_ROOT" rm -rf "$DMG_ROOT" if [[ "$SIGNING_IDENTITY" != "-" ]]; then