Desktop build & release pipeline — design spec
Status: Approved · Author: Claude (with Christophe) · Date: 2026-07-22 · Repo: north-os (apps/polaris, apps/polaris-win, apps/web, .github/workflows)
Related: apps/polaris/scripts/release.sh (legacy local pipeline), .github/workflows/release-desktop.yml (rewritten by this spec), polaris-win.yml, polaris-mac.yml
Decision
Both desktop apps (Polaris macOS, Polaris Windows) build, sign, and package entirely on GitHub Actions hosted runners. A release is triggered by pushing one git tag (desktop-v<version>); both platforms release in lockstep under one shared version number. Artifacts and auto-update feeds are served from a public Cloudflare R2 bucket behind updates.heynorth.dev. A latest.json manifest published alongside the feeds drives a new authenticated /downloads page in apps/web.
Current state (why this spec exists)
- Windows already builds fully on CI (
polaris-win.yml: self-containeddotnet publish→ Velopack → rolling GitHub Releasepolaris-win-latest), but nothing moves those artifacts to the update origin the app actually polls. - Mac has only a CI compile check; the real release is
release.shrun manually on Christophe's Mac because the Developer ID cert, notarization credentials, and Sparkle EdDSA key live in his Keychain. - Distribution is broken three ways:
updates.heynorth.devDNS points at195.154.103.206while Caddy serves it from cosmic (51.159.97.50) — TLS fails and the feed is unreachable; both/var/www/updates/*feed dirs are empty; and two conflicting origin designs coexist (R2 inrelease-desktop.ymlvs Caddy static inrelease.sh). - The checked-in
appcast.xmlstill carries legacymakesomething.dmgitems. - No download surface exists in
apps/web.
1 · Origin — R2 behind updates.heynorth.dev
One public R2 bucket (north-updates) with custom domain updates.heynorth.dev (Cloudflare R2 custom-domain routing; this replaces the broken DNS record). Bucket layout:
polaris-mac/appcast.xml Sparkle feed
polaris-mac/North-<version>.dmg versioned DMGs (no more single-file overwrite)
polaris-win/RELEASES Velopack feed index
polaris-win/*.nupkg, NorthSetup.exe Velopack packages + installer
latest.json manifest for the web download page
staging/<ref>/... dry-run output, never polled by apps
latest.json shape:
{
"version": "1.7",
"pubDate": "2026-07-22T10:00:00Z",
"notes": "one-paragraph release notes",
"mac": { "url": "https://updates.heynorth.dev/polaris-mac/North-1.7.dmg", "size": 11852050, "minOS": "14.0" },
"win": { "url": "https://updates.heynorth.dev/polaris-win/NorthSetup.exe", "size": 89000000 }
}
Retention: the publish job keeps the last 5 DMG versions and lets Velopack manage its own package set; older objects are pruned during publish.
2 · Release workflow — release-desktop.yml (rewritten)
Trigger: push of tag desktop-v* (e.g. desktop-v1.7), plus workflow_dispatch with inputs version and dry_run.
Job mac (macos-latest)
- Import the Developer ID
.p12into an ephemeral keychain (created and destroyed within the job). xcodebuild archivewithMARKETING_VERSIONfrom the tag andCURRENT_PROJECT_VERSION= monotonically increasing build number (GitHub run number offset past the current appcast build).- Export Developer-ID-signed
.app; notarize with an App Store Connect API key vianotarytool(no interactive keychain profile). create-dmgwith the existing drag-to-Applications background; notarize + staple the DMG.- Sparkle:
sign_updatewith the EdDSA private key from a secret (passed via file, never echoed); download the currentappcast.xmlfrom R2 and prepend the new item (bootstrap case: start a fresh appcast if none exists or only legacymakesomethingitems are present — those are dropped). - Upload DMG + new appcast as job artifacts only — this job never touches R2.
Job win (windows-latest)
dotnet test(meeting-detection logic) thendotnet publishself-contained win-x64,-p:Version=<tag version>.<run_number>.vpk downloadthe previous release from the R2 feed (S3-compatible endpoint) sovpk packemits delta packages — users patch instead of re-downloading.- Upload the Velopack output as job artifacts only.
Job publish (ubuntu-latest, needs: [mac, win])
- Download both artifact sets.
- Single
aws s3 syncpush of everything (both feeds + regeneratedlatest.json) to R2 — tostaging/<ref>/whendry_run, live prefixes otherwise. - Prune old versions per retention policy.
The single publish job is the lockstep guarantee: if either platform build fails, nothing ships, and the feeds can never desync. A failed run is retried by deleting and re-pushing the same tag after the fix.
3 · Versioning
Tag desktop-v1.7 → marketing version 1.7 on both apps. Mac build number and Windows fourth version segment derive from the workflow run number, so re-runs are always monotonic. The tag is the single source of truth; appcast.xml no longer drives version detection.
4 · Existing workflows — disposition
| Workflow | Fate |
|---|---|
polaris-mac.yml | Kept as-is (unsigned compile check on Polaris paths). |
polaris-win.yml | Kept for CI + the polaris-win-dev portable branch builds. The polaris-win-latest rolling release stops being the auto-update channel (step removed); the channel is now tag-driven R2. |
release-desktop.yml | Fully rewritten per §2 (the current "promote staged artifacts" flow assumed humans stage signed builds — that assumption dies). |
release.sh | Kept as documented local fallback, retargeted to upload to R2 instead of rsync-to-Caddy. |
Caddy updates-heynorth.caddyfile + /var/www/updates on cosmic | Decommissioned once R2 serves the domain. |
5 · Download page — apps/web /downloads
Authenticated route (confidential project — not public). Server-side fetch of https://updates.heynorth.dev/latest.json, cached ~5 minutes. Two cards (macOS: DMG, "requires macOS 14+"; Windows: NorthSetup.exe) showing version, release date, and notes, with direct download buttons. Linked from the user menu / settings. Follows DESIGN.md (semantic tokens, card surface ladder). Graceful degradation: if latest.json is unreachable, the page renders with an "updates feed unavailable" state rather than erroring.
6 · Secrets (one-time provisioning — the only manual part)
| Secret | Source |
|---|---|
APPLE_CERT_P12 (base64) + APPLE_CERT_PASSWORD | Export Developer ID Application cert from Keychain |
ASC_API_KEY_P8 + ASC_KEY_ID + ASC_ISSUER_ID | New App Store Connect API key (Developer role) |
SPARKLE_ED_PRIVATE_KEY | Export from Keychain (generate_keys -x) |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_UPDATES_BUCKET | Cloudflare dashboard (some may already exist for release-desktop.yml) |
Cloudflare dashboard (manual): create north-updates bucket, attach custom domain updates.heynorth.dev (replaces the stale A record).
Accounts: no new paid account. GitHub-hosted runners are included with the repo (macOS bills at 10× multiplier — a release run ≈ 200 billed minutes, a handful per month fits the included quota). The Apple Developer Program ($99/yr) is already held — its cert is what release.sh signs with today.
Verification
- Signing/notarization failures fail the run before anything publishes (artifacts-then-publish ordering).
workflow_dispatchwithdry_run: trueexercises the full build + sign + notarize + package path and publishes tostaging/— end-to-end rehearsal without touching live feeds.- First real release validated by: Sparkle update check from an older installed build (Mac), Velopack
--checkForUpdate(Windows), and a manual download of both installers from/downloads.
Out of scope
- Windows code-signing certificate (Authenticode / EV). Velopack installs unsigned today; SmartScreen warnings are accepted for now. A follow-up spec can add Azure Trusted Signing.
- Public marketing download page (the
/downloadsroute is authenticated-only). - Changelog automation beyond the one-paragraph
notesfield (the/whats-newsystem stays separate).