---
name: macos-release
description: >
  Release a native macOS app with Sparkle auto-update and GitHub distribution. Use when the user
  wants to add auto-updates, create a DMG, notarize, publish to GitHub, update an appcast, or
  set up a release pipeline. Covers Sparkle SPM integration, EdDSA signing, DMG creation,
  notarization, appcast.xml management, and the full release workflow.
---

# macOS Release & Auto-Update

Complete pipeline for distributing a native macOS app outside the Mac App Store via GitHub Releases with Sparkle auto-update support.

Source: https://sparkle-project.org/documentation/

---

## 1. Sparkle Auto-Update Setup (HIGH)

### 1.1 — Add Sparkle via SPM

In Xcode: File > Add Package Dependencies:

```
https://github.com/sparkle-project/Sparkle
```

Use "Up to Next Major Version" with `2.0.0`.

### 1.2 — Create UpdaterManager

See `references/UpdaterManager.swift` in the macos skill. Key points:
- Uses `ObservableObject` (not `@Observable`) for Combine KVO bridge
- `startingUpdater: false` — call `start()` explicitly in `applicationDidFinishLaunching`
- DEBUG guards — never check for updates in debug builds
- For menu-bar-only apps: temporarily switch to `.regular` activation policy

### 1.3 — Configure Info.plist

```xml
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/OWNER/REPO/main/appcast.xml</string>

<key>SUPublicEDKey</key>
<string>YOUR_PUBLIC_EDDSA_KEY</string>

<key>SUEnableInstallerLauncherService</key>
<true/>
```

### 1.4 — Generate EdDSA Keys

```bash
find ~/Library/Developer/Xcode/DerivedData -name "generate_keys" -type f 2>/dev/null | head -1
```

Run it. Public key goes in `SUPublicEDKey`. Private key is stored in Keychain automatically.

### 1.5 — Wire Into App Delegate

```swift
final class AppDelegate: NSObject, NSApplicationDelegate {
    private let updater = UpdaterManager.shared

    func applicationDidFinishLaunching(_ notification: Notification) {
        updater.start()
    }
}
```

### 1.6 — UI Integration

```swift
struct AboutPane: View {
    @ObservedObject private var updater = UpdaterManager.shared

    var body: some View {
        Section("Updates") {
            Toggle("Automatically check for updates", isOn: Binding(
                get: { updater.automaticallyChecksForUpdates },
                set: { updater.automaticallyChecksForUpdates = $0 }
            ))
            Button("Check for Updates...") { updater.checkForUpdates() }
                .disabled(!updater.canCheckForUpdates)
        }
    }
}
```

### 1.7 — Create Initial Appcast

```xml
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
  xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>YourApp</title>
    <description>Most recent changes.</description>
    <language>en</language>
  </channel>
</rss>
```

Host on GitHub raw URL. Must match `SUFeedURL` in Info.plist.

---

## 2. Release Pipeline (HIGH)

### Full Pipeline

```
Bump version → Archive → Notarize → Export → Create DMG → Sign DMG → Update appcast → Push → GitHub Release
```

### Prerequisites

| Tool | Install | Purpose |
|------|---------|---------|
| `create-dmg` | `brew install create-dmg` | DMG installer |
| `gh` | `brew install gh` | GitHub releases |
| `sign_update` | Built with Sparkle in DerivedData | EdDSA signing |

### Step 1: Bump Version

Update in Xcode:
- `MARKETING_VERSION` (e.g., `1.2`) — user-facing
- `CURRENT_PROJECT_VERSION` (e.g., `5`) — build number (unique per release)

### Step 2: Archive + Notarize

Product > Archive > Distribute App > Direct Distribution. Wait for notarization. Export to `~/Downloads/YourApp.app`.

### Step 3: Create DMG

```bash
create-dmg \
  --volname "YourApp" \
  --window-pos 200 120 \
  --window-size 660 400 \
  --icon-size 160 \
  --icon "YourApp.app" 180 170 \
  --app-drop-link 480 170 \
  --hide-extension "YourApp.app" \
  ~/Downloads/YourApp.dmg \
  ~/Downloads/YourApp.app
```

### Step 4: Sign DMG

```bash
/path/to/sign_update ~/Downloads/YourApp.dmg
```

Outputs: `sparkle:edSignature="BASE64..." length="12345"`

### Step 5: Update Appcast

Add new `<item>` at top of `<channel>`:

```xml
<item>
  <title>Version 1.2</title>
  <pubDate>Mon, 26 May 2026 12:00:00 +0000</pubDate>
  <sparkle:version>5</sparkle:version>
  <sparkle:shortVersionString>1.2</sparkle:shortVersionString>
  <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
  <description><![CDATA[<ul><li>New feature</li></ul>]]></description>
  <enclosure url="https://github.com/OWNER/REPO/releases/download/v1.2/YourApp.dmg"
             type="application/octet-stream"
             sparkle:edSignature="SIGNATURE_FROM_STEP_4"
             length="LENGTH_FROM_STEP_4" />
</item>
```

### Step 6: Push Appcast

```bash
git add appcast.xml
git commit -m "Release v1.2 appcast"
git push origin main
```

### Step 7: Create GitHub Release

```bash
gh release create v1.2 \
  ~/Downloads/YourApp.dmg \
  --title "v1.2" \
  --notes "- New feature\n- Bug fix"
```

---

## 3. CLI Automation (MEDIUM)

For frequent releases, use the Go CLI in `cli/main.go` that automates steps 3–7.

### Setup

Create `release.json` in your project root:

```json
{
  "app_name": "MyApp",
  "github_repo": "owner/myapp"
}
```

### Run

```bash
go run ./skills/macos-release/cli
```

The CLI:
1. Finds `release.json`
2. Validates exported app in ~/Downloads
3. Checks for `create-dmg`, `gh`, `sign_update`
4. Collects release notes interactively
5. Creates DMG, signs it, updates appcast, pushes, creates GitHub release

---

## Common Issues

| Problem | Solution |
|---------|----------|
| `sign_update` not found | Build project in Xcode first |
| `gh` auth failure | Run `gh auth login` |
| Duplicate build number | Bump `CURRENT_PROJECT_VERSION` |
| App won't update | Verify `SUFeedURL` is raw GitHub URL, not page URL |
| DMG too large | Check for debug symbols in export |
