# 0.2 — Phase 5: Sparkle auto-update + DMG distribution (Implementation Plan)

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Every push of a `v*` tag produces a notarized + stapled DMG, attaches it to the corresponding GitHub Release, and updates the Sparkle appcast hosted on `gh-pages`. Existing AccountPilot installs see "Update available" within Sparkle's check interval and can update in-place without re-granting FDA (TeamID + bundle ID stay constant).

**Architecture:** Sparkle 2.x integrated via Swift Package Manager in `app/project.yml`. EdDSA-signed updates via Sparkle's `sign_update` tool. DMG produced by `create-dmg` (already installed since Phase 1 prereqs). GitHub Actions workflow on macOS runners imports the Developer ID Application cert into a temporary keychain, runs `app/scripts/build-app.sh` (existing) → `app/scripts/build-dmg.sh` (new) → `scripts/sign-update.sh` (new), uploads the DMG to the Release, and pushes a new appcast entry to `gh-pages` (served at `https://aren13.github.io/accountpilot/appcast.xml`).

**Tech Stack:** Sparkle 2.x (SwiftPM) · `create-dmg` 1.2 · `xcrun notarytool submit` (already used in Phase 1) · `xcrun stapler` · GitHub Actions on `macos-latest` · `apple-actions/import-codesign-certs` · GitHub Pages on `gh-pages` branch

---

## Prerequisites

Before starting Task 1, ensure:

- [ ] Phase 4 PR has merged to `main`. Verify: `git log --oneline main | head -3` shows the Phase 4 merge.
- [ ] On a Phase 5 branch: `git checkout -b 0.2-p5-sparkle-dmg` (off main).
- [ ] `create-dmg` installed: `which create-dmg` returns a path (Phase 1 prerequisites already covered this).
- [ ] `dist/AccountPilot.app` from Phase 4 still builds end-to-end (`./app/scripts/build-app.sh` ends in `==> all verification passed`).

## What this plan does NOT do (one-time human setup)

The user (you, Arda) must do these once outside the plan — they handle secrets that can't be committed:

1. **Generate the Sparkle EdDSA keypair** — run `Sparkle/bin/generate_keys` (after Sparkle is checked out by SwiftPM in Task 1). The tool prints the private key to stdout and offers to save the public key. Save the private key to a password manager + add it as GitHub Actions secret `SPARKLE_PRIVATE_KEY`. The public key gets committed (it's safe to publish).
2. **Add the Developer ID Application certificate** as a GitHub Actions secret. Export from Keychain Access as a `.p12` (with a password), base64 it (`base64 -i cert.p12 | pbcopy`), add to GitHub as `APPLE_DEV_ID_CERT_P12_BASE64` + `APPLE_DEV_ID_CERT_PASSWORD`.
3. **Add the App Store Connect API key** for notarization (already used by `scripts/release-helper.sh`). Three secrets: `APPLE_API_KEY_BASE64` (the .p8 file base64'd), `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID`.
4. **Initialize the `gh-pages` branch** with an empty `appcast.xml`. The workflow appends entries; the initial bootstrap is a one-shot manual step (Task 6 of this plan covers it).
5. **Enable GitHub Pages** for the `gh-pages` branch in the repo's Settings → Pages.

These five steps are documented in detail in `docs/release.md` (Task 9).

## File structure (created or modified)

```
app/project.yml                            # MODIFY: add Sparkle SwiftPM package + dependency
app/AccountPilot/SparkleConfig.swift       # CREATE: SUPDStandardUpdaterController + menu item wiring
app/AccountPilot/AccountPilotApp.swift     # MODIFY: inject Sparkle into main menu

app/AccountPilot/Info.plist                # auto-regen via xcodegen — adds SUFeedURL + SUPublicEDKey
                                           # via project.yml info.properties

app/scripts/build-dmg.sh                   # CREATE: wrap create-dmg around dist/AccountPilot.app
                                           # → dist/AccountPilot-<version>.dmg

scripts/
├── sign-update.sh                          # CREATE: wraps Sparkle's sign_update; emits ed-signature
└── append-appcast-entry.sh                 # CREATE: appends a new <item> to appcast.xml

.github/workflows/release-app.yml           # CREATE: triggered by v* tags

docs/release.md                             # CREATE: one-time setup + how to do a release

CHANGELOG.md                                # MODIFY: Phase 5 entries
```

`appcast.xml` lives on the `gh-pages` branch — not in `main`. The bootstrap appcast (Task 6) gets pushed to that branch directly, outside the normal merge flow.

---

## Task 1: Add Sparkle as a Swift Package + regenerate xcodeproj

**Goal:** Wire Sparkle 2.x into the AccountPilot target. After this task, `import Sparkle` works in Swift; the framework is embedded in the .app bundle.

**Files:**
- Modify: `app/project.yml`

- [ ] **Step 1: Read the current `app/project.yml`**

```bash
cat app/project.yml
```

- [ ] **Step 2: Add the `packages` section + AccountPilot dependency**

Insert a top-level `packages:` block (after `settings:`, before `targets:`):

```yaml
packages:
  Sparkle:
    url: https://github.com/sparkle-project/Sparkle.git
    from: "2.6.0"
```

In the `AccountPilot` target's `dependencies:` block (currently lists `target: AccountPilotXPC` from Phase 3), append:

```yaml
    dependencies:
      - target: AccountPilotXPC
        copy:
          destination: plugins
          embed: true
      - package: Sparkle
        product: Sparkle
        embed: true
```

XcodeGen handles the `embed: true` directive — Sparkle.framework will be copied into `Contents/Frameworks/` of the .app and signed by `app/scripts/codesign-app.sh`'s find-loop.

- [ ] **Step 3: Add Sparkle Info.plist keys via project.yml**

In the `AccountPilot.info.properties` block, add:

```yaml
        SUFeedURL: "https://aren13.github.io/accountpilot/appcast.xml"
        SUPublicEDKey: "REPLACE_AFTER_GENERATING_KEY"
        SUEnableInstallerLauncherService: false
```

The `SUPublicEDKey` is a placeholder — it gets replaced in Task 2 once the keypair is generated. Note this in the plan output if asking the user.

Also: the existing entitlements need `com.apple.security.cs.allow-unsigned-executable-memory: false` (already there) — Sparkle 2's installer service needs no extra entitlements on the parent app.

- [ ] **Step 4: Regenerate + build**

```bash
cd app && xcodegen generate && cd ..
xcodebuild -project app/AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath app/build/ -skipMacroValidation build 2>&1 | tail -10
```

Expected: `** BUILD SUCCEEDED **`. The first build will take a minute longer than usual (~2-3 min) as Xcode resolves + caches the Sparkle SwiftPM checkout.

If xcodebuild fails on package resolution (network issues with the github.com clone): try again — `actions/checkout@v4` in CI handles this automatically.

If the Mach-O find-loop in `app/scripts/codesign-app.sh` doesn't sign Sparkle's nested binaries correctly during `build-app.sh`, that'll surface in Task 4. For now: just confirm the build compiles + Sparkle.framework lands in `Contents/Frameworks/`:

```bash
find app/build/Build/Products/Release -name "Sparkle.framework" -maxdepth 5 -type d
```

Expected: prints a path inside `…/AccountPilot.app/Contents/Frameworks/Sparkle.framework`.

- [ ] **Step 5: Commit**

```bash
git add app/project.yml
git commit -m "build(app): add Sparkle 2.x via SwiftPM, embed in bundle"
```

---

## Task 2: Generate the Sparkle EdDSA keypair (one-time, requires user)

**Goal:** Produce the EdDSA keypair Sparkle uses to verify update authenticity. Public key gets committed; private key goes to a password manager + GitHub Actions secret.

**This task requires the human user.** The agent should pause and ask.

**Files:**
- Modify: `app/project.yml` (replace `SUPublicEDKey` placeholder)

- [ ] **Step 1: Locate Sparkle's key tools after SwiftPM checkout**

After Task 1's build, Sparkle's checkout lives under `~/Library/Developer/Xcode/DerivedData` or `app/build/SourcePackages/checkouts/Sparkle/`. The `generate_keys` tool ships in `bin/generate_keys`. Locate it:

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

Expected: at least one path. If neither: download Sparkle's release tarball (https://github.com/sparkle-project/Sparkle/releases/latest, `Sparkle-X.Y.Z.tar.xz`), extract, the tools live in `bin/`.

- [ ] **Step 2: Generate the keypair (USER ACTION REQUIRED)**

```bash
<path-to>/generate_keys
```

The tool stores the keypair in the macOS Keychain by default (under `https://sparkle-project.org`). It prints the public key as a base64 string AND offers to write `<KEY>` files. Save the public key string somewhere safe; you'll paste it into project.yml in Step 3.

To export the private key (for the GitHub Actions secret), run:

```bash
<path-to>/generate_keys -p > /tmp/sparkle-public.b64
<path-to>/generate_keys -x /tmp/sparkle-private.b64
```

(The `-x` flag writes the private key to the given file; it's a single-line base64 string.)

**Add the private key as a GitHub Actions secret** named `SPARKLE_PRIVATE_KEY`:

```bash
gh secret set SPARKLE_PRIVATE_KEY < /tmp/sparkle-private.b64
shred -u /tmp/sparkle-private.b64    # or `rm -P` on macOS, gpg-encrypt + delete, etc.
```

(Don't leave the .b64 file on disk after `gh secret set`.)

- [ ] **Step 3: Replace `SUPublicEDKey` placeholder in `app/project.yml`**

Edit `app/project.yml`'s `AccountPilot.info.properties`: replace `SUPublicEDKey: "REPLACE_AFTER_GENERATING_KEY"` with the actual public key string from Step 2 (e.g. `SUPublicEDKey: "abc123…="`).

- [ ] **Step 4: Regenerate + build to confirm Info.plist updated correctly**

```bash
cd app && xcodegen generate && cd ..
plutil -extract SUPublicEDKey raw app/AccountPilot/Info.plist
```

Expected: prints the public key string you just inserted.

- [ ] **Step 5: Commit**

```bash
git add app/project.yml app/AccountPilot/Info.plist
git commit -m "build(app): commit Sparkle EdDSA public key"
```

The `Info.plist` is auto-generated by xcodegen but Phase 1 + Phase 2 have been treating it as gitignored / regenerated each build. **For this project key the public-key value MUST persist across xcodegen runs.** Since it's authored in `project.yml`, every regeneration produces the same Info.plist — committing the generated Info.plist is fine but redundant. Adjust per the existing convention: commit project.yml only.

---

## Task 3: `SparkleConfig.swift` + Sparkle wiring

**Goal:** Add an `SPUStandardUpdaterController` to the app's main scene so Sparkle initializes on launch and the app's menu gains a "Check for Updates…" item.

**Files:**
- Create: `app/AccountPilot/SparkleConfig.swift`
- Modify: `app/AccountPilot/AccountPilotApp.swift`

- [ ] **Step 1: Write `SparkleConfig.swift`**

```swift
import SwiftUI
import Sparkle

/// Wraps Sparkle's `SPUStandardUpdaterController` so it can be passed
/// down through the App scene as an environment value. Configured via
/// the bundle Info.plist (SUFeedURL + SUPublicEDKey set in project.yml).
final class SparkleUpdaterController {
    let controller: SPUStandardUpdaterController

    init() {
        controller = SPUStandardUpdaterController(
            startingUpdater: true,
            updaterDelegate: nil,
            userDriverDelegate: nil
        )
    }

    /// Trigger a "Check Now" UI flow.
    func checkForUpdates() {
        controller.checkForUpdates(nil)
    }
}

/// SwiftUI `Commands` builder that adds the standard "Check for Updates…"
/// menu item under the application menu.
struct SparkleUpdatesCommand: Commands {
    let updater: SparkleUpdaterController

    var body: some Commands {
        CommandGroup(after: .appInfo) {
            Button("Check for Updates…") {
                updater.checkForUpdates()
            }
        }
    }
}
```

- [ ] **Step 2: Wire into `AccountPilotApp.swift`**

Read the current file:

```bash
cat app/AccountPilot/AccountPilotApp.swift
```

Find the `@main struct AccountPilotApp: App` body. Add an `@StateObject`-style holder (well, `let` — the controller is a class but we don't need observation) and the `.commands` modifier:

```swift
@main
struct AccountPilotApp: App {
    @StateObject private var supervisor = SyncSupervisor()
    @State private var migration: ConfigMigration.Result?
    @AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
    private let sparkle = SparkleUpdaterController()

    var body: some Scene {
        Window("AccountPilot", id: "main") {
            ContentView(migration: migration, supervisor: supervisor)
                .frame(minWidth: 720, minHeight: 480)
                .task {
                    if migration == nil {
                        migration = await ConfigMigration.runIfNeeded()
                    }
                    if backgroundSyncEnabled {
                        await supervisor.start()
                    }
                }
        }
        .windowResizability(.contentMinSize)
        .commands {
            SparkleUpdatesCommand(updater: sparkle)
        }

        MenuBarExtra(
            "AccountPilot",
            systemImage: supervisor.isRunning ? "tray.full.fill" : "tray"
        ) {
            StatusBarMenu(supervisor: supervisor)
        }

        Settings {
            SettingsView(supervisor: supervisor)
        }
    }
}
```

(Keep the existing scenes — Window, MenuBarExtra, Settings — intact. The only addition is `private let sparkle = SparkleUpdaterController()` and `.commands { SparkleUpdatesCommand(updater: sparkle) }` on the Window scene.)

- [ ] **Step 3: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -5 && cd ..
```

Expected: `** BUILD SUCCEEDED **`. Sparkle initializes when SPUStandardUpdaterController is constructed (Sparkle internally checks the Info.plist's SUFeedURL + SUPublicEDKey at startup).

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/SparkleConfig.swift app/AccountPilot/AccountPilotApp.swift
git commit -m "feat(app): wire Sparkle SPUStandardUpdaterController + menu item"
```

---

## Task 4: Update `app/scripts/codesign-app.sh` for Sparkle's nested binaries

**Goal:** Sparkle.framework contains nested signed binaries (Autoupdate, Updater.app, Installer.app) that must be signed with our Developer ID before the outer .app bundle. The current find-loop signs `Frameworks/python/` recursively — extend it to also walk `Frameworks/Sparkle.framework/`.

**Files:**
- Modify: `app/scripts/codesign-app.sh`

- [ ] **Step 1: Read the current script**

```bash
cat app/scripts/codesign-app.sh
```

The current logic walks `$APP_BUNDLE/Contents/Resources/python` for Mach-Os, signs them depth-first. Sparkle.framework lives in `Contents/Frameworks/Sparkle.framework/` (this is the convention; XcodeGen's `embed: true` copies it there).

- [ ] **Step 2: Extend the script to walk Sparkle.framework**

Find the existing find-loop. Add a new section AFTER it (before signing the FDA helper) that signs Sparkle's nested binaries:

```bash
# Sign Sparkle's nested binaries depth-first.
SPARKLE_BUNDLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
if [ -d "$SPARKLE_BUNDLE" ]; then
    echo "==> signing Sparkle.framework nested Mach-Os"
    # Sparkle 2 ships these helpers inside the framework's Versions/B/
    # Resources/. Sign all .xpc bundles + .app bundles + the framework
    # itself depth-first.
    find "$SPARKLE_BUNDLE" -type d \( -name "*.xpc" -o -name "*.app" \) | while read -r nested; do
        # Sign the nested Mach-O first.
        nested_macos="$nested/Contents/MacOS"
        if [ -d "$nested_macos" ]; then
            for bin in "$nested_macos"/*; do
                if [ -f "$bin" ] && head -c 4 "$bin" | xxd -p | grep -qE '^(cffaedfe|cefaedfe|feedface|feedfacf|cafebabe|cafebabf)' 2>/dev/null; then
                    codesign --force --sign "$APPLE_DEV_ID" --options runtime --timestamp "$bin"
                fi
            done
        fi
        # Sign the nested bundle wrapper.
        codesign --force --sign "$APPLE_DEV_ID" --options runtime --timestamp "$nested"
    done
    # Sign the framework wrapper.
    codesign --force --sign "$APPLE_DEV_ID" --options runtime --timestamp "$SPARKLE_BUNDLE"
fi
```

- [ ] **Step 3: Run the existing build-app.sh end-to-end (without Sparkle yet, to verify Phase 1-4 still works)**

Wait — Sparkle is now embedded after Task 1. So this build will exercise the new Sparkle signing path. Run:

```bash
rm -rf dist/AccountPilot.app
./app/scripts/build-app.sh > /tmp/p5-build-task4.log 2>&1; echo "exit=$?"
tail -25 /tmp/p5-build-task4.log
```

Expected ending: `==> all verification passed`. The codesign step should produce additional log lines for Sparkle's nested binaries. Notarization should still pass — Sparkle's binaries are normal Mach-Os signed with our Developer ID + secure timestamps + hardened runtime.

If notarization rejects with "the binary is not signed" on a Sparkle nested file, the find-loop missed it. Inspect Sparkle.framework's structure with:

```bash
find dist/AccountPilot.app/Contents/Frameworks/Sparkle.framework -type f -perm -u+x | head -20
```

…and add the missed paths to the codesign loop.

- [ ] **Step 4: Commit**

```bash
git add app/scripts/codesign-app.sh
git commit -m "build(app): codesign Sparkle.framework nested binaries"
```

---

## Task 5: `app/scripts/build-dmg.sh` — DMG packaging

**Goal:** A script that takes `dist/AccountPilot.app` (already signed + notarized + stapled by `build-app.sh`) and produces `dist/AccountPilot-<version>.dmg`.

**Files:**
- Create: `app/scripts/build-dmg.sh`

- [ ] **Step 1: Write the script**

```bash
#!/usr/bin/env bash
# Package dist/AccountPilot.app into a notarized + stapled DMG.
#
# Inputs:
#   $APP_BUNDLE   absolute path to AccountPilot.app (default: dist/AccountPilot.app)
#   $VERSION      version string for the output filename (default: read from Info.plist)
#
# Output:
#   dist/AccountPilot-<version>.dmg  (notarized + stapled)
#
# create-dmg arguments configure window size, icon position, and
# Applications symlink (drag-to-install).

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"

APP_BUNDLE="${APP_BUNDLE:-$REPO_ROOT/dist/AccountPilot.app}"
test -d "$APP_BUNDLE" || { echo "error: $APP_BUNDLE missing — run build-app.sh first"; exit 1; }

if [ -z "${VERSION:-}" ]; then
    VERSION="$(plutil -extract CFBundleShortVersionString raw \
        "$APP_BUNDLE/Contents/Info.plist")"
fi
test -n "$VERSION" || { echo "error: could not read CFBundleShortVersionString"; exit 1; }

DMG_PATH="$REPO_ROOT/dist/AccountPilot-$VERSION.dmg"
rm -f "$DMG_PATH"

echo "==> building DMG → $DMG_PATH"
create-dmg \
    --volname "AccountPilot $VERSION" \
    --window-pos 200 120 \
    --window-size 600 360 \
    --icon-size 100 \
    --icon "AccountPilot.app" 150 180 \
    --hide-extension "AccountPilot.app" \
    --app-drop-link 450 180 \
    --no-internet-enable \
    "$DMG_PATH" \
    "$APP_BUNDLE"

echo "==> codesign the DMG (notarization requires it)"
: "${APPLE_DEV_ID:=Developer ID Application: FAZLA GIDA ANONIM SIRKETI (P2R7PD8VGY)}"
codesign --force --sign "$APPLE_DEV_ID" --timestamp "$DMG_PATH"

echo "==> notarize + staple the DMG"
# Reuse the same notary auth resolution as scripts/release-helper.sh:
# API key (mode B) takes precedence; else fall back to keychain profile.
if [ -n "${APPLE_API_KEY_PATH:-}" ]; then
    NOTARY_AUTH=(--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID")
else
    NOTARY_AUTH=(--keychain-profile "${APPLE_NOTARY_PROFILE:-accountpilot-notary}")
fi
xcrun notarytool submit "$DMG_PATH" "${NOTARY_AUTH[@]}" --wait
xcrun stapler staple "$DMG_PATH"
xcrun stapler validate "$DMG_PATH"

echo "==> done: $DMG_PATH"
ls -lh "$DMG_PATH"
```

- [ ] **Step 2: Make executable**

```bash
chmod +x app/scripts/build-dmg.sh
```

- [ ] **Step 3: Local smoke test (uses Phase 1's keychain profile auth)**

```bash
./app/scripts/build-dmg.sh
```

Expected: produces `dist/AccountPilot-0.2.0.dmg`, ~80-150 MB. Notarization round-trip ~3-5 min.

If notarization rejects: read the notary log via `xcrun notarytool log <id> --keychain-profile accountpilot-notary`. Most likely cause: an unsigned binary inside the .app that wasn't caught by build-app.sh's verify (Sparkle's nested binaries — Task 4 should have signed them). Re-run build-app.sh first to confirm `==> all verification passed`, then re-run build-dmg.sh.

- [ ] **Step 4: Verify the DMG mounts + installs**

```bash
hdiutil attach dist/AccountPilot-0.2.0.dmg
ls /Volumes/AccountPilot\ 0.2.0/
hdiutil detach /Volumes/AccountPilot\ 0.2.0/
```

Expected: directory contains `AccountPilot.app` + `Applications` symlink (the drag-to-install).

- [ ] **Step 5: Commit**

```bash
git add app/scripts/build-dmg.sh
git commit -m "feat(release): build-dmg.sh — notarized + stapled DMG packaging"
```

---

## Task 6: Bootstrap the `gh-pages` branch with empty appcast

**Goal:** Create a `gh-pages` orphan branch with an initial empty `appcast.xml`. The release workflow appends entries; the bootstrap is a one-shot manual step.

**This task touches an external branch — uses `git worktree` for isolation.**

**Files:**
- Create: `appcast.xml` (on `gh-pages` branch, NOT main)

- [ ] **Step 1: Create the orphan branch via worktree**

```bash
git worktree add -b gh-pages /tmp/accountpilot-gh-pages
cd /tmp/accountpilot-gh-pages
# Wipe everything from the parent branch's tree (orphan: empty initial commit)
git rm -rf . || true
```

- [ ] **Step 2: Write the bootstrap `appcast.xml`**

```bash
cat > appcast.xml <<'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>AccountPilot Updates</title>
    <link>https://aren13.github.io/accountpilot/appcast.xml</link>
    <description>AccountPilot release feed</description>
    <language>en</language>
    <!-- New <item> entries appended by .github/workflows/release-app.yml -->
  </channel>
</rss>
XML
```

- [ ] **Step 3: Add a stub `index.html` so GitHub Pages routes work**

```bash
cat > index.html <<'HTML'
<!doctype html>
<title>AccountPilot</title>
<h1>AccountPilot release feed</h1>
<p>Sparkle appcast: <a href="appcast.xml">appcast.xml</a></p>
HTML
```

- [ ] **Step 4: Commit + push the orphan branch**

```bash
git add appcast.xml index.html
git commit -m "build(release): bootstrap gh-pages with empty appcast"
git push -u origin gh-pages
```

- [ ] **Step 5: Clean up the worktree**

```bash
cd "$REPO_ROOT"
git worktree remove /tmp/accountpilot-gh-pages
git branch -D gh-pages    # local branch we no longer need; origin/gh-pages remains
```

- [ ] **Step 6: USER ACTION REQUIRED — enable GitHub Pages**

In the repo's GitHub Settings → Pages:
- Source: **Deploy from a branch**
- Branch: **gh-pages**, folder: **/ (root)**

Verify: visit https://aren13.github.io/accountpilot/appcast.xml — should serve the bootstrap XML within ~1 minute of enabling.

(This task creates no commits on `main`. The agent should mark this task complete after verifying the URL responds with the appcast XML.)

---

## Task 7: `scripts/sign-update.sh` — wraps Sparkle's sign_update

**Goal:** A script the GitHub workflow calls to produce the EdDSA signature for a DMG. Output goes into the `<sparkle:edSignature>` attribute of the appcast entry.

**Files:**
- Create: `scripts/sign-update.sh`

- [ ] **Step 1: Locate Sparkle's sign_update tool**

`sign_update` lives alongside `generate_keys`. After Phase 5 Task 1's build, find it:

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

The tool reads the EdDSA private key from the Keychain by default (the same place `generate_keys` stored it). For CI, it accepts `-f <private_key_file>` to read from a file written from the GitHub Actions secret.

- [ ] **Step 2: Write `scripts/sign-update.sh`**

```bash
#!/usr/bin/env bash
# Generate the Sparkle EdDSA signature for a DMG.
#
# Inputs:
#   $1                     path to the DMG file
#   $SPARKLE_PRIVATE_KEY   (optional) base64 private key — if set, the
#                          tool reads from this file instead of Keychain
#
# Output (to stdout): the line emitted by sign_update, looks like:
#   sparkle:edSignature="..." length="..."
#
# CI consumes this and embeds it into the new appcast <item>.

set -euo pipefail

DMG="${1:?usage: sign-update.sh <dmg-path>}"
test -f "$DMG" || { echo "error: $DMG not found" >&2; exit 1; }

# Locate sign_update — try the cached SwiftPM checkout first, then a
# system install.
SIGN_UPDATE="$(
    find ~/Library/Developer/Xcode/DerivedData -name "sign_update" -type f 2>/dev/null | head -1
)"
if [ -z "$SIGN_UPDATE" ]; then
    SIGN_UPDATE="$(find /tmp -name "sign_update" -type f 2>/dev/null | head -1)"
fi
test -x "$SIGN_UPDATE" || {
    echo "error: sign_update not found — Sparkle's tools must be available" >&2
    echo "       Build the .app at least once (xcodebuild) so SwiftPM caches Sparkle's checkout" >&2
    exit 1
}

# If $SPARKLE_PRIVATE_KEY env var holds the literal base64 private key
# (CI use), write it to a temp file and pass --ed-key-file.
if [ -n "${SPARKLE_PRIVATE_KEY:-}" ]; then
    KEY_FILE="$(mktemp)"
    chmod 600 "$KEY_FILE"
    printf '%s' "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE"
    trap 'rm -f "$KEY_FILE"' EXIT
    "$SIGN_UPDATE" -f "$KEY_FILE" "$DMG"
else
    # Local dev: read from Keychain (where generate_keys stored it).
    "$SIGN_UPDATE" "$DMG"
fi
```

- [ ] **Step 3: Make executable + smoke test locally**

```bash
chmod +x scripts/sign-update.sh
./scripts/sign-update.sh dist/AccountPilot-0.2.0.dmg
```

Expected output (single line):
```
sparkle:edSignature="<base64>" length="<bytes>"
```

If `sign_update` not found: build the .app at least once (xcodebuild) — SwiftPM resolves Sparkle and caches the tools. Re-run.

- [ ] **Step 4: Commit**

```bash
git add scripts/sign-update.sh
git commit -m "feat(release): sign-update.sh — Sparkle EdDSA per-DMG signature"
```

---

## Task 8: `.github/workflows/release-app.yml` — the orchestrator

**Goal:** A workflow triggered by `v*` tags that builds the .app, creates the DMG, signs the update, attaches the DMG to the GitHub Release, appends an entry to the appcast on `gh-pages`, and pushes.

**Files:**
- Create: `.github/workflows/release-app.yml`
- Create: `scripts/append-appcast-entry.sh`

The workflow runs on `macos-latest`. It needs:
- The Apple Developer ID Application certificate (imported into a temp keychain)
- The App Store Connect API key (for notarytool)
- The Sparkle EdDSA private key

All three live as GitHub Actions secrets (set up by the user — see `docs/release.md` in Task 9).

- [ ] **Step 1: Write `scripts/append-appcast-entry.sh`**

```bash
#!/usr/bin/env bash
# Append a new <item> to appcast.xml for a tagged release.
#
# Inputs:
#   $1   path to appcast.xml (mutated in place)
#   $2   version string (e.g. "0.2.1")
#   $3   path to the DMG (used for length + by the sparkle:edSignature output)
#   $4   download URL (the GitHub Release asset URL)
#   $5   Sparkle ed signature line ('sparkle:edSignature="..." length="..."')
#
# A tiny portable XML editor — sed/awk-based; idempotent if the same
# version is already in the appcast (replaces).

set -euo pipefail

APPCAST="${1:?usage: append-appcast-entry.sh appcast.xml version dmg url ed-sig-line}"
VERSION="${2:?missing version}"
DMG="${3:?missing dmg path}"
URL="${4:?missing download url}"
ED_SIG_LINE="${5:?missing ed-signature line}"

DMG_LENGTH="$(stat -f%z "$DMG" 2>/dev/null || stat -c%s "$DMG")"
PUB_DATE="$(date -u +"%a, %d %b %Y %H:%M:%S %Z")"

NEW_ITEM=$(cat <<XML
    <item>
      <title>Version $VERSION</title>
      <pubDate>$PUB_DATE</pubDate>
      <sparkle:version>$VERSION</sparkle:version>
      <sparkle:shortVersionString>$VERSION</sparkle:shortVersionString>
      <sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
      <enclosure url="$URL" type="application/octet-stream" $ED_SIG_LINE />
    </item>
XML
)

# Insert before the closing </channel> tag.
python3 - "$APPCAST" "$NEW_ITEM" <<'PY'
import sys, re
appcast_path, new_item = sys.argv[1], sys.argv[2]
with open(appcast_path) as f:
    xml = f.read()
# Remove an existing entry for the same version if present (idempotency).
import xml.etree.ElementTree as ET  # noqa
# Simpler: regex-strip <item>…</item> blocks containing the same sparkle:version.
import re as _re
version_match = _re.search(r"<sparkle:version>([\d.]+)</sparkle:version>", new_item)
v = version_match.group(1)
xml = _re.sub(
    r"\s*<item>(?:(?!</item>).)*?<sparkle:version>" + _re.escape(v) +
    r"</sparkle:version>(?:(?!</item>).)*?</item>\s*",
    "\n",
    xml,
    flags=_re.DOTALL,
)
xml = xml.replace("</channel>", new_item + "\n  </channel>")
with open(appcast_path, "w") as f:
    f.write(xml)
PY

echo "==> appended version $VERSION to $APPCAST"
```

- [ ] **Step 2: Make executable**

```bash
chmod +x scripts/append-appcast-entry.sh
```

- [ ] **Step 3: Write `.github/workflows/release-app.yml`**

```yaml
name: Release .app
# Triggered by v* tag pushes. Builds, signs, notarizes, packages,
# attaches the DMG to the GitHub Release, and appends a new appcast
# entry on the gh-pages branch.

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:
    inputs:
      version:
        description: "Version string (e.g. 0.2.1)"
        required: true

jobs:
  build-and-release:
    name: Build .app + DMG, attach to Release, update appcast
    runs-on: macos-latest
    permissions:
      contents: write   # needed to create release + push to gh-pages

    steps:
      - uses: actions/checkout@v4

      - name: Resolve version
        id: version
        run: |
          if [ -n "${{ github.event.inputs.version }}" ]; then
            echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
          else
            echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
          fi

      - name: Install build tooling
        run: |
          brew install xcodegen create-dmg

      - name: Import Developer ID Application certificate
        uses: apple-actions/import-codesign-certs@v3
        with:
          p12-file-base64: ${{ secrets.APPLE_DEV_ID_CERT_P12_BASE64 }}
          p12-password: ${{ secrets.APPLE_DEV_ID_CERT_PASSWORD }}

      - name: Write App Store Connect API key
        env:
          API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
        run: |
          mkdir -p "$HOME/.appstoreconnect/private_keys"
          echo "$API_KEY_BASE64" | base64 --decode \
            > "$HOME/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8"
          chmod 600 "$HOME/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8"

      - name: Build + notarize + staple the .app
        env:
          APPLE_API_KEY_PATH: ${{ format('{0}/.appstoreconnect/private_keys/AuthKey_{1}.p8', env.HOME, secrets.APPLE_API_KEY_ID) }}
          APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
          APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
        run: |
          ./app/scripts/build-app.sh

      - name: Build + notarize + staple the DMG
        env:
          APPLE_API_KEY_PATH: ${{ format('{0}/.appstoreconnect/private_keys/AuthKey_{1}.p8', env.HOME, secrets.APPLE_API_KEY_ID) }}
          APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
          APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          ./app/scripts/build-dmg.sh

      - name: Generate Sparkle EdDSA signature
        id: sparkle
        env:
          SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          DMG="dist/AccountPilot-${VERSION}.dmg"
          SIG_LINE="$(./scripts/sign-update.sh "$DMG")"
          # Mask any secrets that might leak; SIG_LINE is safe (public sig)
          echo "ed_sig_line=$SIG_LINE" >> "$GITHUB_OUTPUT"

      - name: Create GitHub Release + upload DMG
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          DMG="dist/AccountPilot-${VERSION}.dmg"
          gh release create "v${VERSION}" "$DMG" \
            --title "AccountPilot ${VERSION}" \
            --notes "Auto-built by .github/workflows/release-app.yml on $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            --verify-tag

      - name: Update appcast.xml on gh-pages
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ steps.version.outputs.version }}
          ED_SIG_LINE: ${{ steps.sparkle.outputs.ed_sig_line }}
        run: |
          DMG="$(realpath dist/AccountPilot-${VERSION}.dmg)"
          DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/AccountPilot-${VERSION}.dmg"

          git fetch origin gh-pages
          git worktree add /tmp/gh-pages gh-pages

          ./scripts/append-appcast-entry.sh \
            /tmp/gh-pages/appcast.xml \
            "${VERSION}" \
            "$DMG" \
            "$DOWNLOAD_URL" \
            "$ED_SIG_LINE"

          cd /tmp/gh-pages
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add appcast.xml
          git commit -m "release: appcast entry for v${VERSION}"
          git push origin gh-pages
```

- [ ] **Step 4: Verify workflow YAML syntax**

```bash
yamllint -d "{rules: {document-start: disable, line-length: disable, indentation: {indent-sequences: false}}}" .github/workflows/release-app.yml
```

If `yamllint` isn't installed: `pip install yamllint --user` first. Or just commit and let the GitHub side validate when the workflow is registered.

- [ ] **Step 5: Commit**

```bash
git add .github/workflows/release-app.yml scripts/append-appcast-entry.sh
git commit -m "ci: release-app.yml — build + DMG + appcast on v* tags"
```

---

## Task 9: `docs/release.md` — one-time setup + release procedure

**Files:**
- Create: `docs/release.md`

- [ ] **Step 1: Write the doc**

```markdown
# AccountPilot release procedure

This document covers (a) the one-time setup needed before the first
release and (b) the steps to cut a new release.

## One-time setup

### Sparkle EdDSA keypair

Sparkle verifies update authenticity with an EdDSA signature. Generate
a keypair on a trusted machine:

```bash
# Locate sign_update / generate_keys after `xcodebuild build` resolves Sparkle
find ~/Library/Developer/Xcode/DerivedData -name "generate_keys" -type f -print -quit
# Then:
<path>/generate_keys
```

The tool stores the keypair in your Keychain under the `https://sparkle-project.org` service. Print the public key:

```bash
<path>/generate_keys -p
```

Paste the public key into `app/project.yml`'s `AccountPilot.info.properties.SUPublicEDKey`. **Commit this**.

Export the private key for CI:

```bash
<path>/generate_keys -x /tmp/sparkle-private.b64
gh secret set SPARKLE_PRIVATE_KEY < /tmp/sparkle-private.b64
shred -u /tmp/sparkle-private.b64
```

### Apple Developer ID Application certificate

Export the certificate from Keychain Access as a `.p12` (right-click → Export) with a password. Then:

```bash
base64 -i developer-id.p12 | pbcopy   # copies to clipboard
```

Add as GitHub Actions secrets:
- `APPLE_DEV_ID_CERT_P12_BASE64` — the base64 string
- `APPLE_DEV_ID_CERT_PASSWORD` — the password you set during export

### App Store Connect API key (for notarytool in CI)

Create an API key at https://appstoreconnect.apple.com/access/api with the **Developer** role. Download the `.p8` file (you can only download it once).

```bash
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
```

Add as GitHub Actions secrets:
- `APPLE_API_KEY_BASE64` — the base64 string
- `APPLE_API_KEY_ID` — the 10-character key ID (e.g. `WHX8YQB959`)
- `APPLE_API_ISSUER_ID` — the UUID issuer ID for your team

### Bootstrap the gh-pages branch

```bash
git worktree add -b gh-pages /tmp/accountpilot-gh-pages
cd /tmp/accountpilot-gh-pages
git rm -rf . || true
# Write the bootstrap appcast.xml + index.html (see Phase 5 plan Task 6)
git add appcast.xml index.html
git commit -m "build(release): bootstrap gh-pages with empty appcast"
git push -u origin gh-pages
```

Then in GitHub Settings → Pages: source `gh-pages` branch, root folder. Verify https://aren13.github.io/accountpilot/appcast.xml serves the XML.

## Cutting a release

Cutting a release is a single command:

```bash
git tag v0.2.1
git push --tags
```

The `.github/workflows/release-app.yml` workflow runs on `macos-latest`. It takes ~10-15 minutes (most of that is the notarization round-trip — twice, once for the .app and once for the DMG). When complete:

- `https://github.com/aren13/accountpilot/releases/tag/v0.2.1` has the DMG attached
- `https://aren13.github.io/accountpilot/appcast.xml` has a new `<item>` for v0.2.1
- Existing AccountPilot installs see "Update available" within Sparkle's check interval (default: 24h, override via `SUScheduledCheckInterval` in Info.plist)

Watch the workflow at https://github.com/aren13/accountpilot/actions.

If notarization fails: read the workflow log; the `xcrun notarytool log <id> --key …` invocation prints the JSON of why Apple rejected. Most common cause: an unsigned binary somewhere in the .app bundle that `app/scripts/codesign-app.sh`'s find-loop missed — check Sparkle.framework's nested helpers if a Sparkle update changed the structure.

## Local DMG production (for manual testing)

To build a DMG locally without going through CI:

```bash
./app/scripts/build-app.sh    # produces dist/AccountPilot.app
./app/scripts/build-dmg.sh    # produces dist/AccountPilot-<version>.dmg
```

Both scripts use the local keychain profile `accountpilot-notary` (see `scripts/release-helper.sh` for the auth resolution).
```

- [ ] **Step 2: Commit**

```bash
git add docs/release.md
git commit -m "docs: release procedure + one-time setup for Sparkle/DMG/CI"
```

---

## Task 10: CHANGELOG + manual local DMG smoke test

- [ ] **Step 1: Append to CHANGELOG.md**

Under the existing `[0.2.0]` `### Added`:

```markdown
- **Sparkle auto-update + DMG distribution.** Tagged `v*` releases
  produce a notarized DMG attached to the GitHub Release, plus an
  appcast entry on the `gh-pages` branch. The .app's main menu has a
  "Check for Updates…" item. EdDSA-signed updates verify
  authenticity. Bundle ID + TeamID stay constant across updates so
  the FDA grant survives.
- **`app/scripts/build-dmg.sh`** wraps `create-dmg` to package the
  signed .app into a notarized + stapled DMG. **`scripts/sign-update.sh`**
  generates Sparkle's EdDSA signature for the appcast entry.
- **`.github/workflows/release-app.yml`** orchestrates the full flow on
  `macos-latest`: import cert → build .app → build DMG → sign update →
  attach to GitHub Release → append appcast → push to gh-pages.
```

- [ ] **Step 2: Run the local DMG smoke test**

```bash
rm -rf dist/AccountPilot.app dist/AccountPilot-*.dmg
./app/scripts/build-app.sh
./app/scripts/build-dmg.sh
ls -lh dist/
```

Expected: `dist/AccountPilot.app/` + `dist/AccountPilot-0.2.0.dmg` (~80-150 MB).

```bash
hdiutil attach dist/AccountPilot-0.2.0.dmg
ls /Volumes/AccountPilot\ 0.2.0/
hdiutil detach /Volumes/AccountPilot\ 0.2.0/
```

Expected: directory has `AccountPilot.app` + `Applications` symlink. Drag-install works (visually verified by mounting the DMG via `open dist/AccountPilot-0.2.0.dmg` and inspecting the Finder window).

```bash
xcrun stapler validate dist/AccountPilot-0.2.0.dmg
```

Expected: `dist/AccountPilot-0.2.0.dmg: The validate action worked!`

- [ ] **Step 3: Sparkle "Check for Updates…" menu test**

```bash
osascript -e 'tell application "AccountPilot" to quit' 2>/dev/null
rm -rf /Applications/AccountPilot.app
cp -R dist/AccountPilot.app /Applications/AccountPilot.app
open /Applications/AccountPilot.app
```

In the running app: open the **AccountPilot** menu (cmd+, area, top-left). Confirm "**Check for Updates…**" appears. Click it. Expected behavior depends on whether the appcast.xml has a newer entry than the installed version:
- If appcast empty (or only has the same version installed): "You're up to date" alert
- If a newer entry exists: "Update available" alert with Install button

For Phase 5's PR, the appcast on `gh-pages` is the bootstrap (no items), so the "up to date" path should fire.

- [ ] **Step 4: Commit CHANGELOG (only)**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): Phase 5 — Sparkle + DMG"
```

The DMG itself is gitignored from Phase 1 (`dist/AccountPilot-*.dmg`).

---

## Task 11: Push branch + open PR

- [ ] **Step 1: Push**

```bash
git push -u origin 0.2-p5-sparkle-dmg
```

- [ ] **Step 2: Open PR**

```bash
gh pr create --title "0.2 Phase 5: Sparkle auto-update + DMG distribution" --body "$(cat <<'EOF'
Implements Phase 5 of the 0.2 macOS .app pivot per
\`docs/plans/0.2-p5-sparkle-dmg.md\`.

## What's in here

- Sparkle 2.x via SwiftPM, embedded into AccountPilot.app/Contents/Frameworks/Sparkle.framework
- SUFeedURL + SUPublicEDKey baked into Info.plist via project.yml
- "Check for Updates…" item in the app menu (SparkleUpdatesCommand)
- \`app/scripts/build-dmg.sh\` — notarized + stapled DMG via create-dmg
- \`scripts/sign-update.sh\` — EdDSA signature for the appcast
- \`scripts/append-appcast-entry.sh\` — idempotent appcast XML editor
- \`.github/workflows/release-app.yml\` — \`v*\` tag triggers full release flow on macos-latest
- \`docs/release.md\` — one-time setup + release procedure
- \`gh-pages\` branch bootstrapped with empty appcast (separately, Task 6)

## Verification

- \`./app/scripts/build-app.sh\` ends in \`==> all verification passed\` (15 checks unchanged from Phase 3+4; Sparkle.framework's nested binaries are signed by codesign-app.sh's extended Sparkle loop)
- \`./app/scripts/build-dmg.sh\` produces a notarized + stapled DMG
- Manual: copied to /Applications, "Check for Updates…" menu item appears and shows "You're up to date" against the bootstrap appcast

## What's NOT in this PR (one-time human setup)

The user must do these once before tagging the first release (documented in docs/release.md):

1. Generate Sparkle EdDSA keypair → store private key as \`SPARKLE_PRIVATE_KEY\` GitHub secret
2. Export Developer ID Application cert as p12 → store \`APPLE_DEV_ID_CERT_P12_BASE64\` + \`APPLE_DEV_ID_CERT_PASSWORD\` secrets
3. Create App Store Connect API key → store \`APPLE_API_KEY_BASE64\` + \`APPLE_API_KEY_ID\` + \`APPLE_API_ISSUER_ID\` secrets
4. Enable GitHub Pages on the \`gh-pages\` branch

After those four are done: \`git tag v0.2.0 && git push --tags\` triggers the release workflow.

## Phase 6 next

Agent-friendly CLI hardening (--json on every read, stable exit codes, docs/cli-contract.md). Branch \`0.2-p6-cli-hardening\` after merge.
EOF
)"
```

---

## Phase 5 done when

- [ ] All 10 implementation task commits land + verification + PR (~12 commits total)
- [ ] `./app/scripts/build-app.sh` ends in `==> all verification passed` (Sparkle.framework signed correctly)
- [ ] `./app/scripts/build-dmg.sh` produces a notarized + stapled DMG locally
- [ ] Sparkle "Check for Updates…" menu item appears in the running app
- [ ] `gh-pages` branch exists with bootstrap appcast (verified via the GitHub Pages URL)
- [ ] `docs/release.md` documents all four user-side setup steps
- [ ] PR open against `main`, CI green (no new tests; existing CI suite still green), merged
- [ ] (Out of plan, by user) Tag `v0.2.0` and confirm the workflow produces a real release

## Phase 6 starts after

- Phase 5 PR merged
- (Optional but recommended) First `v0.2.0` release tag pushed and verified end-to-end
- New branch `0.2-p6-cli-hardening`
- Write `docs/plans/0.2-p6-cli-hardening.md` via `superpowers:writing-plans`
