The App Store Gauntlet: Preparation

“I thought building the app was the hard part.”

The binary was done. I assumed the hard part was over.

Every developer assumes this exactly once.

What Apple Actually Requires

Submitting to the Mac App Store is not uploading a zip file. The checklist is long, and some of it is non-obvious.

Privacy Manifest. Apple now requires a PrivacyInfo.xcprivacy file for apps that use certain system APIs. The file is a machine-readable plist declaring which APIs you use and why. File access APIs, for example, need a declared reason code. The codes are predefined by Apple. You pick the one that matches your actual use. This is not a rubber stamp: the reviewer cross-references it. Getting this wrong is a rejection.

%%{init: {"theme": "default"}}%%
graph TD
    PRIV["PrivacyInfo.xcprivacy"] --> APIS["Required Reason APIs<br/>(file access, user defaults)"]
    PRIV --> DATA["Collected Data Types<br/>(none collected)"]
    PRIV --> SDK["Third-Party SDK Policies<br/>(StoreKit: no data collected)"]
    APIS --> CODES["Apple-defined reason codes<br/>e.g. C617.1, CA92.1"]

Screenshots. Five screenshots at exactly 2880x1800 pixels. If you are not working on a 5K monitor, you cannot capture them natively. I composed them in a tool that outputs at the right resolution. Each one tells a part of the story: opening scan, treemap in detail, sidebar navigation, duplicate detection, cleanup workflow. Screenshots are a pitch, not documentation.

Metadata. App Store description, subtitle, keywords (100 characters, comma-separated, no spaces after commas, no redundancy with the app name). A privacy policy URL. A support URL. A category. An age rating. Most of this is boilerplate, but the keyword field requires thought: those 100 characters directly affect App Store search ranking.

The Tip Jar

Renala is free. If someone wants to pay, there should be a way.

StoreKit 2 makes tip jars straightforward. A non-subscription in-app purchase, loaded via Product.products(for:) with a set of product identifiers. The user sees the available amounts, taps one, StoreKit handles the transaction. No server required. No entitlement to check.

The implementation is compact: a TipJarManager observable class, a TipJarView that loads products on appear and renders them in a list. No subscriptions, no restore button, no popups.

Setting up the products in App Store Connect is a separate manual step. Each product needs a display name, a description, and a price tier. And they need to be in a certain review state before they are visible in certain contexts. Remember this sentence. It will be on the exam.

The CI/CD Pipeline

Doing any of this by hand once is fine. Doing it again after a bug fix is where manual processes fall apart.

The pipeline splits into two branches:

graph LR
    TAG[Git version tag] --> GH[GitHub Actions]
    GH --> BUILD[xcodebuild archive]
    BUILD --> SIGN[Sign with<br/>Developer ID]
    SIGN --> NOTA[xcrun notarytool<br/>notarize]
    NOTA --> STAPLE[staple notarization]
    STAPLE --> DMG[Create DMG<br/>arm64 + x86_64]
    DMG --> REL[GitHub Release]

    GH --> ABUILD[xcodebuild archive<br/>App Store method]
    ABUILD --> PKG[productbuild .pkg]
    PKG --> SUBMIT[xcrun altool<br/>submit]
    SUBMIT --> REVIEW[App Store Review]

The left branch is for direct distribution: sign with Developer ID, notarize, create DMGs (separate builds for arm64 and x86_64), publish as a GitHub Release. The right branch is for App Store: archive with the App Store distribution method, package as a .pkg with productbuild, submit with xcrun altool.

Locally, the Makefile exposes the same operations: make dmg-arm64, make dmg-x86_64, make notarize. Credentials are read from a .env file, not committed to the repository.

The App Store path specifically goes through these stages:

%%{init: {"theme": "default"}}%%
graph LR
    BUILD["xcodebuild archive"] --> SIGN["Sign with<br/>Developer ID or<br/>App Store cert"]
    SIGN --> NOTA["xcrun notarytool<br/>submit"]
    NOTA --> STAPLE["staple notarization<br/>ticket"]
    STAPLE --> UPLOAD["xcrun altool<br/>upload"]
    UPLOAD --> REVIEW["App Store Review"]
    REVIEW --> APPROVED["Approved:<br/>Ready for Sale"]
    REVIEW --> REJECTED["Rejected:<br/>Guideline violation<br/>+ crash log"]

What Notarization Is

When a user downloads your app and double-clicks it, macOS checks whether Apple has scanned the binary and approved it. That scan is notarization.

You submit the signed binary to Apple’s notarization service. If it passes, Apple returns a ticket. You “staple” that ticket to the binary, embedding it. Now Gatekeeper can verify the app offline, without contacting Apple’s servers. Without the staple, a Mac with no network shows a warning instead of launching the app.

Required for all Mac software distributed outside the App Store. For software inside the store, Apple handles it as part of the review process.

The Gotchas

Two things caught me on the App Store packaging path.

First: xcodebuild -exportArchive with method: app-store validates that the signing certificate matches the provisioning profile. This validation is stricter than Developer ID signing. The workaround: bypass the export step entirely and call productbuild --component <app> /Applications --sign "3rd Party Mac Developer Installer: ..." directly. This creates the .pkg that xcrun altool expects.

Second: xcrun altool authentication. The flag is --username, not --apple-id. The error message when you use the wrong flag is not especially helpful. This is the kind of thing that costs twenty minutes the first time and zero minutes every time after, which is why nobody writes it down, which is why it costs twenty minutes the first time.

What the Pipeline Buys

Once the workflows were in place, a version tag triggers everything: build, sign, notarize, package, submit. This matters most after a rejection. Fix the issue, bump the version, tag, let the pipeline run. Confidence in the process means confidence that the fix actually shipped, not “I think I followed all the steps.”

The submission pipeline is as complex as a small application in its own right. Automate it before you need to rerun it. You will need to rerun it.

References