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 follows is not engineering. It is paperwork, packaging, and one pipeline that turned out to be as complex as a small application in its own right.
What Apple Actually Requires
Submitting to the Mac App Store is not uploading a zip file. It is metadata, screenshots, policy files, signing, packaging, and one or two traps that only appear when the clock is running.
Privacy Manifest. Renala needed a PrivacyInfo.xcprivacy file because it touches disk space, file timestamps, and user defaults.1 Even an app that collects no user data still has to declare what system APIs it calls and why.
The file is a machine-readable plist (Apple’s property-list format): what data you collect, which protected API categories you touch, and the Apple reason codes that justify them. In Renala’s case, the manifest was simple: no collected data, no tracking, three API categories, three reason codes.2
%%{init: {"theme": "default"}}%%
accTitle: Privacy manifest structure
accDescr: PrivacyInfo.xcprivacy declares three sections: Required Reason APIs (file access, user defaults) with Apple-defined reason codes, Collected Data Types (none collected), and Third-Party SDK Policies (StoreKit with no data collected).
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. 3B52.1, E174.1, CA92.1"]
Screenshots. The Mac App Store wants between one and ten screenshots, and 2880x1800 is one of the accepted Mac sizes.3 I used five. If you are not working on a 5K monitor, you cannot capture that size 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, privacy policy URL, support URL, category, age rating. Most of this is boilerplate. The keyword field is not. Apple gives you 100 bytes, and it is better spent on terms not already covered by the app or company name.4
The Tip Jar
Renala is free. If someone wants to pay, there should be a way.
StoreKit 2 makes tip jars straightforward if the products are consumables.5 Consumables are the right product type: tips are one-time purchases with no entitlement to track or restore. Load them with Product.products(for:) and a set of product identifiers. The user sees the available amounts, taps one, StoreKit handles the transaction. No server required. No restore flow. No entitlement state to keep in sync later.
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 custom purchase UI.6
Setting up the products in App Store Connect is a separate manual step. Each product needs an identifier, a reference name, customer-facing text, and a price tier. And each one has its own review state.7 Remember this sentence. It will be on the exam.
That is the product configuration. Now the question is how to ship it, reliably, more than once.
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 in two because the same app ships through two different distribution systems. Outside the store, it needs Developer ID signing and notarization. Inside the store, it needs App Store signing, installer packaging, and App Store Connect upload.8
graph LR
accTitle: CI/CD pipeline: direct distribution and App Store
accDescr: A git version tag triggers GitHub Actions. The left branch builds with Developer ID, notarizes, creates DMGs for both architectures, and publishes a GitHub Release. The right branch archives with Apple Distribution signing, packages as .pkg, uploads to App Store Connect, and enters App Store Review.
TAG[Git version tag] --> GH[GitHub Actions]
GH --> BUILD[xcodebuild build<br/>Developer ID]
BUILD --> NOTA[xcrun notarytool<br/>submit + staple]
NOTA --> DMG[Create DMG (disk image)<br/>arm64 + x86_64]
DMG --> REL[GitHub Release]
GH --> ABUILD[xcodebuild archive<br/>Apple Distribution]
ABUILD --> PKG[productbuild .pkg]
PKG --> SUBMIT[xcrun altool<br/>upload]
SUBMIT --> REVIEW[App Store Review]
The left branch is for direct distribution: Developer ID build, notarize, create DMGs, publish as a GitHub Release. The right branch is for the Mac App Store: archive a store-signed app, package it as a .pkg with productbuild (Apple’s installer packaging tool), upload it to App Store Connect with xcrun altool.
Locally, the Makefile exposes the direct-distribution operations: make dmg-arm64, make dmg-x86_64, make notarize. The App Store submission path lives in its own workflow because the signing identities and packaging rules are different.9
Zooming into the App Store branch:
%%{init: {"theme": "default"}}%%
accTitle: App Store submission workflow
accDescr: xcodebuild produces an archive signed for Apple Distribution. productbuild packages it as a .pkg with an Installer certificate. xcrun altool uploads it. App Store Review either approves (Ready for Sale) or rejects with a guideline violation and crash log.
graph LR
BUILD["xcodebuild archive<br/>Apple Distribution + profile"] --> PKG["productbuild .pkg<br/>Installer cert"]
PKG --> 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 a Developer ID build and double-clicks it, Gatekeeper checks whether Apple notarized it.10 Notarization is not App Review. It is Apple’s automated malware and code-signing check for software distributed outside the store.
If the submission passes, Apple issues a ticket. Staple that ticket to the app bundle, disk image, or installer package, and Gatekeeper can verify it even when the machine is offline.11
The Mac App Store path is different. Apple runs equivalent security checks during store submission, so there is no separate notarytool step in that pipeline.12
The Gotchas
Two things caught me on the App Store packaging path.
First: xcodebuild -exportArchive on the App Store path was more trouble than it was worth. In my setup, the archive already contained the correctly signed app. productbuild --component <app> /Applications --sign "3rd Party Mac Developer Installer: ..." produced the .pkg that App Store Connect actually wanted, so I used that path instead.13
Second: xcrun altool authentication. For the upload command, the value I needed was my Apple account username, not the app’s numeric Apple ID.14 The error message when you get that wrong 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: Developer ID builds for direct download, App Store packaging for review, and the boring glue between them.
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.”
That pipeline I mentioned in the opening, the one as complex as a small application? Automate it before you need to rerun it. You will need to rerun it.
References
- Apple: App Store Review Guidelines
- Apple: Privacy manifest files
- Apple: Notarizing macOS software before distribution
- Apple: In-App Purchase with StoreKit
- Apple: Screenshot specifications
- Apple: Platform version information
- WWDC 2022: Explore in-app purchase integration and migration
- WWDC 2023: Meet StoreKit for SwiftUI
Footnotes
-
The app’s manifest in the Renala source declares three accessed API categories: file timestamp, disk space, and user defaults. ↩
-
The exact reason codes in the project are
3B52.1for file timestamps,E174.1for disk space, andCA92.1for user defaults. ↩ -
Apple’s current App Store Connect help allows one to ten screenshots for Mac apps, and 2880x1800 is one accepted 16:10 size, not the only one. ↩
-
Apple documents the keyword limit as 100 bytes, not 100 characters. It also advises against repeating terms already covered by the app or company name. ↩
-
The tip products in Renala’s StoreKit configuration are consumables, which is the important distinction here. “Non-subscription” is technically true, but too vague. ↩
-
StoreKit still presents the purchase sheet. “No custom purchase UI” is the useful part. ↩
-
The practical fields are product ID, reference name, localized display text, and price. The important operational detail is that each IAP also has its own approval state. ↩
-
Same app, different rules. Developer ID distribution is a direct relationship between you, Apple notarization, and the user. Mac App Store distribution goes through App Store Connect review and store packaging rules. ↩
-
In the app repo, the direct-distribution workflow and the App Store workflow are separate for exactly this reason: different certificates, different packaging, different final destination. ↩
-
Gatekeeper is macOS’s launch-time security check for downloaded software. ↩
-
More precisely, you staple the ticket to a supported bundle or container. The practical point is the offline check, not the exact attachment surface. ↩
-
Apple describes the Mac App Store submission process as including equivalent security checks, which is why the App Store workflow does not need a separate notarization step. ↩
-
Current Xcode terminology prefers
app-store-connectover the olderapp-storeexport method name. The project workflow here skips that export step and packages the archived app directly. ↩ -
Current
altoolsupports both--usernameand--apple-id, but they mean different things. On this upload path,--usernamewas the value that mattered. ↩
