Your fastlane pipeline finishes. Every step is green. The build uploads. You feel safe.
Then Apple rejects the build. Or your screenshots vanish from the App Store. Or your watchOS app ships as version 1.0 next to an iOS app on version 1.3.
A green pipeline is not proof that everything worked. It only proves that no command returned an error code. Some of the worst iOS release bugs happen inside steps that report success while doing the wrong thing.
This article covers four of these traps. Each one is easy to hit, hard to notice, and confirmed against current fastlane and Apple documentation.
1. increment_build_number Can Hardcode Your Build Number
This is the sneakiest one. It looks like a simple version bump. It is not.
What happens
This trap applies when your project uses Apple Generic Versioning. Xcode does not enable a versioning system by default, so this has to be turned on explicitly (the build setting is VERSIONING_SYSTEM, and its literal value is apple-generic), as Apple documents in QA1827. Many projects do enable it, because agvtool and most CI tooling expect it. When it is on, the CURRENT_PROJECT_VERSION build setting holds the real build number. Your Info.plist just references it with $(CURRENT_PROJECT_VERSION), so Xcode fills in the value at build time.
fastlane’s increment_build_number action does not touch this setting directly. It shells out to Apple’s own agvtool command line tool. By default, it runs:
agvtool next-version -all
Read the agvtool man page and the -all flag is explicit about what it does:
The -all option will also update the CFBundleVersion Info.plist key.
That is the trap. -all does not just bump CURRENT_PROJECT_VERSION. It writes the literal, resolved build number straight into every Info.plist’s CFBundleVersion key. Your $(CURRENT_PROJECT_VERSION) reference is gone, replaced by a hardcoded number like 42.
Why this breaks your pipeline
Once CFBundleVersion is a literal number, it stops following CURRENT_PROJECT_VERSION. The next time something bumps the project setting (a later fastlane step, a manual Xcode edit, another agvtool call), the plist keeps its old, stale value. The plist wins at archive time.
You end up uploading a build where Xcode’s project settings say one number, but the actual bundled binary reports another. Apple either rejects it as a duplicate build number, or you get a build in App Store Connect that does not match what you think you shipped.
How to detect it
Check your Info.plist after running your build lane. Do not use grep for this: a built Info.plist can be binary-encoded, and grep will silently find nothing or return garbage against a binary file. Use plutil instead, which reads both binary and XML plists:
plutil -p MyApp/Info.plist | grep CFBundleVersion
Or ask for the single key directly with PlistBuddy:
/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" MyApp/Info.plist
If you see a plain number instead of $(CURRENT_PROJECT_VERSION), -all already rewrote it. Note this check assumes your project checks in a physical Info.plist with that reference. If your target uses GENERATE_INFOPLIST_FILE = YES instead, there may be no such reference to check in the source file, so verify the resolved build settings (xcodebuild -showBuildSettings) instead.
The fix
Pass skip_info_plist: true. Looking at fastlane’s own source for this action, the parameter controls the flag directly:
mode = params[:skip_info_plist] ? '' : ' -all'
So setting it to true drops -all from the command entirely:
increment_build_number(
xcodeproj: "MyApp.xcodeproj",
skip_info_plist: true
)
Now agvtool next-version only updates CURRENT_PROJECT_VERSION, and your Info.plist keeps the $(CURRENT_PROJECT_VERSION) reference the way Xcode expects.
2. deliver Can Delete Your Live Screenshots
This one has caused real production incidents: a routine metadata push wipes out screenshots that were already live on the App Store.
What happens
deliver (the fastlane action behind upload_to_app_store) has two options that touch screenshots on App Store Connect:
overwrite_screenshots: fastlane’s own docs describe this plainly: “Clear all previously uploaded screenshots before uploading the new ones.” The source code confirms this: when the flag istrue,delivercalls adelete_screenshotsstep that removes every existing screenshot for the languages you are uploading, before it uploads anything new.sync_screenshots: a newer, beta sync mode gated behind theFASTLANE_ENABLE_BETA_DELIVER_SYNC_SCREENSHOTSenvironment variable. Its job is to make App Store Connect match your local screenshot folder. In practice that means the same risk applies: any device size or locale missing from your local folder is not kept as-is, because the whole point of “sync” is to match local state.
Treat both options as destructive to anything not present in your local screenshots_path. That is expected and safe if you are uploading a full, current set of screenshots for every device size.
The trap appears when your local screenshots_path is scoped. Say you only keep Apple Watch screenshots in your automation folder, because those are the only ones a script recently updated. deliver has no idea your iPhone and iPad screenshots exist anywhere else. For every language present in that local folder, it clears the existing screenshots first, then uploads only what it finds locally: the Watch images. Your iPhone and iPad screenshots for those same languages are now gone.
There is a broader point hiding in both options above: a clean deliver log only tells you the API calls it made returned without error. It does not tell you what your screenshot set looks like afterward. Whether overwrite_screenshots or sync_screenshots is on, whether a locale already had screenshots, or whether your screenshots_path is scoped to a subset of devices, the safe habit is the same: do not treat a green log as proof of the outcome. Verify directly on App Store Connect.
How to detect it
After any deliver run that touches screenshots, open App Store Connect and check the screenshot count for every device size and every locale. Do not trust the fastlane log alone.
The fix
Split screenshot uploads from metadata uploads into two different lanes. Keep your routine metadata lane screenshot-safe:
lane :push_metadata do
upload_to_app_store(
skip_screenshots: true,
skip_binary_upload: true,
force: true
)
end
skip_screenshots is documented plainly: “Don’t upload the screenshots.” With that flag set, a metadata-only lane can never delete or touch a screenshot, no matter what screenshots_path happens to contain.
Then run a separate, deliberate lane only when you have a full, current screenshot set for every device size ready to upload:
lane :push_screenshots do
upload_to_app_store(
skip_metadata: true,
skip_binary_upload: true,
overwrite_screenshots: true,
screenshots_path: "./fastlane/screenshots"
)
end
Only run this lane when ./fastlane/screenshots actually contains a complete set for every device and every locale you support.
3. A watchOS Target Without Version Settings Can Ship as 1.0 Build 1
If your app has a paired Apple Watch target, this one can quietly reject your whole submission.
What happens
Modern Xcode targets often use GENERATE_INFOPLIST_FILE = YES. Xcode builds the Info.plist from build settings instead of a checked-in file. MARKETING_VERSION maps to CFBundleShortVersionString and CURRENT_PROJECT_VERSION maps to CFBundleVersion. When a target has neither setting configured, Xcode does not fail the build. Apple’s build settings reference documents how these settings populate a generated Info.plist, but it does not define a guaranteed fallback when they are missing. What you get then depends on your project and the template the target came from. A common outcome, and the one worth checking for, is the value a brand-new target ships with: version 1.0, build 1.
Watch targets are the usual victim. They get added later, copied from a template, or set up quickly, and nobody sets their version settings to match the main iOS app. The build compiles. The archive succeeds. Nothing warns you.
Note on target structure: since Xcode 14 and watchOS 9, new watchOS projects use a single unified Watch App target instead of a separate Watch app and Watch extension. The version mismatch trap still applies either way, you just have one target to check instead of two. Older projects that still carry both a Watch app and Watch extension target need to check both.
Why this breaks your pipeline
A paired iOS and watchOS submission is expected to carry consistent versioning. If your iOS app reports version 1.3.0 build 16 and the paired Watch app reports 1.0 build 1, that is inconsistent metadata you do not want to carry into review. Apple does not publish a rule saying this always fails, but there is no upside to shipping mismatched versions. Check it and keep every target aligned.
Your fastlane build number bump (see trap #1) usually only targets the main app’s .xcodeproj scheme, so it is easy for a Watch target to fall out of sync and stay that way for months.
How to detect it
Open project.pbxproj and check every target’s build settings for MARKETING_VERSION and CURRENT_PROJECT_VERSION:
grep -E "MARKETING_VERSION|CURRENT_PROJECT_VERSION" MyApp.xcodeproj/project.pbxproj
Every target, including the Watch app and Watch extension, should show the same values as your main app target. If a target is missing both keys entirely, it is running on the silent 1.0 / 1 default. This grep only catches inline settings, not values inherited from a shared .xcconfig file. If your project uses .xcconfig files (see the fix below), confirm the resolved value instead with xcodebuild -showBuildSettings -target YourWatchTarget.
The fix
Set explicit values for every target, or better, inherit them from a shared .xcconfig file so one place controls the version for the whole project:
// Version.xcconfig
MARKETING_VERSION = 1.3.0
CURRENT_PROJECT_VERSION = 16
Reference this file from the build settings of the iOS target, the Watch app target, and the Watch extension target, so a single bump in fastlane updates all of them together.
4. Apple Watch Screenshot Sizes Are Literal Pixels, Not Points
This trap catches developers who know iOS design well. The instinct to multiply by 2 for Retina is wrong here.
What happens
Apple’s specifications list pixel dimensions for iPhone too, but most developers first think in points, since that is how Xcode’s UI and design tools like Figma work, then multiply by the scale factor to land on the right pixel size. Apple Watch screenshots do not give you that shortcut. Apple’s official App Store Connect screenshot specifications list Watch sizes as the final, literal pixel dimensions you must upload. No multiplier needed.
Per Apple’s current specification page, the required sizes by Watch model are:
| Pixels | Models |
|---|---|
| 422 x 514 | Ultra 3 |
| 410 x 502 | Ultra 2, Ultra |
| 416 x 496 | Series 11, Series 10 |
| 396 x 484 | Series 9, Series 8, Series 7 |
| 368 x 448 | Series 6, Series 5, Series 4, SE 3 and SE |
| 312 x 390 | Series 3 |
Apple lists one size per model family. Several families also ship in a smaller case size that does not appear in the table. A simulator for that smaller case can produce different pixel dimensions, and App Store Connect rejects a size it does not expect.
So do not trust the device name alone. Check the pixel size of the capture itself, and confirm it matches a row above before you upload.
Apple also states a rule that is easy to miss: you must use the same screenshot size for Apple Watch consistently across all localizations for the app. Do not mix sizes between locales, even if Apple would technically accept more than one size somewhere in your set.
How to detect it
If your screenshot automation script applies a 2x multiplier to a Watch simulator capture, check the output dimensions before uploading:
sips -g pixelWidth -g pixelHeight watch_screenshot.png
If you doubled a point-based size instead of using the simulator’s native capture, the numbers will not match Apple’s table above.
The fix
Do not compute Watch screenshot sizes by hand. Capture directly from the correct simulator device. The watchOS simulator’s raw screen capture already comes out at the exact required pixel size for that device, so it is a reliable sanity check.
Do not use booted as the target when more than one simulator can be running at once, since it is ambiguous about which device you actually captured. List your simulators first to get an explicit UDID:
xcrun simctl list devices
Then pass that UDID directly. Replace YOUR_UDID below with the value from the previous command (angle brackets like <UDID> are shell redirection syntax, so avoid them in a real command):
xcrun simctl io YOUR_UDID screenshot watch_screenshot.png
Run this against a “Apple Watch Series 10 (46mm)” simulator for the 416x496 slot, and an “Apple Watch Ultra 2” simulator for the 410x502 slot. Then verify the output with sips before it goes anywhere near deliver.
Pre-Release Checklist
Run through this before any App Store submission:
-
plutil -p(orPlistBuddy) on everyInfo.plist. It should say$(CURRENT_PROJECT_VERSION), not a literal number. - Every target in
project.pbxproj(main app, Watch app, Watch extension) has matchingMARKETING_VERSIONandCURRENT_PROJECT_VERSION. - After any
deliverrun touching screenshots, open App Store Connect and count screenshots per device size and locale by hand. - Watch screenshots match Apple’s literal pixel table for the exact device, not a doubled point size.
- Metadata-only lanes always pass
skip_screenshots: true.
None of these checks are visible in a fastlane log. A pipeline that reports success is not the same as a release that is correct. Trust the artifact, not the exit code.