Skip to content

Commit 2263d97

Browse files
wesmclaude
andauthored
feat: add desktop release packaging with auto-update (wesm#109)
## Summary - Add GitHub Actions workflow for building signed macOS DMG and Windows NSIS desktop installers on tag push - Add Tauri updater plugin for in-app desktop updates with native dialogs and menu bar integration - Add Go server update-check endpoint and frontend update notification UI for CLI/browser users - Add sidecar build script with version resolution, semver patching, and cross-compilation support - Add setup guide for Apple signing, notarization, and Tauri signing keys ## Desktop release CI (`.github/workflows/desktop-release.yml`) Workflow triggered on `v*` tags with three jobs: - **build-macos**: Imports Apple signing certificate into ephemeral keychain, writes App Store Connect API key, builds with full bundle targets (no `--bundles` flag) so the `.app` bundle exists for the updater `.tar.gz` to be derived from. Code signing and notarization included. - **build-windows**: Sets up MinGW, builds with `--bundles nsis` (avoids MSI version format restrictions). `createUpdaterArtifacts: "v1Compatible"` generates `.nsis.zip` + `.sig` updater bundles. - **release**: Downloads artifacts, generates `SHA256SUMS-desktop` and `latest.json` updater manifest. Uploads `.dmg` and `.exe` installers to the versioned release. Uploads updater bundles (`.app.tar.gz`, `.nsis.zip`, sigs, `latest.json`) to a permanent `updater` prerelease, keeping the user-facing release page clean. The workflow patches `tauri.conf.json` at build time via `sed` to inject the updater pubkey and correct the endpoint URL for the current repository (fork-aware via `${GITHUB_REPOSITORY}`). Build outputs are copied to a flat staging directory before `upload-artifact` to avoid nested directory structures. ## Tauri configuration - `bundle.createUpdaterArtifacts: "v1Compatible"` generates `.app.tar.gz` (macOS) and `.nsis.zip` (Windows) updater bundles alongside installers - `plugins.updater.pubkey` set to `"NOT_SET"` placeholder, patched by CI - Updater endpoint points to `releases/download/updater/latest.json` (the permanent updater prerelease) - `Entitlements.plist`: Hardened runtime entitlements for WebKit JIT ## Tauri desktop updater (`desktop/src-tauri/src/lib.rs`) - Registers `tauri-plugin-updater` and `tauri-plugin-dialog` - `option_env!("AGENTSVIEW_UPDATER_PUBKEY")` compile-time override - Menu bar "Check for Updates..." item - Auto-check 5s after startup (disabled via `AGENTSVIEW_DESKTOP_AUTOUPDATE=0`) - Native confirmation dialogs for download and restart - `AtomicBool` guard prevents concurrent update checks - Redirect URL includes `?desktop=1` for frontend context detection ## Go server update endpoint - `GET /api/v1/update/check` handler with 1-hour cache - `WithUpdateChecker()` and `WithDataDir()` server options for testing - Four deterministic tests (no network access needed) ## Frontend update notification - `UpdateModal.svelte`: current vs latest version with CLI instructions - `StatusBar.svelte`: clickable "update available" indicator - Skips update check in desktop mode (`?desktop=1`) - Tests for desktop and non-desktop paths ## Test plan - [x] Push test tag to fork, verify CI produces signed+notarized DMG, NSIS installer, updater artifacts, `latest.json`, and `SHA256SUMS-desktop` - [x] Versioned release contains only `.dmg`, `.exe`, and checksums - [x] Updater prerelease contains `.app.tar.gz`, `.nsis.zip`, sigs, and `latest.json` - [ ] `make test` -- Go tests pass - [ ] `bash desktop/scripts/test-prepare-sidecar.sh` -- sidecar tests - [ ] `cd frontend && npx vitest run` -- frontend tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d85a6c commit 2263d97

File tree

25 files changed

+2695
-33
lines changed

25 files changed

+2695
-33
lines changed
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
name: Desktop Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
build-macos:
17+
name: Desktop Build (macOS)
18+
runs-on: macos-15
19+
steps:
20+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21+
with:
22+
persist-credentials: false
23+
fetch-depth: 0
24+
25+
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
26+
with:
27+
go-version-file: go.mod
28+
29+
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
30+
with:
31+
node-version: "24"
32+
33+
- name: Install desktop dependencies
34+
run: npm ci
35+
working-directory: desktop
36+
37+
- name: Prepare sidecar
38+
env:
39+
AGENTSVIEW_VERSION: ${{ github.ref_name }}
40+
run: npm run prepare-sidecar
41+
working-directory: desktop
42+
43+
- name: Patch updater config for current repository
44+
env:
45+
AGENTSVIEW_UPDATER_PUBKEY: ${{ secrets.AGENTSVIEW_UPDATER_PUBKEY }}
46+
run: |
47+
sed -i.bak \
48+
-e "s|wesm/agentsview|${GITHUB_REPOSITORY}|g" \
49+
-e "s|NOT_SET|${AGENTSVIEW_UPDATER_PUBKEY}|g" \
50+
desktop/src-tauri/tauri.conf.json
51+
rm -f desktop/src-tauri/tauri.conf.json.bak
52+
53+
- name: Import signing certificate
54+
env:
55+
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
56+
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
57+
run: |
58+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
59+
KEYCHAIN_PASS="$(openssl rand -base64 32)"
60+
61+
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH"
62+
security set-keychain-settings -lut 3600 "$KEYCHAIN_PATH"
63+
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH"
64+
65+
CERT_PATH="$RUNNER_TEMP/certificate.p12"
66+
echo "$APPLE_CERTIFICATE" | base64 -d > "$CERT_PATH"
67+
security import "$CERT_PATH" \
68+
-k "$KEYCHAIN_PATH" \
69+
-P "$APPLE_CERTIFICATE_PASSWORD" \
70+
-T /usr/bin/codesign \
71+
-T /usr/bin/security
72+
rm -f "$CERT_PATH"
73+
74+
security set-key-partition-list \
75+
-S apple-tool:,apple: \
76+
-k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH"
77+
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
78+
79+
- name: Write API key
80+
env:
81+
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
82+
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
83+
run: |
84+
KEY_DIR="$RUNNER_TEMP/apple-keys"
85+
mkdir -p "$KEY_DIR"
86+
echo "$APPLE_API_KEY_CONTENT" | base64 -d \
87+
> "$KEY_DIR/AuthKey_${APPLE_API_KEY}.p8"
88+
echo "APPLE_API_KEY_PATH=$KEY_DIR/AuthKey_${APPLE_API_KEY}.p8" \
89+
>> "$GITHUB_ENV"
90+
91+
- name: Build DMG and updater bundle
92+
env:
93+
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
94+
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
95+
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
96+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
97+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
98+
AGENTSVIEW_UPDATER_PUBKEY: ${{ secrets.AGENTSVIEW_UPDATER_PUBKEY }}
99+
run: npx tauri build
100+
working-directory: desktop
101+
102+
- name: Collect build artifacts
103+
run: |
104+
mkdir -p staging
105+
cp desktop/src-tauri/target/release/bundle/dmg/*.dmg staging/
106+
cp desktop/src-tauri/target/release/bundle/macos/*.app.tar.gz staging/
107+
cp desktop/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig staging/
108+
ls -la staging/
109+
110+
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
111+
with:
112+
name: agentsview-desktop-macos
113+
path: staging/*
114+
if-no-files-found: error
115+
116+
- name: Cleanup signing secrets
117+
if: always()
118+
run: |
119+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
120+
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
121+
rm -rf "$RUNNER_TEMP/apple-keys" 2>/dev/null || true
122+
123+
build-windows:
124+
name: Desktop Build (Windows)
125+
runs-on: windows-latest
126+
steps:
127+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
128+
with:
129+
persist-credentials: false
130+
fetch-depth: 0
131+
132+
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
133+
with:
134+
go-version-file: go.mod
135+
136+
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
137+
with:
138+
node-version: "24"
139+
140+
- name: Setup MinGW
141+
uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
142+
with:
143+
msystem: MINGW64
144+
update: false
145+
install: mingw-w64-x86_64-gcc
146+
path-type: inherit
147+
148+
- name: Install desktop dependencies
149+
run: npm ci
150+
working-directory: desktop
151+
152+
- name: Prepare sidecar
153+
shell: bash
154+
env:
155+
AGENTSVIEW_VERSION: ${{ github.ref_name }}
156+
run: npm run prepare-sidecar
157+
working-directory: desktop
158+
159+
- name: Patch updater config for current repository
160+
shell: bash
161+
env:
162+
AGENTSVIEW_UPDATER_PUBKEY: ${{ secrets.AGENTSVIEW_UPDATER_PUBKEY }}
163+
run: |
164+
sed -i.bak \
165+
-e "s|wesm/agentsview|${GITHUB_REPOSITORY}|g" \
166+
-e "s|NOT_SET|${AGENTSVIEW_UPDATER_PUBKEY}|g" \
167+
desktop/src-tauri/tauri.conf.json
168+
rm -f desktop/src-tauri/tauri.conf.json.bak
169+
170+
- name: Build NSIS installer and updater bundle
171+
shell: bash
172+
env:
173+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
174+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
175+
AGENTSVIEW_UPDATER_PUBKEY: ${{ secrets.AGENTSVIEW_UPDATER_PUBKEY }}
176+
run: npx tauri build --bundles nsis
177+
working-directory: desktop
178+
179+
- name: Collect build artifacts
180+
shell: bash
181+
run: |
182+
mkdir -p staging
183+
cp desktop/src-tauri/target/release/bundle/nsis/*.exe staging/
184+
cp desktop/src-tauri/target/release/bundle/nsis/*.nsis.zip staging/
185+
cp desktop/src-tauri/target/release/bundle/nsis/*.nsis.zip.sig staging/
186+
ls -la staging/
187+
188+
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
189+
with:
190+
name: agentsview-desktop-windows
191+
path: staging/*
192+
if-no-files-found: error
193+
194+
release:
195+
name: Upload Desktop Installers
196+
needs: [build-macos, build-windows]
197+
runs-on: ubuntu-latest
198+
permissions:
199+
contents: write
200+
steps:
201+
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
202+
with:
203+
path: artifacts
204+
pattern: agentsview-desktop-*
205+
merge-multiple: true
206+
207+
- name: Generate checksums
208+
run: |
209+
cd artifacts
210+
sha256sum *.dmg *.exe *.tar.gz *.nsis.zip > SHA256SUMS-desktop 2>/dev/null || true
211+
cat SHA256SUMS-desktop
212+
213+
- name: Generate updater manifest
214+
run: |
215+
set -euo pipefail
216+
VERSION="${GITHUB_REF_NAME#v}"
217+
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
218+
TAG="updater"
219+
220+
MACOS_SIG=""
221+
MACOS_URL=""
222+
WINDOWS_SIG=""
223+
WINDOWS_URL=""
224+
225+
for f in artifacts/*.app.tar.gz.sig; do
226+
if [ -f "$f" ]; then
227+
MACOS_SIG="$(cat "$f")"
228+
MACOS_TARBALL="$(basename "${f%.sig}")"
229+
MACOS_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${MACOS_TARBALL}"
230+
fi
231+
done
232+
233+
for f in artifacts/*.nsis.zip.sig; do
234+
if [ -f "$f" ]; then
235+
WINDOWS_SIG="$(cat "$f")"
236+
NSIS_ZIP="$(basename "${f%.sig}")"
237+
WINDOWS_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${NSIS_ZIP}"
238+
fi
239+
done
240+
241+
if [ -z "$MACOS_SIG" ] || [ -z "$MACOS_URL" ]; then
242+
echo "error: missing macOS updater signature or URL" >&2
243+
exit 1
244+
fi
245+
if [ -z "$WINDOWS_SIG" ] || [ -z "$WINDOWS_URL" ]; then
246+
echo "error: missing Windows updater signature or URL" >&2
247+
exit 1
248+
fi
249+
250+
cat > artifacts/latest.json << MANIFEST
251+
{
252+
"version": "${VERSION}",
253+
"pub_date": "${DATE}",
254+
"platforms": {
255+
"darwin-aarch64": {
256+
"url": "${MACOS_URL}",
257+
"signature": "${MACOS_SIG}"
258+
},
259+
"windows-x86_64": {
260+
"url": "${WINDOWS_URL}",
261+
"signature": "${WINDOWS_SIG}"
262+
}
263+
}
264+
}
265+
MANIFEST
266+
267+
echo "Generated latest.json:"
268+
cat artifacts/latest.json
269+
270+
- name: Upload installers to versioned release
271+
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
272+
with:
273+
files: |
274+
artifacts/*.dmg
275+
artifacts/*.exe
276+
artifacts/SHA256SUMS-desktop
277+
278+
- name: Upload updater artifacts
279+
env:
280+
GH_TOKEN: ${{ github.token }}
281+
run: |
282+
set -euo pipefail
283+
# Create or update the permanent 'updater' release
284+
if ! gh release view updater --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
285+
gh release create updater \
286+
--repo "$GITHUB_REPOSITORY" \
287+
--title "Auto-Updater Artifacts" \
288+
--notes "Internal release used by the desktop auto-updater. Do not delete." \
289+
--prerelease
290+
fi
291+
# Overwrite existing assets
292+
gh release upload updater \
293+
--repo "$GITHUB_REPOSITORY" \
294+
--clobber \
295+
artifacts/*.app.tar.gz \
296+
artifacts/*.app.tar.gz.sig \
297+
artifacts/*.nsis.zip \
298+
artifacts/*.nsis.zip.sig \
299+
artifacts/latest.json

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ make vet # go vet
9797
- Tests should be fast and isolated
9898
- No emojis in code or output
9999
- **The database is a persistent archive** — never drop, truncate, or recreate the database to handle data version changes. Use non-destructive migrations (ALTER TABLE, UPDATE) and full resync (build fresh DB, copy orphaned data, swap) instead. Session data must survive even when the original source files are gone.
100+
- **Markdown formatting**: Use `mdformat --wrap 80` to format Markdown files. Requires the `mdformat-tables` plugin (`uv tool install mdformat --with mdformat-tables`).
100101

101102
## Git Workflow
102103

Makefile

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ LDFLAGS := -X main.version=$(VERSION) \
1111
LDFLAGS_RELEASE := $(LDFLAGS) -s -w
1212
DESKTOP_DIST_DIR := dist/desktop
1313

14-
.PHONY: build build-release install frontend frontend-dev dev desktop-dev desktop-build desktop-macos-app desktop-windows-installer desktop-app test test-short e2e vet lint tidy clean release release-darwin-arm64 release-darwin-amd64 release-linux-amd64 install-hooks ensure-embed-dir help
14+
.PHONY: build build-release install frontend frontend-dev dev desktop-dev desktop-build desktop-macos-app desktop-macos-dmg desktop-windows-installer desktop-app test test-short e2e vet lint tidy clean release release-darwin-arm64 release-darwin-amd64 release-linux-amd64 install-hooks ensure-embed-dir help
1515

1616
# Ensure go:embed has at least one file (no-op if frontend is built)
1717
ensure-embed-dir:
@@ -76,6 +76,22 @@ desktop-macos-app:
7676
$(DESKTOP_DIST_DIR)/macos/AgentsView.app
7777
@echo "macOS app bundle copied to $(DESKTOP_DIST_DIR)/macos/AgentsView.app"
7878

79+
# Build macOS DMG installer
80+
desktop-macos-dmg:
81+
cd desktop && npm install && npm run tauri:build:macos-dmg
82+
mkdir -p $(DESKTOP_DIST_DIR)/macos
83+
rm -f $(DESKTOP_DIST_DIR)/macos/*.dmg
84+
@dmg_count=$$(find desktop/src-tauri/target/release/bundle/dmg \
85+
-maxdepth 1 -type f -name '*.dmg' | wc -l | tr -d ' '); \
86+
if [ "$$dmg_count" -eq 0 ]; then \
87+
echo "error: no DMG installer found in bundle output" >&2; \
88+
exit 1; \
89+
fi; \
90+
find desktop/src-tauri/target/release/bundle/dmg \
91+
-maxdepth 1 -type f -name '*.dmg' \
92+
-exec cp {} $(DESKTOP_DIST_DIR)/macos/ \;; \
93+
echo "Copied $$dmg_count DMG installer(s) to $(DESKTOP_DIST_DIR)/macos/"
94+
7995
# Build Windows NSIS installer bundle (.exe)
8096
# Run on Windows runner/host.
8197
desktop-windows-installer:
@@ -180,6 +196,7 @@ help:
180196
@echo " desktop-dev - Run Tauri desktop wrapper in dev mode"
181197
@echo " desktop-build - Build Tauri desktop app bundles"
182198
@echo " desktop-macos-app - Build macOS .app bundle only"
199+
@echo " desktop-macos-dmg - Build macOS DMG installer"
183200
@echo " desktop-windows-installer - Build Windows NSIS installer"
184201
@echo " desktop-app - Alias for desktop-macos-app"
185202
@echo ""

cmd/agentsview/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ func runServe(args []string) {
198198
Commit: commit,
199199
BuildDate: buildDate,
200200
}),
201+
server.WithDataDir(cfg.DataDir),
201202
)
202203

203204
url := fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)

desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"tauri:dev": "npm run prepare-sidecar && tauri dev",
99
"tauri:build": "npm run prepare-sidecar && tauri build",
1010
"tauri:build:macos-app": "npm run prepare-sidecar && tauri build --bundles app",
11+
"tauri:build:macos-dmg": "npm run prepare-sidecar && tauri build --bundles dmg",
1112
"tauri:build:windows": "npm run prepare-sidecar && tauri build --bundles nsis",
1213
"tauri": "tauri"
1314
},

0 commit comments

Comments
 (0)