How do marketing versions and build numbers work, how should CI generate them, and which rules does App Store Connect enforce?
Predict first
A build number is set in an Info.plist from a script. How does sort -V order three versions compared with plain text sorting?
// === Info.plist ===
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleShortVersionString</key><string>1.9.0</string>
<key>CFBundleVersion</key><string>41</string>
</dict></plist>
- build 42; version sort 1.2.0 1.9.0 1.10.0; text sort 1.10.0 1.2.0 1.9.0
- Both sorts agree
- Text sort is the correct one
Mental model. The marketing version is the book’s edition on the cover; the build number is the print run stamped on the last page. Readers see editions; the printer needs print runs to know which copy has the typo.
Two numbers identify a build, and they have different jobs. CFBundleShortVersionString is the marketing version users see (“2.4.0”), three period-separated integers by convention, chosen by people for a release. CFBundleVersion is the build number, which App Store Connect requires to be unique and increasing for each upload of a given marketing version, so that every binary you send is distinguishable. Keep them in the build settings (MARKETING_VERSION and CURRENT_PROJECT_VERSION) so they flow into every target’s Info.plist, and make app extensions and the watch app share the host’s values (mismatches produce validation warnings or rejection). Never edit them by hand on release day: let CI set the build number from something monotonic (the CI run number, a timestamp, or a counter from App Store Connect) and read the marketing version from one source of truth in the repository. The check below sets the build number with PlistBuddy and shows that versions must be compared numerically by component: as text, “1.10.0” sorts before “1.2.0”, but as a version it is newer, which is exactly the bug in hand-written version comparisons and in “minimum supported version” checks.
Tie each build to its provenance. Tag the commit with the version and build, store the .xcarchive and dSYMs for every build you ship (crash symbolication depends on them, Q461), and record which build went to which TestFlight group and release. A new build is needed for every change: you cannot change a binary after upload, and re-uploading the same build number is rejected. Decide the policy for hotfixes (bump the patch version and keep the build increasing) and for release candidates (the same marketing version with rising build numbers). Display the version and build in a debug or About screen and attach them to crash reports and bug reports, because “which build” is the first question every investigation asks.
Versions are compared per numeric component. Text ordering puts 1.10.0 before 1.2.0, which is wrong.
Common traps
- Comparing versions as strings. “1.10.0” sorts before “1.9.0” as text, so a minimum-version check misfires; compare numerically by component.
- Reusing a build number. The upload is rejected, and it also makes crash reports ambiguous.
They might follow up with
- Where should the build number come from? The CI system (run number or timestamp), never a person.
- What must match between the app and its extensions? The version values, which are normally taken from the same build settings.
Related questions
- 25.2.1 What are the main ways to run iOS CI/CD (Xcode Cloud, GitHub Actions, Fastlane, others), and what do they all…
- 25.2.3 How do you design a release pipeline: hermetic builds, secrets, caching, artefact promotion and the order of…
- 23.5.1 How do you read a crash report, what do the exception types and Swift runtime messages mean, and how does…
- 6.7.1 What is Info.plist for, and which keys matter most?
← What are the main ways to run iOS CI/CD (Xcode Cloud, GitHub Actions… How do you design a release pipeline: hermetic builds, secrets… →