From be1e1fa250d5c6cb39cacb89dd411422d79c02ec Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:29:01 +0700 Subject: [PATCH 1/8] Fix update focus and polish DMG install --- .github/workflows/ci.yml | 3 ++ .../workflows/notarization-continuation.yml | 3 ++ .github/workflows/release.yml | 3 ++ README.md | 6 ++++ Resources/DaylineDMGBackground.png | Bin 0 -> 2293 bytes Sources/Dayline/Services/UpdateService.swift | 31 ++++++++++++++---- Tests/DaylineTests/UpdateServiceTests.swift | 28 ++++++++++++---- script/app_bundle_contract_test.sh | 26 ++++++++++++++- script/package_release.sh | 31 ++++++++++++++++-- 9 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 Resources/DaylineDMGBackground.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8982301..6ea2fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Test run: swift test + - name: Install DMG packaging tool + run: brew install create-dmg + - name: Validate automation run: | bash -n script/*.sh diff --git a/.github/workflows/notarization-continuation.yml b/.github/workflows/notarization-continuation.yml index ef2b29b..7fc8813 100644 --- a/.github/workflows/notarization-continuation.yml +++ b/.github/workflows/notarization-continuation.yml @@ -73,6 +73,9 @@ jobs: - name: Resolve Sparkle publishing tools run: swift build + - name: Install DMG packaging tool + run: brew install create-dmg + - name: Import Developer ID certificate run: | certificate_path="$RUNNER_TEMP/developer-id.p12" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cfb7e6..ffed64e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,9 @@ jobs: swift build swift test + - name: Install DMG packaging tool + run: brew install create-dmg + - name: Preserve and submit signed app env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 00e46bd..8e8b2d6 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,12 @@ CI also runs `swift build` and `swift test` for pull requests and pushes to ### Manual fallback +Install the DMG layout tool once before packaging locally: + +```sh +brew install create-dmg +``` + Create local unsigned or development-signed artifacts: ```sh diff --git a/Resources/DaylineDMGBackground.png b/Resources/DaylineDMGBackground.png new file mode 100644 index 0000000000000000000000000000000000000000..e899c923dd952fc0eb3a396f67ed14db6cfe9e44 GIT binary patch literal 2293 zcmeAS@N?(olHy`uVBq!ia0y~yU}|7sU~~{*0*ZXLZ+M~|K9N8X zhd?${Zid!G(Kih2ES)oXH?W#SAJ{UJrCFsRnSBjYGt>989xlpl2OPc|_=f+j?pqV0 zdj8&?Kk@3xJ%-10fu_#5^){IwNHhFzW?*ArP;g{mW?-10(7?#R;304TD6Ybh03?N2 z41gpjlLP}p0}CS$1A{};sPfT}8PFu_ zXJB?Xq4%(j;6wfiRlWr3N_;l$nZ<@Wb~sx&iZ2pwRUpbVh} zRGS%B2m<+P&5SRA`LThA4V*>oPb4rrQ)^&sm=9#6sR9}ICqS%W2nU(y0?fwsia-~E za&?(fGh>7`FxS3uWMkeC2ekgiY@j=OfetM2HIis31zK>xe&RufHLXC^c|bc7e)=7g zNbX7QXFTx7Z1&k_fBxJE-cWM8<=(R|8`BvR%*=oqnr^T$7gzudi;w~ORuPyP5*BWg zta+o!Fryu4jM%HH@GQmyp+HMESgbq5^gsZ_Qn)1EaQLIN3d4hAIqTQ%x_2}7CHH}a zH-WA-clgD$p$2H&4|SlLtw6sXF!hXQxaJC!tDd}%Va-IKq2Cm086)&TK5+r6E(59N z1{xp(@`);t;R9rRkOu1i33Sc_kU^20Kn6Qd>JCr}s2fzMO;D=^2d02(EhKQ2Yr%mo zpj6AKA$))VP8WT9_vY^A>fQVGYZ+&B0^@eVE%U05DFDNR!PC{xWt~$( F699k 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/UpdateServiceTests.swift b/Tests/DaylineTests/UpdateServiceTests.swift index 568ff19..fec68e0 100644 --- a/Tests/DaylineTests/UpdateServiceTests.swift +++ b/Tests/DaylineTests/UpdateServiceTests.swift @@ -11,15 +11,29 @@ struct UpdateServiceTests { #expect(service.availableVersion == "9.9.9") } - @Test func enabledInjectedUpdaterInvokesConfiguredCheckAction() { - var invocationCount = 0 - let service = UpdateService(canCheckForUpdates: true) { - invocationCount += 1 + @Test func enabledInjectedUpdaterActivatesThenChecksOnNextMainLoopTurn() async { + var actions: [String] = [] + var service: UpdateService? + + await withCheckedContinuation { continuation in + service = UpdateService( + canCheckForUpdates: true, + activateApplicationAction: { + actions.append("activate") + }, + checkForUpdatesAction: { + actions.append("check") + continuation.resume() + } + ) + + #expect(service?.isUpdaterAvailable == true) + service?.checkForUpdates() + #expect(actions.isEmpty) } - #expect(service.isUpdaterAvailable) - service.checkForUpdates() - #expect(invocationCount == 1) + #expect(actions == ["activate", "check"]) + _ = service } @Test func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() { diff --git a/script/app_bundle_contract_test.sh b/script/app_bundle_contract_test.sh index 223a5fc..bddc29a 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,17 @@ 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/hdiutil detach "$DMG_MOUNT" >/dev/null +rmdir "$DMG_MOUNT" +DMG_MOUNT="" + echo "app_bundle_contract_test: passed" diff --git a/script/package_release.sh b/script/package_release.sh index ae7f90b..7e6046f 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -25,6 +25,7 @@ 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="$ROOT_DIR/Resources/DaylineDMGBackground.png" NOTARY_ZIP="$DIST_DIR/$APP_NAME-notary.zip" INSTALL_APP=false @@ -71,6 +72,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 +294,37 @@ 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" ]]; then + echo "Missing DMG background: $DMG_BACKGROUND" >&2 + exit 2 + fi + 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 From c455366a8d6f2e0ad0507b6d9942e4c9a4d7c777 Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:30:17 +0700 Subject: [PATCH 2/8] Make DMG background reviewable --- Resources/DaylineDMGBackground.png | Bin 2293 -> 0 bytes Resources/DaylineDMGBackground.svg | 4 ++++ script/package_release.sh | 9 ++++++--- 3 files changed, 10 insertions(+), 3 deletions(-) delete mode 100644 Resources/DaylineDMGBackground.png create mode 100644 Resources/DaylineDMGBackground.svg diff --git a/Resources/DaylineDMGBackground.png b/Resources/DaylineDMGBackground.png deleted file mode 100644 index e899c923dd952fc0eb3a396f67ed14db6cfe9e44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2293 zcmeAS@N?(olHy`uVBq!ia0y~yU}|7sU~~{*0*ZXLZ+M~|K9N8X zhd?${Zid!G(Kih2ES)oXH?W#SAJ{UJrCFsRnSBjYGt>989xlpl2OPc|_=f+j?pqV0 zdj8&?Kk@3xJ%-10fu_#5^){IwNHhFzW?*ArP;g{mW?-10(7?#R;304TD6Ybh03?N2 z41gpjlLP}p0}CS$1A{};sPfT}8PFu_ zXJB?Xq4%(j;6wfiRlWr3N_;l$nZ<@Wb~sx&iZ2pwRUpbVh} zRGS%B2m<+P&5SRA`LThA4V*>oPb4rrQ)^&sm=9#6sR9}ICqS%W2nU(y0?fwsia-~E za&?(fGh>7`FxS3uWMkeC2ekgiY@j=OfetM2HIis31zK>xe&RufHLXC^c|bc7e)=7g zNbX7QXFTx7Z1&k_fBxJE-cWM8<=(R|8`BvR%*=oqnr^T$7gzudi;w~ORuPyP5*BWg zta+o!Fryu4jM%HH@GQmyp+HMESgbq5^gsZ_Qn)1EaQLIN3d4hAIqTQ%x_2}7CHH}a zH-WA-clgD$p$2H&4|SlLtw6sXF!hXQxaJC!tDd}%Va-IKq2Cm086)&TK5+r6E(59N z1{xp(@`);t;R9rRkOu1i33Sc_kU^20Kn6Qd>JCr}s2fzMO;D=^2d02(EhKQ2Yr%mo zpj6AKA$))VP8WT9_vY^A>fQVGYZ+&B0^@eVE%U05DFDNR!PC{xWt~$( F699k + + + diff --git a/script/package_release.sh b/script/package_release.sh index 7e6046f..b297249 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -25,7 +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="$ROOT_DIR/Resources/DaylineDMGBackground.png" +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 @@ -302,11 +303,13 @@ create_dmg() { echo "create-dmg is required to package Dayline. Install it with: brew install create-dmg" >&2 exit 2 fi - if [[ ! -f "$DMG_BACKGROUND" ]]; then - echo "Missing DMG background: $DMG_BACKGROUND" >&2 + 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" From e0917baaa1889656d5a8e4ac0d8f1a1918857d58 Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:35:37 +0700 Subject: [PATCH 3/8] Harden DMG release provenance --- .github/workflows/ci.yml | 4 +- .../workflows/notarization-continuation.yml | 28 +++++++---- .github/workflows/release.yml | 25 ++++++---- README.md | 4 +- Tests/DaylineTests/UpdateServiceTests.swift | 12 ++++- script/app_bundle_contract_test.sh | 12 +++++ script/install_create_dmg.sh | 16 +++++++ script/notarization_release.sh | 47 +++++++++++++++---- 8 files changed, 118 insertions(+), 30 deletions(-) create mode 100755 script/install_create_dmg.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ea2fa9..51a3881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: run: swift test - name: Install DMG packaging tool - run: brew install create-dmg + 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: | diff --git a/.github/workflows/notarization-continuation.yml b/.github/workflows/notarization-continuation.yml index 7fc8813..88de8dd 100644 --- a/.github/workflows/notarization-continuation.yml +++ b/.github/workflows/notarization-continuation.yml @@ -23,23 +23,27 @@ 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: Install pinned 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 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 \ @@ -73,10 +77,10 @@ jobs: - name: Resolve Sparkle publishing tools run: swift build - - name: Install DMG packaging tool - run: brew install create-dmg - - 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" @@ -109,6 +113,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 ffed64e..7a68766 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 @@ -50,9 +45,19 @@ jobs: test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG" git merge-base --is-ancestor HEAD origin/main + - name: Install pinned 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 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 +89,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 +124,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" @@ -129,9 +141,6 @@ jobs: swift build swift test - - name: Install DMG packaging tool - run: brew install create-dmg - - name: Preserve and submit signed app env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 8e8b2d6..adb6403 100644 --- a/README.md +++ b/README.md @@ -513,10 +513,12 @@ CI also runs `swift build` and `swift test` for pull requests and pushes to ### Manual fallback -Install the DMG layout tool once before packaging locally: +Install the DMG layout tool once before packaging locally, or use the pinned +bootstrap script used by CI: ```sh brew install create-dmg +./script/install_create_dmg.sh /tmp/dayline-create-dmg ``` Create local unsigned or development-signed artifacts: diff --git a/Tests/DaylineTests/UpdateServiceTests.swift b/Tests/DaylineTests/UpdateServiceTests.swift index fec68e0..bfe2414 100644 --- a/Tests/DaylineTests/UpdateServiceTests.swift +++ b/Tests/DaylineTests/UpdateServiceTests.swift @@ -1,3 +1,4 @@ +import Dispatch import Testing @testable import Dayline @@ -11,7 +12,8 @@ struct UpdateServiceTests { #expect(service.availableVersion == "9.9.9") } - @Test func enabledInjectedUpdaterActivatesThenChecksOnNextMainLoopTurn() async { + @Test(.timeLimit(.minutes(1))) + func enabledInjectedUpdaterActivatesThenChecksOnNextMainLoopTurn() async { var actions: [String] = [] var service: UpdateService? @@ -36,7 +38,8 @@ struct UpdateServiceTests { _ = service } - @Test func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() { + @Test(.timeLimit(.minutes(1))) + func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() async { var invocationCount = 0 let service = UpdateService(canCheckForUpdates: false) { invocationCount += 1 @@ -44,6 +47,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 bddc29a..35c850e 100755 --- a/script/app_bundle_contract_test.sh +++ b/script/app_bundle_contract_test.sh @@ -162,6 +162,18 @@ DMG_MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/dayline-dmg-contract.XXXXXX")" [[ -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="" 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..a84ccbf 100755 --- a/script/notarization_release.sh +++ b/script/notarization_release.sh @@ -412,28 +412,59 @@ 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 + 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" + + 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")" From 5ab41d116a60ea8949056bba35647b205781f89e Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:37:49 +0700 Subject: [PATCH 4/8] Bind DMG tooling to release commit --- .github/workflows/notarization-continuation.yml | 5 ----- .github/workflows/release.yml | 5 ----- script/notarization_release.sh | 5 +++-- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/notarization-continuation.yml b/.github/workflows/notarization-continuation.yml index 88de8dd..fc92e03 100644 --- a/.github/workflows/notarization-continuation.yml +++ b/.github/workflows/notarization-continuation.yml @@ -31,11 +31,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Install pinned 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 release secrets env: APPCAST_DEPLOY_KEY: ${{ secrets.APPCAST_DEPLOY_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a68766..5f314d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,11 +45,6 @@ jobs: test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG" git merge-base --is-ancestor HEAD origin/main - - name: Install pinned 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 release secrets env: APPCAST_DEPLOY_KEY: ${{ secrets.APPCAST_DEPLOY_KEY }} diff --git a/script/notarization_release.sh b/script/notarization_release.sh index a84ccbf..6c4daa2 100755 --- a/script/notarization_release.sh +++ b/script/notarization_release.sh @@ -412,7 +412,7 @@ advance_accepted_app() { local release_id="$1" local release_json="$2" local state_json="$3" - local tag commit_sha resolved_tag_sha version build_number app_asset app_sha tag_root accepted_app tag_artifacts + 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")" @@ -448,7 +448,8 @@ advance_accepted_app() { xcrun stapler staple "$accepted_app" xcrun stapler validate "$accepted_app" - MARKETING_VERSION="$version" BUILD_NUMBER="$build_number" \ + 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" From c1b48199026f1800e0b06747f9c3b696d5f2206a Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:39:36 +0700 Subject: [PATCH 5/8] Bound updater callback test --- Tests/DaylineTests/UpdateServiceTests.swift | 46 +++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/Tests/DaylineTests/UpdateServiceTests.swift b/Tests/DaylineTests/UpdateServiceTests.swift index bfe2414..cb5e82b 100644 --- a/Tests/DaylineTests/UpdateServiceTests.swift +++ b/Tests/DaylineTests/UpdateServiceTests.swift @@ -15,27 +15,39 @@ struct UpdateServiceTests { @Test(.timeLimit(.minutes(1))) func enabledInjectedUpdaterActivatesThenChecksOnNextMainLoopTurn() async { var actions: [String] = [] - var service: UpdateService? + let (checkEvents, checkEventContinuation) = AsyncStream.makeStream() + let service = UpdateService( + canCheckForUpdates: true, + activateApplicationAction: { + actions.append("activate") + }, + checkForUpdatesAction: { + actions.append("check") + checkEventContinuation.yield() + } + ) - await withCheckedContinuation { continuation in - service = UpdateService( - canCheckForUpdates: true, - activateApplicationAction: { - actions.append("activate") - }, - checkForUpdatesAction: { - actions.append("check") - continuation.resume() - } - ) - - #expect(service?.isUpdaterAvailable == true) - service?.checkForUpdates() - #expect(actions.isEmpty) + #expect(service.isUpdaterAvailable) + service.checkForUpdates() + #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"]) - _ = service } @Test(.timeLimit(.minutes(1))) From 2cc73d291cc61325dea91955abc121063963f8f2 Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:42:43 +0700 Subject: [PATCH 6/8] Clarify pinned DMG bootstrap --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index adb6403..bc87032 100644 --- a/README.md +++ b/README.md @@ -513,12 +513,18 @@ CI also runs `swift build` and `swift test` for pull requests and pushes to ### Manual fallback -Install the DMG layout tool once before packaging locally, or use the pinned -bootstrap script used by CI: +Install the DMG layout tool with Homebrew before packaging locally: ```sh brew install create-dmg -./script/install_create_dmg.sh /tmp/dayline-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 ``` Create local unsigned or development-signed artifacts: From 3061b15cbfb1725a90b587e361c5c27f89fc1af3 Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:44:15 +0700 Subject: [PATCH 7/8] Remove duplicate packaging command --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index bc87032..e7b83a4 100644 --- a/README.md +++ b/README.md @@ -527,12 +527,6 @@ CREATE_DMG_BIN="$(./script/install_create_dmg.sh /tmp/dayline-create-dmg)" \ ./script/package_release.sh ``` -Create local unsigned or development-signed artifacts: - -```sh -./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. From 5af4ce912332e1f22aeccf3e5dc6500dbf77d524 Mon Sep 17 00:00:00 2001 From: Robin | Liquidium Date: Thu, 13 Aug 2026 06:47:55 +0700 Subject: [PATCH 8/8] Keep all-day creation fixture current --- Tests/DaylineTests/AppleCalendarEventCreationTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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",