diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml
index b9ec015..5f599e5 100644
--- a/.github/workflows/auto-merge-release.yml
+++ b/.github/workflows/auto-merge-release.yml
@@ -53,10 +53,16 @@ jobs:
if: steps.find-pr.outputs.found == 'true'
run: swift run GitKitChecks
- - name: Approve and auto-merge
+ - name: Approve PR
if: steps.find-pr.outputs.found == 'true'
env:
- GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr review "${{ steps.find-pr.outputs.number }}" --approve
+
+ - name: Auto-merge PR
+ if: steps.find-pr.outputs.found == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ run: |
gh pr merge "${{ steps.find-pr.outputs.number }}" --squash --auto
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 00a64d6..b42c3c6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,9 +33,42 @@ jobs:
with:
ref: ${{ needs.release-please.outputs.tag_name }}
+ - name: Import code signing certificate
+ env:
+ APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ run: |
+ CERT_PATH="$RUNNER_TEMP/certificate.p12"
+ KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
+ KEYCHAIN_PASSWORD="$(openssl rand -base64 32)"
+
+ echo -n "$APPLE_CERTIFICATE_BASE64" | base64 --decode -o "$CERT_PATH"
+
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \
+ -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
+ security set-key-partition-list -S apple-tool:,apple: \
+ -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
+
+ - name: Store notarization credentials
+ env:
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ xcrun notarytool store-credentials "GITIFY_NOTARY" \
+ --apple-id "$APPLE_ID" \
+ --team-id "$APPLE_TEAM_ID" \
+ --password "$APPLE_ID_PASSWORD"
+
- name: Build DMG
env:
VERSION: ${{ needs.release-please.outputs.version }}
+ SIGN_IDENTITY: ${{ secrets.SIGN_IDENTITY }}
+ NOTARY_PROFILE: "GITIFY_NOTARY"
run: scripts/package.sh
- name: Download Sparkle sign_update
@@ -135,6 +168,9 @@ jobs:
run: |
gh release upload "${{ needs.release-please.outputs.tag_name }}" \
"build/Gitify-${{ needs.release-please.outputs.version }}.dmg"
+ cp "build/Gitify-${{ needs.release-please.outputs.version }}.dmg" build/Gitify.dmg
+ gh release upload "${{ needs.release-please.outputs.tag_name }}" \
+ build/Gitify.dmg
- name: Commit updated appcast.xml
if: env.ED_SIGNATURE != ''
@@ -153,3 +189,8 @@ jobs:
git add appcast.xml
git commit -m "chore: update appcast.xml for v${{ needs.release-please.outputs.version }}"
git push origin main
+
+ - name: Clean up keychain
+ if: always()
+ run: |
+ security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true
diff --git a/README.md b/README.md
index 8e78fab..534acbc 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Gitify
+**Website: [gitify.mrmasonic.com](https://gitify.mrmasonic.com)**
+
A fast, lightweight, native macOS Git client built entirely with SwiftUI and AppKit. No Electron, no embedded runtimes -- just a clean, responsive app that wraps your installed `git` binary so every operation matches your exact git config.

@@ -29,11 +31,6 @@ A fast, lightweight, native macOS Git client built entirely with SwiftUI and App
1. Go to the [Releases](https://github.com/craigharman/gitify/releases) page
2. Download the latest `.dmg` file
3. Open the `.dmg` and drag **Gitify.app** into your Applications folder
-4. On first launch, macOS will block the app because it is not signed with an Apple Developer ID. To allow it:
- - Open **System Settings** > **Privacy & Security**
- - Scroll down to the **Security** section
- - You will see a message that "Gitify" was blocked -- click **Open Anyway**
- - Confirm in the dialog that appears
## Building from source
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 08cd349..75bd40a 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -7,7 +7,7 @@
CFBundleDisplayName
Gitify
CFBundleIdentifier
- com.gitify.app
+ com.craigharman.gitify
CFBundleVersion
1.1.0
CFBundleShortVersionString
diff --git a/Sources/GitKit/RepositoryWatcher.swift b/Sources/GitKit/RepositoryWatcher.swift
index 2aa2229..73932c9 100644
--- a/Sources/GitKit/RepositoryWatcher.swift
+++ b/Sources/GitKit/RepositoryWatcher.swift
@@ -50,8 +50,10 @@ public final class RepositoryWatcher: RepositoryWatching, @unchecked Sendable {
}
public func stop() {
- queue.async { [weak self] in
- guard let self, let stream = self.stream else { return }
+ // Must run on the stream's dispatch queue so no callback is in-flight when we
+ // invalidate. Uses sync so the caller can safely release the watcher afterward.
+ queue.sync {
+ guard let stream else { return }
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
@@ -60,9 +62,17 @@ public final class RepositoryWatcher: RepositoryWatching, @unchecked Sendable {
}
deinit {
+ // If stop() was already called, stream is nil and this is a no-op.
+ // Otherwise, schedule cleanup on the stream's queue. We can't use queue.sync
+ // from deinit (risks deadlock), so capture the values and dispatch async.
guard let stream else { return }
- FSEventStreamStop(stream)
- FSEventStreamInvalidate(stream)
- FSEventStreamRelease(stream)
+ let q = queue
+ // OpaquePointer is not Sendable but FSEventStreamRef is safe to pass across queues.
+ nonisolated(unsafe) let s = stream
+ q.async {
+ FSEventStreamStop(s)
+ FSEventStreamInvalidate(s)
+ FSEventStreamRelease(s)
+ }
}
}
diff --git a/Sources/Gitify/App/GitifyApp.swift b/Sources/Gitify/App/GitifyApp.swift
index c0be7e0..34d15f4 100644
--- a/Sources/Gitify/App/GitifyApp.swift
+++ b/Sources/Gitify/App/GitifyApp.swift
@@ -6,17 +6,28 @@ struct GitifyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var model = AppModel()
@State private var sparkleController = SparkleController()
+ @AppStorage("appearance.mode") private var appearanceMode: String = "system"
+
+ private var colorScheme: ColorScheme? {
+ switch appearanceMode {
+ case "light": .light
+ case "dark": .dark
+ default: nil
+ }
+ }
var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.frame(minWidth: 900, minHeight: 560)
+ .preferredColorScheme(colorScheme)
}
.windowStyle(.titleBar)
.windowToolbarStyle(.unified)
Settings {
AppSettingsView()
+ .preferredColorScheme(colorScheme)
}
.commands {
CommandGroup(after: .appInfo) {
diff --git a/Sources/Gitify/Model/AppDefaults.swift b/Sources/Gitify/Model/AppDefaults.swift
index 9e2f078..ccb29bc 100644
--- a/Sources/Gitify/Model/AppDefaults.swift
+++ b/Sources/Gitify/Model/AppDefaults.swift
@@ -3,6 +3,20 @@ import AppKit
/// App-level preferences stored in UserDefaults.
enum AppDefaults {
+ // MARK: - Appearance
+
+ /// The user\u{2019}s preferred appearance mode.
+ enum AppearanceMode: String, CaseIterable {
+ case system, light, dark
+ }
+
+ private static let appearanceKey = "appearance.mode"
+
+ static var appearanceMode: AppearanceMode {
+ get { UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppearanceMode.init(rawValue:)) ?? .system }
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: appearanceKey) }
+ }
+
// MARK: - Keys
private static let terminalKey = "defaultTerminalBundleID"
diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift
index 6b9864c..f19b41b 100644
--- a/Sources/Gitify/Model/RepositoryViewModel.swift
+++ b/Sources/Gitify/Model/RepositoryViewModel.swift
@@ -63,8 +63,17 @@ final class RepositoryViewModel {
init(ref: RepositoryRef) {
self.ref = ref
}
- // Cleanup is automatic: releasing `watcher` invalidates the FSEvents stream via its
- // own deinit, and the debounced refresh task holds only a weak self.
+
+ /// Explicitly tears down the file-system watcher and cancels the debounce task. Called
+ /// from the view's `onDisappear` so cleanup happens deterministically before the view
+ /// model is released (relying solely on `deinit` is racy when FSEvents callbacks are
+ /// still in-flight on the watcher's dispatch queue).
+ func tearDown() {
+ autoRefreshTask?.cancel()
+ autoRefreshTask = nil
+ watcher?.stop()
+ watcher = nil
+ }
var localBranches: [Ref] { refs.filter { $0.kind == .localBranch } }
var remoteBranches: [Ref] { refs.filter { $0.kind == .remoteBranch } }
@@ -228,6 +237,15 @@ final class RepositoryViewModel {
}
}
+ func openProject(in app: AppDefaults.KnownApp) {
+ guard let appURL = AppDefaults.appURL(for: app.bundleID) else { return }
+ NSWorkspace.shared.open(
+ [ref.url],
+ withApplicationAt: appURL,
+ configuration: NSWorkspace.OpenConfiguration()
+ )
+ }
+
func openInTerminal(_ file: FileStatus) {
let dir = ref.url.appendingPathComponent(file.path).deletingLastPathComponent()
let bundleID = AppDefaults.terminalBundleID
@@ -562,7 +580,9 @@ final class RepositoryViewModel {
/// deletes the now-merged `source` branch.
func merge(source: String, into target: String, squash: Bool, noFastForward: Bool,
noCommit: Bool, skipHooks: Bool, deleteSource: Bool,
- pushAfterMerge: Bool = false) async {
+ pushAfterMerge: Bool = false,
+ squashCommitMessage: String? = nil,
+ generateSquashMessage: Bool = false) async {
// Park the deletion intent before attempting the merge so it survives a conflict: a
// conflicting merge throws below and is finished later by a manual commit, which calls
// finalizePendingMergeDeletion(). Don't delete when stopping before commit — nothing
@@ -577,6 +597,22 @@ final class RepositoryViewModel {
try await service.merge(branch: source, squash: squash, noFastForward: noFastForward,
noCommit: noCommit, skipHooks: skipHooks)
// Reaching here means the merge completed cleanly (a conflicting merge throws above).
+
+ // Auto-commit after a squash merge when the user opted in.
+ if squash, let requestedMessage = squashCommitMessage {
+ var message = requestedMessage
+ if generateSquashMessage {
+ if let ai = try? await AICommitMessageGenerator.generate(viewModel: self),
+ !ai.isEmpty {
+ message = ai
+ }
+ }
+ if message.isEmpty {
+ message = "Squash merge branch \u{2018}\(source)\u{2019} into \u{2018}\(target)\u{2019}"
+ }
+ try await service.commit(message: message, amend: false)
+ }
+
await self.finalizePendingMergeDeletion()
}
// If the merge didn't leave a conflicted merge in progress (it finalized cleanly above,
diff --git a/Sources/Gitify/Resources/CHANGELOG.md b/Sources/Gitify/Resources/CHANGELOG.md
index bb61033..81cd19f 100644
--- a/Sources/Gitify/Resources/CHANGELOG.md
+++ b/Sources/Gitify/Resources/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [1.14.0](https://github.com/craigharman/gitify/compare/v1.13.1...v1.14.0) (2026-07-03)
+
+
+### Features
+
+* add (optional) ai assistant ([#36](https://github.com/craigharman/gitify/issues/36)) ([225e2d1](https://github.com/craigharman/gitify/commit/225e2d13f620547cff3fe997ff9f9b2d544ab102))
+
## [1.13.1](https://github.com/craigharman/gitify/compare/v1.13.0...v1.13.1) (2026-06-30)
diff --git a/Sources/Gitify/Views/AppSettingsView.swift b/Sources/Gitify/Views/AppSettingsView.swift
index 3f0f49a..32780f5 100644
--- a/Sources/Gitify/Views/AppSettingsView.swift
+++ b/Sources/Gitify/Views/AppSettingsView.swift
@@ -18,11 +18,18 @@ private struct GeneralSettingsTab: View {
@State private var terminalID: String = AppDefaults.terminalBundleID
@State private var editorID: String = AppDefaults.editorBundleID ?? ""
+ @State private var appearanceMode: AppDefaults.AppearanceMode = AppDefaults.appearanceMode
@State private var installedTerminals: [AppDefaults.KnownApp] = []
@State private var installedEditors: [AppDefaults.KnownApp] = []
var body: some View {
Form {
+ Picker("Appearance", selection: $appearanceMode) {
+ Text("System").tag(AppDefaults.AppearanceMode.system)
+ Text("Light").tag(AppDefaults.AppearanceMode.light)
+ Text("Dark").tag(AppDefaults.AppearanceMode.dark)
+ }
+
Picker("Terminal", selection: $terminalID) {
ForEach(installedTerminals) { app in
AppLabel(app: app).tag(app.bundleID)
@@ -39,6 +46,9 @@ private struct GeneralSettingsTab: View {
.formStyle(.grouped)
.frame(width: 400)
.fixedSize(horizontal: false, vertical: true)
+ .onChange(of: appearanceMode) { _, newValue in
+ AppDefaults.appearanceMode = newValue
+ }
.onChange(of: terminalID) { _, newValue in
AppDefaults.terminalBundleID = newValue
}
diff --git a/Sources/Gitify/Views/HistoryView.swift b/Sources/Gitify/Views/HistoryView.swift
index 7817626..0cd520a 100644
--- a/Sources/Gitify/Views/HistoryView.swift
+++ b/Sources/Gitify/Views/HistoryView.swift
@@ -30,8 +30,9 @@ struct HistoryView: View {
}
}
.frame(minWidth: 380, idealWidth: 480, maxHeight: .infinity)
+ .layoutPriority(1)
CommitDetailPane(selection: selection, viewModel: viewModel)
- .frame(minWidth: 320, maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
diff --git a/Sources/Gitify/Views/IntegrationSheets.swift b/Sources/Gitify/Views/IntegrationSheets.swift
index b7949fe..a31bb69 100644
--- a/Sources/Gitify/Views/IntegrationSheets.swift
+++ b/Sources/Gitify/Views/IntegrationSheets.swift
@@ -19,6 +19,12 @@ struct MergeSheet: View {
@State private var preview: MergePreview?
@State private var loadingPreview = false
+ @State private var commitAfterSquash = true
+ @State private var squashMessage = ""
+ @State private var useAIMessage = false
+ @FocusState private var messageEditorFocused: Bool
+ @AppStorage("ai.hidden") private var hideAIFeatures = false
+
/// Any branch except the target is a valid source (you can't merge a branch into itself).
private var branches: [String] {
(viewModel.localBranches.map(\.name) + viewModel.remoteBranches.map(\.name))
@@ -28,6 +34,7 @@ struct MergeSheet: View {
/// HEAD) is meaningful and no branch switch is needed.
private var mergesIntoCurrent: Bool { viewModel.currentBranch?.name == target }
private var sourceIsLocal: Bool { viewModel.localBranches.contains { $0.name == source } }
+ private var showAIButton: Bool { !hideAIFeatures && AIDefaults.hasAPIKey }
var body: some View {
VStack(alignment: .leading, spacing: 14) {
@@ -75,6 +82,70 @@ struct MergeSheet: View {
}
.padding(.leading, FormMetrics.contentInset) // align checkboxes under the fields
+ if squash {
+ VStack(alignment: .leading, spacing: 8) {
+ option("Commit After Squash",
+ "Automatically commit the squashed changes with the message below.",
+ isOn: $commitAfterSquash)
+
+ if commitAfterSquash {
+ ZStack(alignment: .topLeading) {
+ if squashMessage.isEmpty && !messageEditorFocused && !useAIMessage {
+ Text("Squash commit message")
+ .font(.body)
+ .foregroundStyle(.tertiary)
+ .padding(.leading, 5)
+ .allowsHitTesting(false)
+ }
+ if useAIMessage {
+ Text("AI will generate a commit message after merge")
+ .font(.body).italic()
+ .foregroundStyle(.secondary)
+ .padding(.leading, 5)
+ .allowsHitTesting(false)
+ }
+ TextEditor(text: $squashMessage)
+ .focused($messageEditorFocused)
+ .font(.body)
+ .scrollContentBackground(.hidden)
+ .disabled(useAIMessage)
+ .opacity(useAIMessage ? 0 : 1)
+ }
+ .padding(8)
+ .frame(height: 68)
+ .background(RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .textBackgroundColor)))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))
+ .overlay(alignment: .topTrailing) {
+ if showAIButton {
+ Button {
+ useAIMessage.toggle()
+ if useAIMessage { squashMessage = "" }
+ } label: {
+ Image(systemName: "sparkles")
+ .font(.system(size: 10))
+ .foregroundStyle(useAIMessage ? Color.accentColor : .secondary)
+ }
+ .buttonStyle(.plain)
+ .help(useAIMessage
+ ? "Disable AI commit message generation"
+ : "Generate commit message with AI after merge")
+ .padding(6)
+ }
+ }
+ .padding(.leading, 20)
+
+ if squashMessage.isEmpty && !useAIMessage {
+ Text("Leave empty for default: \u{201c}Squash merge branch \u{2018}\(source)\u{2019} into \u{2018}\(target)\u{2019}\u{201d}")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, 20)
+ }
+ }
+ }
+ .padding(.leading, FormMetrics.contentInset)
+ }
+
Spacer(minLength: 0)
HStack {
Spacer()
@@ -86,7 +157,11 @@ struct MergeSheet: View {
noFastForward: noFastForward, noCommit: noCommit,
skipHooks: skipHooks,
deleteSource: deleteSource && sourceIsLocal,
- pushAfterMerge: pushAfterMerge && !noCommit)
+ pushAfterMerge: pushAfterMerge && !noCommit,
+ squashCommitMessage: squash && commitAfterSquash
+ ? squashMessage : nil,
+ generateSquashMessage: squash && commitAfterSquash
+ && useAIMessage)
}
}
.keyboardShortcut(.defaultAction)
@@ -94,7 +169,7 @@ struct MergeSheet: View {
}
}
.padding(24)
- .frame(width: 600, height: 480)
+ .frame(width: 600, height: squash && commitAfterSquash ? 600 : 480)
.task(id: source) { await loadPreview() }
}
diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
index 7ce765e..89c4f1d 100644
--- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift
+++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift
@@ -44,6 +44,7 @@ struct RepositoryWorkspaceView: View {
NavigationSplitView {
WorkspaceRail(viewModel: viewModel, section: $section, integrationSheet: $integrationSheet)
.navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 300)
+ .layoutPriority(1)
} detail: {
VStack(spacing: 0) {
if let operation = viewModel.operation {
@@ -115,8 +116,9 @@ struct RepositoryWorkspaceView: View {
.help("Push current branch").disabled(viewModel.isBusy || viewModel.remotes.isEmpty)
branchMenu
- moreMenu
+ }
+ ToolbarItemGroup {
if !hideAIFeatures {
Button {
if AIDefaults.hasAPIKey {
@@ -146,6 +148,9 @@ struct RepositoryWorkspaceView: View {
Image(systemName: "arrow.clockwise")
}
.help("Refresh").disabled(viewModel.isLoading)
+
+ openInMenu
+ moreMenu
}
}
.sheet(item: $integrationSheet) { sheet in
@@ -157,6 +162,7 @@ struct RepositoryWorkspaceView: View {
.sheet(isPresented: $showSettings) { SettingsSheet(viewModel: viewModel) }
.sheet(isPresented: $showAssistant) { AIAssistantView(viewModel: viewModel) }
.task { await viewModel.load() }
+ .onDisappear { viewModel.tearDown() }
.overlay {
if viewModel.isBusy {
OperationOverlay(title: viewModel.operationTitle ?? "Working",
@@ -165,6 +171,35 @@ struct RepositoryWorkspaceView: View {
}
}
+ /// Toolbar "Open in…" menu: launch the repository in an installed editor.
+ @ViewBuilder
+ private var openInMenu: some View {
+ let editors = AppDefaults.installedEditors
+ Menu {
+ if editors.isEmpty {
+ Text("No supported editors found")
+ } else {
+ ForEach(editors) { editor in
+ Button { viewModel.openProject(in: editor) } label: {
+ if let icon = editor.icon {
+ Label {
+ Text(editor.name)
+ } icon: {
+ Image(nsImage: icon)
+ }
+ } else {
+ Text(editor.name)
+ }
+ }
+ }
+ }
+ } label: {
+ Label("Open in…", systemImage: "square.and.arrow.up")
+ }
+ .help("Open repository in an editor")
+ .disabled(editors.isEmpty)
+ }
+
/// Toolbar "Branch" menu: checkout, new branch, merge, rebase.
@ViewBuilder
private var branchMenu: some View {
diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift
index bb59d7d..0a9d305 100644
--- a/Sources/Gitify/Views/WorkingTreeView.swift
+++ b/Sources/Gitify/Views/WorkingTreeView.swift
@@ -41,9 +41,10 @@ struct WorkingTreeView: View {
CommitBox(viewModel: viewModel)
}
.frame(minWidth: 300, idealWidth: 360, maxHeight: .infinity)
+ .layoutPriority(1)
DiffPane(viewModel: viewModel)
- .frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: viewModel.selectedPath) { _, path in
diff --git a/scripts/package.sh b/scripts/package.sh
index 3206781..a990796 100755
--- a/scripts/package.sh
+++ b/scripts/package.sh
@@ -46,8 +46,19 @@ fi
if [[ -n "${SIGN_IDENTITY:-}" && -n "${NOTARY_PROFILE:-}" ]]; then
echo "==> Submitting to Apple notary service (this can take a few minutes)"
xcrun notarytool submit "${DMG}" --keychain-profile "${NOTARY_PROFILE}" --wait
- echo "==> Stapling notarization ticket"
- xcrun stapler staple "${DMG}"
+ echo "==> Stapling notarization ticket (retrying up to 5 times for propagation delay)"
+ STAPLE_ATTEMPT=0
+ STAPLE_MAX=5
+ STAPLE_DELAY=30
+ until xcrun stapler staple "${DMG}" 2>/dev/null; do
+ STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
+ if [[ ${STAPLE_ATTEMPT} -ge ${STAPLE_MAX} ]]; then
+ echo "ERROR: stapler failed after ${STAPLE_MAX} attempts" >&2
+ exit 1
+ fi
+ echo " Staple attempt ${STAPLE_ATTEMPT}/${STAPLE_MAX} failed; retrying in ${STAPLE_DELAY}s..."
+ sleep "${STAPLE_DELAY}"
+ done
xcrun stapler validate "${DMG}"
else
echo "==> Skipping notarization (needs SIGN_IDENTITY + NOTARY_PROFILE)"