Skip to content

Commit 71a5e9c

Browse files
committed
✨ Add Sparkle updater and automated appcast publishing
Wire Sparkle update checks into the menubar app with feed URL fallback and app command integration.\nAdd a GitHub Actions workflow that generates and uploads appcast.xml from release zip assets using Sparkle signing keys.\nDocument one-time setup and release flow for manual signed binaries with automated appcast updates.
1 parent e861142 commit 71a5e9c

4 files changed

Lines changed: 276 additions & 5 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
name: Update Sparkle Appcast
2+
3+
on:
4+
release:
5+
types: [published, edited]
6+
workflow_dispatch:
7+
inputs:
8+
release_tag:
9+
description: "Release tag (for example: v1.2.3). Leave empty to use latest release."
10+
required: false
11+
type: string
12+
asset_regex:
13+
description: "Regex used to find release archives."
14+
required: false
15+
default: "Vizzly.*\\.zip$"
16+
type: string
17+
18+
permissions:
19+
contents: write
20+
21+
jobs:
22+
appcast:
23+
runs-on: macos-15
24+
env:
25+
SPARKLE_VERSION: "2.8.1"
26+
DEFAULT_ASSET_REGEX: "Vizzly.*\\.zip$"
27+
steps:
28+
- name: Resolve release and select archive
29+
id: resolve
30+
env:
31+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32+
INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }}
33+
INPUT_ASSET_REGEX: ${{ github.event.inputs.asset_regex }}
34+
EVENT_NAME: ${{ github.event_name }}
35+
EVENT_RELEASE_URL: ${{ github.event.release.url }}
36+
run: |
37+
set -euo pipefail
38+
39+
asset_regex="${INPUT_ASSET_REGEX:-$DEFAULT_ASSET_REGEX}"
40+
if [ -z "$asset_regex" ]; then
41+
asset_regex="$DEFAULT_ASSET_REGEX"
42+
fi
43+
44+
if [ "$EVENT_NAME" = "release" ] && [ -n "${EVENT_RELEASE_URL:-}" ]; then
45+
release_json="$(gh api "$EVENT_RELEASE_URL")"
46+
elif [ -n "${INPUT_RELEASE_TAG:-}" ]; then
47+
release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$INPUT_RELEASE_TAG")"
48+
else
49+
release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest")"
50+
fi
51+
52+
tag_name="$(echo "$release_json" | jq -r '.tag_name')"
53+
release_id="$(echo "$release_json" | jq -r '.id')"
54+
55+
archive_name="$(
56+
echo "$release_json" \
57+
| jq -r --arg re "$asset_regex" '
58+
.assets
59+
| map(select(.name | test($re)))
60+
| sort_by(.updated_at)
61+
| last
62+
| .name // empty
63+
'
64+
)"
65+
66+
if [ -z "$archive_name" ]; then
67+
echo "No release asset matched regex: $asset_regex"
68+
echo "Upload your signed ZIP first, then rerun this workflow."
69+
exit 1
70+
fi
71+
72+
download_url="$(
73+
echo "$release_json" \
74+
| jq -r --arg name "$archive_name" '.assets[] | select(.name == $name) | .browser_download_url'
75+
)"
76+
77+
if [ -z "$download_url" ]; then
78+
echo "Matched asset has no download URL."
79+
exit 1
80+
fi
81+
82+
mkdir -p artifacts
83+
curl -fL --retry 3 --retry-delay 2 -o "artifacts/$archive_name" "$download_url"
84+
85+
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
86+
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
87+
echo "archive_name=$archive_name" >> "$GITHUB_OUTPUT"
88+
89+
- name: Install Sparkle tools
90+
run: |
91+
set -euo pipefail
92+
curl -fL --retry 3 --retry-delay 2 \
93+
-o sparkle.tar.xz \
94+
"https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz"
95+
tar -xJf sparkle.tar.xz
96+
generate_appcast_path="$(find . -type f -path "*/bin/generate_appcast" | head -n 1)"
97+
if [ -z "$generate_appcast_path" ]; then
98+
echo "Could not find generate_appcast in Sparkle distribution."
99+
exit 1
100+
fi
101+
chmod +x "$generate_appcast_path"
102+
echo "GENERATE_APPCAST=$generate_appcast_path" >> "$GITHUB_ENV"
103+
104+
- name: Generate appcast.xml
105+
env:
106+
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
107+
TAG_NAME: ${{ steps.resolve.outputs.tag_name }}
108+
ARCHIVE_NAME: ${{ steps.resolve.outputs.archive_name }}
109+
run: |
110+
set -euo pipefail
111+
if [ -z "${SPARKLE_PRIVATE_KEY:-}" ]; then
112+
echo "Missing SPARKLE_PRIVATE_KEY secret."
113+
exit 1
114+
fi
115+
116+
printf "%s" "$SPARKLE_PRIVATE_KEY" > sparkle_private_key
117+
chmod 600 sparkle_private_key
118+
119+
"$GENERATE_APPCAST" \
120+
--ed-key-file sparkle_private_key \
121+
artifacts
122+
123+
if [ ! -f artifacts/appcast.xml ]; then
124+
echo "generate_appcast did not produce artifacts/appcast.xml"
125+
exit 1
126+
fi
127+
128+
python3 - <<'PY'
129+
from pathlib import Path
130+
import xml.etree.ElementTree as ET
131+
import os
132+
133+
appcast_path = Path("artifacts/appcast.xml")
134+
tree = ET.parse(appcast_path)
135+
root = tree.getroot()
136+
137+
archive_name = os.environ["ARCHIVE_NAME"]
138+
tag_name = os.environ["TAG_NAME"]
139+
repo = os.environ["GITHUB_REPOSITORY"]
140+
download_url = f"https://github.com/{repo}/releases/download/{tag_name}/{archive_name}"
141+
142+
for enclosure in root.findall(".//enclosure"):
143+
enclosure.set("url", download_url)
144+
145+
tree.write(appcast_path, encoding="utf-8", xml_declaration=True)
146+
PY
147+
148+
- name: Upload appcast.xml to release
149+
env:
150+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
151+
TAG_NAME: ${{ steps.resolve.outputs.tag_name }}
152+
run: |
153+
set -euo pipefail
154+
gh release upload "$TAG_NAME" artifacts/appcast.xml --clobber

README.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ A native macOS menubar app for managing [Vizzly](https://vizzly.dev) TDD servers
1414

1515
### Direct Download
1616

17-
Download the latest release from [GitHub Releases](https://github.com/vizzly-testing/vizzly-menubar/releases).
17+
Download the latest release from [GitHub Releases](https://github.com/vizzly-testing/menubar/releases).
1818

1919
### Homebrew
2020

@@ -31,17 +31,42 @@ brew install --cask vizzly
3131

3232
```bash
3333
# Clone the repo
34-
git clone https://github.com/vizzly-testing/vizzly-menubar.git
35-
cd vizzly-menubar
34+
git clone https://github.com/vizzly-testing/menubar.git
35+
cd menubar
3636

3737
# Open in Xcode
38-
open Vizzly.xcodeproj
38+
open Vizzly/Vizzly.xcodeproj
3939
```
4040

4141
## How It Works
4242

4343
The menubar app watches `~/.vizzly/servers.json` for running TDD servers and monitors project/log files for live updates. It spawns CLI commands for server lifecycle management.
4444

45+
## Sparkle Updates
46+
47+
Vizzly uses [Sparkle](https://sparkle-project.org/) for in-app updates.
48+
49+
### One-time setup
50+
51+
1. Generate Sparkle keys locally (`generate_keys` from Sparkle tools).
52+
2. Add `SUPublicEDKey` in the Vizzly app target build settings (Info.plist key).
53+
3. Add a GitHub Actions secret named `SPARKLE_PRIVATE_KEY` with the full private key contents.
54+
55+
### Release flow (manual binary, automated appcast)
56+
57+
1. Build/sign/notarize `Vizzly.app` locally.
58+
2. Zip the signed app (for example `Vizzly-1.0.0.zip`).
59+
3. Create/publish a GitHub release with a tag (for example `v1.0.0`).
60+
4. Upload the signed `.zip` asset to that release.
61+
5. GitHub Actions workflow `.github/workflows/update-appcast.yml` will:
62+
- find the uploaded zip
63+
- generate and sign `appcast.xml`
64+
- upload `appcast.xml` to the same release
65+
66+
Sparkle feed URL in the app points to:
67+
68+
`https://github.com/vizzly-testing/menubar/releases/latest/download/appcast.xml`
69+
4570
See [PLAN.md](./PLAN.md) for detailed architecture documentation.
4671

4772
## License

Vizzly/Vizzly.xcodeproj/project.pbxproj

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
objectVersion = 77;
77
objects = {
88

9+
/* Begin PBXBuildFile section */
10+
80BCE4232F3DA45800D91F91 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 80BCE4222F3DA45800D91F91 /* Sparkle */; };
11+
/* End PBXBuildFile section */
12+
913
/* Begin PBXContainerItemProxy section */
1014
80BCD08E2F2994E200D91F91 /* PBXContainerItemProxy */ = {
1115
isa = PBXContainerItemProxy;
@@ -52,6 +56,7 @@
5256
isa = PBXFrameworksBuildPhase;
5357
buildActionMask = 2147483647;
5458
files = (
59+
80BCE4232F3DA45800D91F91 /* Sparkle in Frameworks */,
5560
);
5661
runOnlyForDeploymentPostprocessing = 0;
5762
};
@@ -112,6 +117,7 @@
112117
);
113118
name = Vizzly;
114119
packageProductDependencies = (
120+
80BCE4222F3DA45800D91F91 /* Sparkle */,
115121
);
116122
productName = Vizzly;
117123
productReference = 80BCD0802F2994E100D91F91 /* Vizzly.app */;
@@ -195,6 +201,9 @@
195201
);
196202
mainGroup = 80BCD0772F2994E100D91F91;
197203
minimizedProjectReferenceProxies = 1;
204+
packageReferences = (
205+
80BCE4212F3DA45800D91F91 /* XCRemoteSwiftPackageReference "Sparkle" */,
206+
);
198207
preferredProjectObjectVersion = 77;
199208
productRefGroup = 80BCD0812F2994E100D91F91 /* Products */;
200209
projectDirPath = "";
@@ -403,6 +412,8 @@
403412
ENABLE_PREVIEWS = YES;
404413
GENERATE_INFOPLIST_FILE = YES;
405414
INFOPLIST_KEY_LSUIElement = YES;
415+
INFOPLIST_KEY_SUPublicEDKey = "UpG9BqC+dujGiVPbWHFVvNu23jlpEqf+EMrv+Dj6cFw=";
416+
INFOPLIST_KEY_SUFeedURL = "https://github.com/vizzly-testing/menubar/releases/latest/download/appcast.xml";
406417
INFOPLIST_KEY_NSHumanReadableCopyright = "";
407418
LD_RUNPATH_SEARCH_PATHS = (
408419
"$(inherited)",
@@ -435,6 +446,8 @@
435446
ENABLE_PREVIEWS = YES;
436447
GENERATE_INFOPLIST_FILE = YES;
437448
INFOPLIST_KEY_LSUIElement = YES;
449+
INFOPLIST_KEY_SUPublicEDKey = "UpG9BqC+dujGiVPbWHFVvNu23jlpEqf+EMrv+Dj6cFw=";
450+
INFOPLIST_KEY_SUFeedURL = "https://github.com/vizzly-testing/menubar/releases/latest/download/appcast.xml";
438451
INFOPLIST_KEY_NSHumanReadableCopyright = "";
439452
LD_RUNPATH_SEARCH_PATHS = (
440453
"$(inherited)",
@@ -573,6 +586,25 @@
573586
defaultConfigurationName = Release;
574587
};
575588
/* End XCConfigurationList section */
589+
590+
/* Begin XCRemoteSwiftPackageReference section */
591+
80BCE4212F3DA45800D91F91 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
592+
isa = XCRemoteSwiftPackageReference;
593+
repositoryURL = "https://github.com/sparkle-project/Sparkle";
594+
requirement = {
595+
kind = upToNextMajorVersion;
596+
minimumVersion = 2.8.1;
597+
};
598+
};
599+
/* End XCRemoteSwiftPackageReference section */
600+
601+
/* Begin XCSwiftPackageProductDependency section */
602+
80BCE4222F3DA45800D91F91 /* Sparkle */ = {
603+
isa = XCSwiftPackageProductDependency;
604+
package = 80BCE4212F3DA45800D91F91 /* XCRemoteSwiftPackageReference "Sparkle" */;
605+
productName = Sparkle;
606+
};
607+
/* End XCSwiftPackageProductDependency section */
576608
};
577609
rootObject = 80BCD0782F2994E100D91F91 /* Project object */;
578610
}

Vizzly/Vizzly/VizzlyApp.swift

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import ServiceManagement
9+
import Sparkle
910
import SwiftUI
1011

1112
// MARK: - Design Tokens
@@ -25,10 +26,11 @@ extension Color {
2526
@main
2627
struct VizzlyApp: App {
2728
@StateObject private var serverManager = ServerManager()
29+
private let appUpdater = AppUpdater()
2830

2931
var body: some Scene {
3032
MenuBarExtra {
31-
PanelView(serverManager: serverManager)
33+
PanelView(serverManager: serverManager, appUpdater: appUpdater)
3234
} label: {
3335
MenuBarLabel(serverManager: serverManager)
3436
}
@@ -44,6 +46,53 @@ struct VizzlyApp: App {
4446
}
4547
}
4648
.windowResizability(.contentSize)
49+
.commands {
50+
CommandGroup(after: .appInfo) {
51+
Button("Check for Updates…") {
52+
appUpdater.checkForUpdates()
53+
}
54+
}
55+
}
56+
}
57+
}
58+
59+
@MainActor
60+
final class AppUpdater: NSObject {
61+
private let updaterController: SPUStandardUpdaterController
62+
private let updaterDelegate: FeedURLUpdaterDelegate
63+
64+
override init() {
65+
self.updaterDelegate = FeedURLUpdaterDelegate(
66+
fallbackFeedURLString: "https://github.com/vizzly-testing/menubar/releases/latest/download/appcast.xml"
67+
)
68+
self.updaterController = SPUStandardUpdaterController(
69+
startingUpdater: true,
70+
updaterDelegate: updaterDelegate,
71+
userDriverDelegate: nil
72+
)
73+
super.init()
74+
updaterController.updater.automaticallyChecksForUpdates = false
75+
}
76+
77+
func checkForUpdates() {
78+
NSApp.activate(ignoringOtherApps: true)
79+
updaterController.checkForUpdates(nil)
80+
}
81+
}
82+
83+
private final class FeedURLUpdaterDelegate: NSObject, SPUUpdaterDelegate {
84+
private let fallbackFeedURLString: String
85+
86+
init(fallbackFeedURLString: String) {
87+
self.fallbackFeedURLString = fallbackFeedURLString
88+
}
89+
90+
func feedURLString(for updater: SPUUpdater) -> String? {
91+
if let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String,
92+
!feedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
93+
return feedURL
94+
}
95+
return fallbackFeedURLString
4796
}
4897
}
4998

@@ -86,6 +135,7 @@ struct MenuBarLabel: View {
86135

87136
struct PanelView: View {
88137
@ObservedObject var serverManager: ServerManager
138+
let appUpdater: AppUpdater
89139
@Environment(\.openSettings) private var openSettings
90140

91141
var body: some View {
@@ -260,6 +310,16 @@ struct PanelView: View {
260310

261311
Spacer()
262312

313+
Button {
314+
appUpdater.checkForUpdates()
315+
} label: {
316+
Image(systemName: "arrow.down.circle")
317+
.font(.system(size: 11, weight: .medium))
318+
.frame(width: 28, height: 24)
319+
}
320+
.buttonStyle(FooterButtonStyle())
321+
.help("Check for Updates…")
322+
263323
Button {
264324
NSApp.activate(ignoringOtherApps: true)
265325
openSettings()

0 commit comments

Comments
 (0)