The Last Piece — Where to Put the Updates
In Part 1 we prepared the Developer ID certificate and notarization, and in Part 2 we prepared the Sparkle signing key. That means we now have a way to sign the app, notarize it, and verify the authenticity of updates.
But the location pointed to by SUFeedURL (https://updates.example.com/appcast.xml), which we wrote into the app’s Info.plist in Part 2, still has nothing in it. In this final part, we’ll host the update feed that goes in that spot and finish the build settings, completing the entire one-time setup.
As in Parts 1 and 2, all names and domains (
FocusTimer,example.com,example-dev, etc.) are example values. In practice, replace them with your own information.
Why Keep a Separate Update Repository
For automatic updates to work, two things must be available on the internet.
appcast.xml— the update feed that tells the app which version is the latest and where to get it.dmg— the actual app installer file
There’s an important constraint here. The Sparkle code inside the app fetches these files with a simple, unauthenticated HTTPS GET. This means the app on the user’s computer must be able to download them directly, without any procedure like a login.
Many developers keep the app’s main source repository private. But release files in a private repository require authentication, so Sparkle can’t fetch them. That’s why a common structure is to split the repositories.
- Main repository (e.g.,
FocusTimer) — the source code. May be kept private. - Update repository (e.g.,
FocusTimer-updates) — hosts onlyappcast.xml. Must be public.
In this article, we’ll run the update repository on GitHub Pages, GitHub’s free static hosting.
Step 1 — Create the Public Update Repository
Create a new repository on GitHub.
- Click New repository
- Name:
FocusTimer-updates— this name is used in the feed address shortly, so set it precisely, including capitalization. - Owner: your account or an organization (example:
example-dev) - Visibility: Public — required, since Sparkle must fetch it without authentication.
- Check Add a README file (for a convenient first commit)
- Click Create repository
Step 2 — Enable GitHub Pages
Serve the repository you just created as a static site.
- Go to the repository’s Settings → Pages in the left menu
- Source: Deploy from a branch
- Branch: select
main/(root)→ Save - After 1–2 minutes, if
https://example-dev.github.io/FocusTimer-updates/becomes reachable, it worked.
At this point you already have a public address where you can put update files. But one more step remains.
Step 3 — Connect a Custom Domain
You can use the default GitHub Pages address (example-dev.github.io/...) as-is and it will work. But if you embed that address in the app’s SUFeedURL, you’ll run into trouble if you ever need to move hosting from GitHub Pages to somewhere else — because every already-distributed user’s app is still pointed at the old address.
The solution is to insert a layer of a domain you control. If you set SUFeedURL to your own domain, like https://updates.example.com/appcast.xml, then when you later move hosting, you only change one line of DNS configuration and existing users automatically follow along to the new location. You only have to do this setup once, and it stays valid forever.
3-1. Add a DNS Record
In the configuration screen of the DNS provider that manages your domain (example.com), add the following record.
| Field | Value |
|---|---|
| Type | CNAME |
| Name | updates |
| Value | example-dev.github.io |
| TTL | 3600 (default) |
This makes the subdomain updates.example.com point to GitHub Pages.
3-2. Add a CNAME File to the Repository
In the root of the update repository (FocusTimer-updates), create a file called CNAME whose content is just one line — the domain.
updates.example.com
Commit and push this file.
3-3. Register the Domain with GitHub Pages
In the repository’s Settings → Pages → Custom domain field, enter updates.example.com and click Save. GitHub automatically issues an HTTPS certificate, and once it’s issued, check Enforce HTTPS.
3-4. Verification
DNS propagation and certificate issuance usually take about 10 minutes. After a short wait, verify with the following command.
curl -I https://updates.example.com/appcast.xml
If the first line of the response shows HTTP/2 200, everything is fine. (If you haven’t uploaded appcast.xml yet, you may get a 404, but the domain and HTTPS connection itself can be confirmed through other paths. The key point is that https://updates.example.com returns a response.)
For reference, the
.dmginstaller file is generally uploaded to GitHub Releases rather than GitHub Pages. Putting large binaries directly in a repository bloats it. Releases are served through a different path than Pages, so leave them as-is, unrelated to the custom domain setup above.
Step 4 — Keep the Update Repository Locally
To edit and commit appcast.xml during release work, you need to have the update repository checked out locally too. If you clone it to a suitable location inside the main project folder (e.g., release/updates), the build and release scripts can handle both repositories from one place, which is convenient.
git clone [email protected]:example-dev/FocusTimer-updates.git release/updates
When you keep one repository inside another like this, add release/updates/ to the main repository’s .gitignore so they don’t interfere with each other.
Step 5 — ExportOptions.plist
Now for the build-side settings. Xcode’s xcodebuild -exportArchive command reads a configuration file called ExportOptions.plist when exporting an archive into a .app for distribution. The contents for direct distribution (the Developer ID channel) are as follows.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>ABCDE12345</string>
</dict>
</plist>
method—developer-id. It means “direct distribution, not the Mac App Store.”signingStyle—automatic. Lets Xcode automatically pick and use the certificate issued in Part 1.teamID— the Team ID you noted in Part 1.
Create this file once inside the project (e.g., release/ExportOptions.plist) and reuse it for every release.
Step 6 — Check the App-Side Settings
Finally, let’s go over the settings that must be present in the app project itself. If you’re adding Sparkle to a new app for the first time, you can use this list as a checklist.
Info.plist
These are the Sparkle keys added in Part 2. The Info.plist must include the following keys.
<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>5vT3kQbA9mZ0wR1yX8cD2eF4gH6jK7lN0pS2uV5xW8c=</string>
Double-check that SUFeedURL points to the custom domain created in Step 3, and that SUPublicEDKey matches the public key generated in Part 2.
Entitlements
If the app uses the App Sandbox, Sparkle needs exception entitlements so it can communicate with internal services to install updates. The following entries go into the FocusTimer.entitlements file.
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
At build time, Xcode automatically substitutes $(PRODUCT_BUNDLE_IDENTIFIER) with the actual bundle identifier (com.example.FocusTimer). For details on each entry, see the official Sparkle sandboxing guide.
Sandboxing is not mandatory for a directly distributed app (the sandbox is a Mac App Store requirement). If your app doesn’t use the sandbox, the
mach-lookupexception above isn’t needed. However, since automatic updates use the network, thenetwork.cliententitlement and Hardened Runtime must be turned on for notarization.
Build Settings
In Xcode’s build settings, check the following.
CODE_SIGN_ENTITLEMENTS— the path to the entitlements file aboveENABLE_HARDENED_RUNTIME = YES— a required condition for notarizationENABLE_OUTGOING_NETWORK_CONNECTIONS = YES— allows network access for checking updates
Series Wrap-Up — One-Time Setup Complete
The three-part one-time setup for direct distribution is now complete. You now have the following in hand.
- ✅ (Part 1) A Developer ID Application certificate + credentials for notarization
- ✅ (Part 2) A Sparkle EdDSA signing key pair + a backup of the private key
- ✅ (Part 3) A public update repository connected to a custom domain +
ExportOptions.plist+ app-side configuration
This is everything that only needs to be done once. You won’t have to redo this work every time you ship a new version.
From here on, the flow for distributing a new version repeats in much the same way each time — build the archive → export with ExportOptions.plist → sign with the Developer ID certificate → notarize with notarytool → create the .dmg → sign it with the Sparkle key → update appcast.xml → upload the .dmg to a GitHub Release. This repetitive process can mostly be automated with a single script, and that’s a topic for a separate article.
Among these, creating the
.dmginvolves design elements like background images and icon placement, so it’s covered in detail in the separate series Designing a Distribution DMG for Your macOS App.
Direct distribution may look daunting at first because there’s a lot to set up, but the key point is that “once you set it up, it keeps getting reused.” In exchange for giving up some of the App Store’s convenience, you get to control every part of the distribution process yourself.