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