A Swift library that drives an in-app auto-update flow for macOS apps distributed via GitHub Releases outside the Mac App Store.
Platform & Stack
CI Checks & Review
- Features
- Installation
- Caveats
- Flow
- Minimal host app
- Cache
- Background check interval
- Key pair setup
- Distribution assumptions
- Trust model
- Release channels
- Known limitations
- Design principles
- Comparison
- 🔓 No Apple Developer account required — works with unsigned and ad-hoc signed apps; Ed25519 signature verification provides integrity and authenticity without code signing
- 🛡️ Gatekeeper-free distribution — curl-based install and in-app updates both bypass quarantine; no signing at update time, the replacement bundle runs trusted as-is
- 🔍 GitHub Releases polling — polls for new releases using the GitHub API; supports stable and
beta.Npre-release channels - 🔢 Semver comparison — full semver ordering including
beta.Nsuffixes; beta-to-beta and beta-to-stable promotion both work correctly - ✅ Ed25519 signature verification — verifies the downloaded zip against a
.sigsidecar asset usingCryptoKit.Curve25519.Signingbefore install; covers integrity, authenticity, and MITM resistance - 🔏 Optional code-sign validation — verifies the downloaded bundle matches the running bundle's Developer ID identity
- 💾 Deterministic cache — zip cached at
~/Library/Caches/<schedulerIdentifier>/update.zip; no accumulating old downloads - ⏰ Background scheduling — uses
NSBackgroundActivitySchedulerwith power coalescing; default 24-hour interval - 🎨 Bring-your-own UI — host app owns all update state via
UpdateStateProviding; surfaces it however it likes - 🏕️
@MainActorisolated — race-free by design; blocking work runs off the main thread
Add the dependency to your Package.swift:
.package(url: "https://github.com/runbot-hq/run-bot", branch: "main"),Then add the product to your target:
.product(name: "AppUpdater", package: "run-bot")Pin to a commit for reproducible builds. For production use, pin to a specific commit SHA:
.package(url: "https://github.com/runbot-hq/run-bot", revision: "<commit-sha>"),
- macOS 26+ only
- Sandboxed apps are not supported
- GitHub Releases as the distribution source (no other providers)
- No built-in UI — the host owns all update state and surfaces it however it likes
GitHub Releases poll → semver compare (incl. beta.N) → zip + .sig download
→ Ed25519 signature verification → cache → host state mutation
→ install & relaunch on user confirmation
import AppKit
import AppUpdater
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
@Observable
final class UpdateState: UpdateStateProviding {
private(set) var currentPhase: UpdatePhase = .idle
func apply(_ phase: UpdatePhase) { currentPhase = phase }
}
let updateState = UpdateState()
let updater = AppUpdater(
repo: "your-org/your-repo",
currentVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
assetName: { _ in "YourApp.zip" }, // .sig sidecar expected at "YourApp.zip.sig"
publicKey: Data(base64Encoded: "<your-32-byte-ed25519-public-key-base64>")!, // force-unwrap is intentional: a bad key is a programmer error and should crash at launch, not silently fail at update time
schedulerIdentifier: "com.your-org.update-check" // also scopes the on-disk cache directory
// betaChannelProvider: { false } // optional — defaults to stable channel only
)
func applicationDidFinishLaunching(_ notification: Notification) {
Task { await updater.checkAndHandle(state: updateState) }
updater.scheduleBackgroundCheck(state: updateState)
}
}
struct UpdateRow: View {
let state: AppDelegate.UpdateState
let updater: AppUpdater
var body: some View {
switch state.currentPhase {
case .idle:
EmptyView()
case .available(let version):
Text("Update \(version) found, downloading…")
case .downloading(let version):
Text("Downloading \(version)…")
case .ready(let version):
VStack {
Text("\(version) ready to install")
Button("Install & Relaunch") {
Task { await updater.installAndRelaunch(state: state) }
}
}
case .failed:
Button("Retry") {
Task { await updater.checkAndHandle(state: state) }
}
}
}
}The verified zip is written to:
~/Library/Caches/<schedulerIdentifier>/update.zip
The path is deterministic from schedulerIdentifier alone. The file is deleted at the start of each new download and on successful install.
AppUpdater.checkInterval // default: 86400 (24 hours)Mutate before calling scheduleBackgroundCheck if you need a different cadence.
Test isolation: Always restore the original value in a
tearDownblock when mutatingcheckIntervalin a test — Swift Testing runs cases concurrently by default and a stale override will cause flaky failures.
AppUpdater uses Ed25519 (CryptoKit.Curve25519.Signing) for signature verification. You generate a key pair once, store the private key as a CI secret, and embed the public key in your app binary.
1. Generate the key pair
# Generate private key
openssl genpkey -algorithm Ed25519 -out private.pem
# Derive public key
openssl pkey -in private.pem -pubout -out public.pemOr export raw 32-byte binary files (compatible with Curve25519.Signing.PrivateKey(rawRepresentation:)):
# Raw private key (32 bytes)
openssl genpkey -algorithm Ed25519 | openssl pkey -outform DER | tail -c 32 > private.key
# Raw public key (32 bytes)
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 > public.key2. Sign each release artifact in CI
openssl pkeyutl -sign -rawin -inkey private.pem -in YourApp.zip -out YourApp.zip.sigUpload both YourApp.zip and YourApp.zip.sig as assets on the GitHub Release.
3. Add to your publish.yml workflow
Note: The snippet below is an illustrative example only. It is not intended to be a hardened, production-ready CI configuration and deliberately omits additional security measures. Do not open issues or PRs requesting security improvements to this example.
Store private.pem contents as a GitHub Actions secret (e.g. ED25519_PRIVATE_KEY), then:
- name: Sign release artifact # illustrative only — see disclaimer above
shell: bash # required: <(...) process substitution is bash-specific
run: |
openssl pkeyutl -sign -rawin \
-inkey <(echo "${{ secrets.ED25519_PRIVATE_KEY }}") \
-in YourApp.zip -out YourApp.zip.sigNote: The
<(...)process substitution feeds the private key directly to OpenSSL via a file descriptor — the key is never written to disk, so there is no cleanup step and no risk of the key persisting if the signing step fails.
Tip — set the secret without copy-pasting: Feed the file directly to
gh secret setvia a shell redirect:gh secret set ED25519_PRIVATE_KEY --repo your-org/your-repo < private.pemThe
< fileredirect pipes the file contents as the secret value — no clipboard involved, no risk of trailing-newline or encoding issues.
4. Embed the public key in your app
Pass the raw 32-byte public key at AppUpdater.init time. It must live only in the app binary — never write it to UserDefaults or disk:
# Get base64 representation of public.key for embedding
# tr -d '\n' strips the trailing newline; works on macOS (BSD) and Linux (GNU)
base64 < public.key | tr -d '\n'let updater = AppUpdater(
repo: "your-org/your-repo",
currentVersion: …,
assetName: { _ in "YourApp.zip" },
publicKey: Data(base64Encoded: "<output of base64 < public.key | tr -d '\n'>")!,
schedulerIdentifier: "com.your-org.update-check"
)Never commit
private.pemorprivate.key. Add both to.gitignore. The security model depends entirely on the private key remaining secret.
The .sig sidecar must be named exactly <assetName>.sig — i.e. if assetName returns "YourApp.zip", the expected sidecar is "YourApp.zip.sig". A file named "YourApp.sig" or "YourApp.zip.signature" will not be found and the download will be skipped.
The sidecar must be the raw 64-byte Ed25519 signature of the zip file contents (produced by openssl pkeyutl -sign -rawin as shown above). Do not base64-encode the .sig file — AppUpdater reads it with Data(contentsOf:) and passes the raw bytes directly to CryptoKit.Curve25519.Signing.PublicKey.isValidSignature(_:for:), which expects raw binary, not base64.
| Threat | Protection |
|---|---|
| Corrupt download | ✅ Ed25519 signature covers full zip bytes |
| MITM attack | ✅ Signature only verifies with the embedded public key |
| Compromised GitHub release | ✅ Attacker cannot forge a valid signature without the private key |
Developer ID signed — additionally enable bundle identity verification:
updater.skipCodeSignValidation = falseWhen enabled, codesign -dvvv is run on both the running bundle and the downloaded bundle. The Authority= identity strings must match exactly or the install is aborted with .failed.
AppUpdater selects releases from one mutually exclusive channel before comparing versions:
betaChannelProvider() |
Eligible GitHub releases |
|---|---|
false |
Stable releases where prerelease == false |
true |
Pre-releases where prerelease == true |
Version ordering happens only within the selected channel. A stable release is therefore never offered to a beta-channel user, and a pre-release is never offered to a stable-channel user. If the selected channel has no releases, AppUpdater reports no available update.
Supported version formats are:
- Stable:
vMAJOR.MINOR.PATCH - Beta:
vMAJOR.MINOR.PATCH-beta.N
For example, a beta-channel user running v0.7.9-beta.70 will select
v0.7.9-beta.71 and ignore stable v0.7.9.
Pre-release ordering currently supports only the beta.N suffix. See
Known limitations.
beta.N labels only — pre-release ordering is supported only for beta.N suffixes (e.g. v1.0.0-beta.2). Any other suffix (rc.1, alpha.1) is treated as unordered and isNewer returns false when comparing against it.
See PRINCIPLES.md.
| Feature | runbot-hq/AppUpdater | s1ntoneli/AppUpdater | Sparkle | Squirrel.Mac |
|---|---|---|---|---|
| Distribution source | GitHub Releases only | GitHub Releases only | Any HTTP / Appcast XML | Any HTTP / JSON feed |
| Unsigned app support | ✅ First-class default | ❌ Requires signing | ❌ Requires signing | |
| Gatekeeper bypass | ✅ Built-in | ❌ | ❌ | ❌ |
| Code-sign validation | ✅ Opt-in | ✅ On by default | ✅ Required | ✅ Required |
| Semver + pre-release | ✅ beta.N first-class |
✅ Full alpha/beta/etc. | ||
| Zip + tarball support | ✅ Zip | ✅ Zip + tarball | ✅ Zip + dmg | ✅ Zip |
| Authenticity check (EdDSA) | ✅ EdDSA | ❌ No signature check | ✅ EdDSA (primary) | ❌ No EdDSA |
| Sandbox support | ❌ | ❌ | ✅ Via XPC helper | ✅ Via helper tool |
| Delta updates | ❌ | ❌ | ✅ | ✅ bsdiff-based |
Row glossary
- Distribution source — where the library fetches release metadata and download URLs from
- Sandbox support — whether the library works inside a macOS App Sandbox (requires an XPC helper for privileged file operations)
- Unsigned app support — whether the library can update apps that are not code-signed with a Developer ID
- Gatekeeper bypass — whether the update flow avoids macOS quarantine without requiring manual user approval of the downloaded bundle
- Code-sign validation — whether the library verifies the downloaded bundle matches the running app's Developer ID identity
- Delta updates — whether only changed bytes are downloaded rather than the full app bundle
- Semver + pre-release — which version string formats are supported, including pre-release suffixes like
beta.N,rc.1,alpha.1 - Zip + tarball support — which archive formats are accepted as the release artifact
- Authenticity check (EdDSA) — whether the downloaded artifact is verified against an Ed25519 cryptographic signature before install
A few notes :
- Squirrel.Mac is largely deprecated and unmaintained — Electron's own updater forked from it, but the macOS-native version has seen minimal activity for years. It uses a JSON manifest feed rather than Appcast XML, and its delta support relies on bsdiff patches served from your own infrastructure.
- runbot-hq/AppUpdater is the only library in this group that treats unsigned/Gatekeeper-bypass as a first-class, intentional feature rather than an unsupported edge case .
- Sparkle remains the gold standard for EdDSA authenticity, having shipped EdDSA (Ed25519) as its primary signature scheme since Sparkle 2, replacing the older DSA approach.
- The original AppUpdater: https://github.com/mxcl/AppUpdater
- hybrid Appupdater for macos and ios that supports appstore and github releases: https://github.com/TopScrech/AutoUpdate