Skip to content
Build Station

Distributing macOS apps

Distribute Flutter macOS apps via the Mac App Store or directly: Developer ID, notarization, DMG via GitHub release or webhook, and Sparkle updates.

The Build Station gets a built macOS app (flutter build macos, output type macOS (.app)) to your users in one of three ways. You choose the route per project in Projekt bearbeiten › Upload › Store-Upload einrichten (Edit project › Upload › set up store upload), in the macOS tab under Verteilung (distribution). In the project editor, macOS-Verteilung einrichten … (set up macOS distribution) takes you there too.

Route What happens Additionally needs
Mac App Store archive, export for App Store Connect, upload with xcrun altool team and automatic signing in the Xcode project, app in the sandbox
GitHub-Release (DMG) (GitHub release) sign with Developer ID, build a DMG, notarize, publish as release v<Version> via gh “Developer ID Application” certificate, gh auth login
HTTPS-Webhook (DMG) like GitHub, but a signed POST to your own server certificate, webhook address and secret

All three routes use the App Store Connect key from the “App Store” tab, i.e. the same credentials as the iOS upload. For direct distribution it’s used for notarization. If it’s missing, the tab points that out. How to create the key is described in App Store and TestFlight.

Automatic upload after a successful build works the same as for the other stores, see Artifacts and upload.

Mac App Store

  1. In App Store Connect, add the macOS platform to the app or create a separate macOS app with the same bundle ID.
  2. In the Xcode project (macos/Runner.xcworkspace), set a team and switch on “Automatically manage signing”. The app has to run in the sandbox.
  3. Xcode fetches the certificate and provisioning profile itself during export, using the API key. If Xcode should also create certificates in the process, the key needs the Admin role. Otherwise, create the certificates once in Xcode under Settings › Accounts.

The flow is xcodebuild archive, then -exportArchive for App Store Connect, then xcrun altool --upload-app -t macos. It’s validated beforehand, just like iOS. Build numbers for macOS are counted separately from iOS.

Direct distribution with Developer ID

Outside the store, a Mac app has to be signed with a Developer ID Application certificate and notarized by Apple, otherwise macOS blocks it.

Setup

  1. Create a “Developer ID Application” certificate in the Apple Developer Portal. Only the Account Holder can do this.
  2. Put the certificate into the keychain of the Mac the Station runs on.
  3. Choose the distribution in the macOS tab. Signier-Identität (optional) (signing identity) stays empty if there’s exactly one “Developer ID Application” in the keychain.

For GitHub-Release (DMG):

  • Enter the target under Repository (owner/repo).
  • The upload uses the GitHub CLI. Run gh auth login once on this Mac.

For HTTPS-Webhook (DMG):

  • Enter your server’s Webhook-Adresse (https://…) (webhook address).
  • Enter the Geheimnis für die Signatur (secret for the signature), or generate it with Erzeugen und kopieren (generate and copy) and store it on the server. It’s kept in the keychain.
  • For Sparkle, specify the Download-Adresse des Servers (server’s download address) where the DMG and appcast.xml will be afterwards.

What happens during upload

  1. Signing: A copy of the app is signed from the inside out, i.e. first the Sparkle components, frameworks and libraries, then the app. With hardened runtime and timestamp. The entitlements come from the project’s macos/Runner/Release.entitlements.
  2. DMG: A DMG with a shortcut to “Applications”, also signed. It’s stored next to the artifact in the collection folder under distribution/.
  3. Notarization: with xcrun notarytool submit --wait. What counts is the status “Accepted”. If Apple rejects it, Apple’s log appears in the upload log. Afterwards, stapler staple attaches the ticket.
  4. Sparkle (optional): EdDSA signature of the DMG and an appcast.xml next to it.
  5. Publishing: as a GitHub release or via webhook. If a GitHub release of the same version already exists, its files are replaced.

The Developer ID certificate is listed with its expiry date in the Übersicht (Overview). If it expires, already notarized versions keep working; you just can’t sign new ones anymore.

Sparkle updates

With Sparkle, the installed app finds new versions by itself. For that, your app needs the Sparkle framework; in Flutter the easiest way is the auto_updater package. If the Station doesn’t find Sparkle in the project, it warns you.

Here’s how to set it up:

  1. In the macOS tab, switch on Updates über Sparkle anbieten (offer updates via Sparkle).
  2. Erzeugen (generate) creates a key pair. If you already have a key from Sparkle’s generate_keys -x, bring it in with Importieren … (import). The private key goes into the keychain.
  3. In Info.plist eintragen (write to Info.plist) writes the public key (SUPublicEDKey), the feed address (SUFeedURL) and SUEnableAutomaticChecks into macos/Runner/Info.plist.
  4. Commit the change to the repository. It takes effect with the next build.

The feed address follows from the target:

Target SUFeedURL
GitHub https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml
Webhook <Download-Adresse>/appcast.xml

Don’t regenerate the key if versions are already out there. They only know the old key and won’t accept new updates. They’d then have to be updated once by hand.

The appcast only contains the latest update. sparkle:version is the build number (CFBundleVersion); Sparkle uses it to decide whether something is newer. The release notes from the upload confirmation appear in the update window.

The webhook protocol

For your own server: the Station sends a POST with multipart/form-data.

Field Content
metadata JSON with event (macos.release), app, bundleId, version, build, releaseNotes, downloadUrl and files[] with field, name, size, sha256
dmg the notarized DMG
appcast the appcast.xml (only with Sparkle)

Plus two headers:

X-BuildStation-Timestamp: 1760475600
X-BuildStation-Signature: sha256=<hex HMAC-SHA256(geheimnis, "<timestamp>.<metadata>")>

Your server should:

  1. verify the signature over exactly the text of the metadata field,
  2. reject old timestamps, e.g. older than five minutes,
  3. compare the SHA-256 of the files with the metadata,
  4. store the DMG and appcast.xml under the download address.

Any response with a 2xx status counts as success. Verifying the signature in Node:

const expected = 'sha256=' + crypto.createHmac('sha256', secret)
  .update(`${req.headers['x-buildstation-timestamp']}.${fields.metadata}`)
  .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-buildstation-signature']))) {
  return res.status(401).end();
}