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 only appcast.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.

  1. Click New repository
  2. Name: FocusTimer-updates — this name is used in the feed address shortly, so set it precisely, including capitalization.
  3. Owner: your account or an organization (example: example-dev)
  4. Visibility: Public — required, since Sparkle must fetch it without authentication.
  5. Check Add a README file (for a convenient first commit)
  6. Click Create repository

Step 2 — Enable GitHub Pages

Serve the repository you just created as a static site.

  1. Go to the repository’s SettingsPages in the left menu
  2. Source: Deploy from a branch
  3. Branch: select main / (root)Save
  4. 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.

FieldValue
TypeCNAME
Nameupdates
Valueexample-dev.github.io
TTL3600 (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 .dmg installer 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>
  • methoddeveloper-id. It means “direct distribution, not the Mac App Store.”
  • signingStyleautomatic. 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-lookup exception above isn’t needed. However, since automatic updates use the network, the network.client entitlement 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 above
  • ENABLE_HARDENED_RUNTIME = YES — a required condition for notarization
  • ENABLE_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 .dmg involves 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.

References