The distance between "it runs on my simulator" and "it is on the App Store" is mostly configuration, and configuration is where a project accumulates decisions nobody can see. This lesson is how to keep those decisions reviewable, plus signing, CI, and the release path with the rejections worth avoiding.
Build settings belong in a file
Xcode will happily let you set every build setting through its project editor, and they land in
project.pbxproj — a file nobody reads and therefore nobody reviews. A setting changed
there appears in a pull request as an unreadable line, which is how a release-only flag gets flipped
and stays flipped for a month.
PRODUCT_BUNDLE_IDENTIFIER = com.lovemesomecoding.pizza.ios
MARKETING_VERSION = 1.0.0
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 17.0
SWIFT_VERSION = 5.0
TARGETED_DEVICE_FAMILY = 1,2
Two of those are the version, and the distinction trips people up. MARKETING_VERSION
is what customers see — 1.0.0, then 1.1.0. CURRENT_PROJECT_VERSION is the build number,
which must increase with every upload to App Store Connect and is otherwise invisible. Uploading a
build whose number you already used is the most common first rejection, and it happens at upload
rather than review.
The setting that must not be shared
// PRODUCT_NAME is deliberately NOT set here.
An .xcconfig assigned at the project level applies to every target, and
PRODUCT_NAME is what the Swift module name derives from. Setting it there gave the app
and its test bundle the same module name, and the build then failed with four "Multiple commands
produce …" errors that name the symptom and not the cause.
It belongs on the target. And while you are there, PRODUCT_MODULE_NAME is worth
setting explicitly — otherwise a product called "StayHub Pizza" gives you a module called
StayHub_Pizza, and @testable import Pizza does not compile.
Debug and Release differ, deliberately
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
SWIFT_OPTIMIZATION_LEVEL = -Onone
ONLY_ACTIVE_ARCH = YES
Debug builds unoptimised for the active architecture only, which is why they are fast to build and slow to run. Release compiles whole-module with optimisation, and it is the only configuration worth drawing a performance conclusion from.
DEBUG is what gates #if DEBUG, which is how the sample data and preview
repositories stay out of the shipped binary.
Where the API host comes from
PIZZA_API_BASE_URL = http:$()/$()/localhost:8085
Not a typo. In an .xcconfig, // starts a comment — so writing the URL
normally gives you the string http:, silently. $() is an empty variable
substitution that separates the two slashes without changing the result.
The release file points somewhere else entirely, which is the whole point of having two:
PIZZA_API_BASE_URL = https:$()/$()/api.pizza.example.com
PIZZA_STRIPE_PUBLISHABLE_KEY =
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
VALIDATE_PRODUCT = YES
The same source ships to every environment and the host is a build setting rather than a code
edit. For a third environment — staging — the shape is one more .xcconfig and one more
configuration, not a branch in the source.
The app reads those values from Info.plist, and the fallback is deliberately
asymmetric: a debug build with nothing configured defaults to localhost, and a release build with
nothing configured traps at launch. Shipping a binary that silently talks to
localhost is far worse than one that refuses to start on the machine of the person who
can still fix it.
Info.plist: if you own it, you own all of it
Xcode synthesises the standard bundle keys when it generates the plist for you. Supply your own and you own every one of them — and omitting them is not a build error:
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
The app compiles, links and signs, and then the simulator refuses to install it: "The application's
Info.plist does not contain a valid CFBundleVersion". Every value is a build-setting substitution, so
the version lives in one .xcconfig rather than being duplicated.
The keys that get you rejected
Any API that touches private data needs a usage-description string, and the wording is read by a human reviewer. "This app needs camera access" is a rejection; "Used to photograph a receipt when reporting a problem with an order" is not. The crash if you forget one entirely is immediate and unmistakable.
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
App Transport Security blocks plain HTTP, and the development backend is
http://localhost:8085. NSAllowsLocalNetworking opens local addresses only.
The tempting alternative — NSAllowsArbitraryLoads — disables HTTPS enforcement for the
entire app and is rejected without a written justification. A production build talks to an
https:// API and needs neither.
Targets, in the manifest
Under the app target's settings.base, alongside INFOPLIST_FILE and
PRODUCT_NAME:
PRODUCT_MODULE_NAME: Pizza
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SUPPORTED_PLATFORMS: iphoneos iphonesimulator
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
The INFOPLIST_KEY_ prefix is worth knowing: Xcode merges those build settings into
the generated plist, so simple values can live beside the rest of the configuration instead of in
XML. Orientation is a good example — it is a product decision, it changes rarely, and it reads better
here than in a plist nobody opens.
Icons and the launch screen
Two small things that block a submission if they are wrong.
The app icon is a single 1024×1024 image in the asset catalogue; the system generates the rest. It must have no alpha channel and no rounded corners — the system rounds it — and a PNG exported with transparency is rejected at upload rather than at review.
The launch screen is not a splash screen and must not be used as one. It is drawn before your code runs, so it cannot show anything dynamic, and the guidance is that it should resemble the first screen of your app so the transition is invisible. A logo on a coloured background technically passes and makes the app feel slower than it is.
Signing
Three things that sound alike. A certificate identifies you. An App ID identifies the app and its capabilities. A provisioning profile ties the two together and lists the devices a development build may run on.
Automatic signing in Xcode manages all three, and for most teams that is the right answer. What is worth knowing is that none of it is needed for the simulator:
CODE_SIGN_STYLE = Automatic
CODE_SIGNING_REQUIRED[sdk=iphonesimulator*] = NO
CODE_SIGNING_ALLOWED[sdk=iphonesimulator*] = NO
Which means a fresh clone builds and tests without an Apple account at all. That matters for CI — where storing a signing identity is real work — and it matters for anyone reading the repository.
CI
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_15.4.app
Pinned, not macos-latest. A runner image upgrade changes the Xcode version, the
simulator runtimes and the Swift compiler all at once, which turns "someone's PR broke the build"
into a half-day of archaeology. Upgrading then becomes a deliberate, reviewable commit.
macOS runners cost roughly ten times a Linux minute, so the path filter is not a detail — a change to an unrelated part of the repository must not start a Mac.
Check the generated project still matches its manifest
- name: Check the project matches project.yml
run: |
brew install xcodegen
./Scripts/generate-project.sh
if ! git diff --quiet -- Pizza.xcodeproj; then
echo "::error::Pizza.xcodeproj is out of date. Run ./Scripts/generate-project.sh and commit the result."
git diff --stat -- Pizza.xcodeproj
exit 1
fi
Where the project is generated and committed, the two can drift: someone changes a setting in
Xcode's editor, commits the .pbxproj, and the next regeneration silently discards it.
Regenerating and diffing in CI is what makes that impossible rather than merely discouraged.
Cache what is expensive
- name: Cache Swift packages
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: ${{ runner.os }}-spm-${{ hashFiles('pizza/pizza-ios-mobile/Pizza.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
Keyed on Package.resolved, which is committed for exactly this reason: the key
changes only when a dependency version does, so the cache is reused until it is genuinely stale.
Committing that file is also what makes a build reproducible — without it, two people resolving the
same range can get different versions.
And upload the result bundle on failure. A CI log tells you a test failed; an
.xcresult tells you which assertion, with the full context, opened in Xcode.
Archive and upload
Two commands, and they are the same ones Xcode's menu runs:
xcodebuild archive -project Pizza.xcodeproj -scheme Pizza \
-destination 'generic/platform=iOS' -archivePath build/Pizza.xcarchive
xcodebuild -exportArchive -archivePath build/Pizza.xcarchive \
-exportOptionsPlist ExportOptions.plist -exportPath build
Then TestFlight before the store, always. Internal testers get a build in minutes with no review; external testers need a short review. It is the only realistic way to find the things that only happen on a real device on a real network — and the crash reports come back symbolicated through the Organizer.
The review, and what actually gets rejected
Most rejections are not about your code.
Missing or vague usage descriptions is the most common. Broken functionality is the second — a reviewer hits a screen that requires a login they do not have, so provide a demo account in App Review notes. Account deletion is required if your app can create an account, and it must be reachable in the app rather than only on a website. And if you sell digital content, it goes through in-app purchase.
Physical goods — a pizza — do not. That is why this app takes card payments with Stripe and is entirely within the rules.
Privacy, which is now a build artefact
Two requirements that are recent enough to catch people out.
A privacy manifest — PrivacyInfo.xcprivacy — declares the data your
app collects and the reason it uses certain common APIs. Several third-party SDKs ship their own, and
Xcode aggregates them into the report you paste into App Store Connect. It is a file, so it belongs in
review like any other.
And required-reason APIs: a handful of ordinary calls, including file timestamps
and UserDefaults, now need a declared reason in that manifest. The failure is an email
after upload rather than a build error, which is exactly the kind of thing to set up once at the
start rather than the week you planned to ship.
Distributing to people who are not customers
Beyond the App Store there are two paths worth knowing exist. Ad hoc distribution installs on specific registered devices, which is the older way to get a build to a colleague. Enterprise distribution is for internal apps at a company and comes with real obligations about who may install it.
For almost everything, TestFlight has replaced both — it needs no device registration, it handles updates, and it gives you the crash reports.
A pre-release checklist
- Build number incremented.
- Release configuration pointed at the production API, and verified rather than assumed.
- No
NSAllowsArbitraryLoads, no debug logging of anything sensitive. - Tested on a real device, on cellular, and with the app killed and relaunched.
- The largest Dynamic Type size does not break a screen.
- A demo account in the review notes.
Next
One lesson left: the questions an iOS interview actually asks, answered against everything in this track.