Skip to content
C.W.K.
Stream
Lesson 04 of 07 · published

Build Numbers: At the Source, Committed Last, Under a Rolling Cap

~15 min · testflight, build-numbers, xcodegen, versioning, upload-limits

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The number is reused, not burnt."

Two Numbers With Two Jobs

An iOS build carries two versions. CFBundleShortVersionString is the marketing version people see, like 1.4.0. CFBundleVersion is the build string, and App Store Connect uses it to identify the build uniquely throughout the system, so an upload that reuses one is refused. In an XcodeGen project the two come from MARKETING_VERSION and CURRENT_PROJECT_VERSION. The share extension's Info.plist refers to both through $(…), so the extension can never carry a different pair from the app that contains it. The export options set manageAppVersionAndBuildNumber to false, because by default Xcode may rewrite the build number at upload, and then the number in the source no longer names what shipped.

Where the Number Lives

The number lives in the source. An app whose project is generated bumps CURRENT_PROJECT_VERSION in project.yml, every occurrence of it, then regenerates. An edit to the generated .xcodeproj lasts until the next xcodegen generate. The terminal app takes the other valid route and uses the committed git revision count, which is also in the source by construction.

The order around the bump is what makes the number mean something. The pipeline refuses to start while the app's inputs are uncommitted, so the number names a commit that contains exactly what is built. It checks after xcodebuild that HEAD did not move and no input changed, because a build started before an edit and uploaded after it would carry the wrong label, and the label is the only thing that ties an installed build back to code. It commits and pushes the bump only after the export or upload succeeded. A refused upload leaves the bump uncommitted, the retry reuses the same number, and nothing about a failed run ever lands in history. One pipeline once committed a build number for an upload that never happened.

The Rolling 24-Hour Cap

App Store Connect limits uploads per app, and the window rolls. On a long day of small slices the native Pippa app uploaded 21 builds in a burst; the next two were refused at export with "Upload limit reached"; the one after was accepted as soon as the first build of the burst was 24 hours old. A calendar-day reset at any midnight from UTC−7 to UTC+7, Pacific midnight included, would already have let the second refused upload through, which is how the rolling shape was proved. The practical rules: note each upload's time, expect the next slot 24 hours after the oldest one still in the window, schedule the retry for that minute with a launchd StartCalendarInterval instead of a sleeping shell, and pace a busy day as one build per few slices rather than one per slice.

Code

bump.zsh: the build number moves in project.yml, every occurrence, then the project is regenerated·bash
#!/bin/zsh
# Bump the build number at its source, every occurrence, then regenerate the project.
set -euo pipefail
yml=SparkMobile/project.yml
current=$(sed -n 's/^ *CURRENT_PROJECT_VERSION: *\([0-9][0-9]*\).*$/\1/p' "$yml" | head -1)
[[ "$current" == <-> ]] || { print -u2 "HARD_FAIL no numeric CURRENT_PROJECT_VERSION in $yml"; exit 1; }
next=$((current + 1))
# Every occurrence: the project-wide value and any target's, so an extension never
# carries a different build number from the app that contains it.
# The number must end where the digits end, or a current 1 would turn a sibling 10 into 20.
sed -E -i '' "s/^( *CURRENT_PROJECT_VERSION:) *${current}([^0-9].*)?$/\1 ${next}\2/" "$yml"
(cd SparkMobile && xcodegen generate >/dev/null)
print "build number: $current -> $next"
grep -n 'CURRENT_PROJECT_VERSION' "$yml"   # every line shows the new number
The rolling cap, as it was measured·text
One app, one burst, times in KST, measured:

  day 1  17:05  upload  1  accepted   <- the oldest upload in the window
         ...           ... accepted
  day 2  01:40  upload 21  accepted
  day 2  13:06  upload 22  refused: "Upload limit reached", server code 90382
  day 2  16:21  upload 23  refused   (a calendar-day reset at any UTC-7..UTC+7 midnight would
                                      already have let this one through)
  day 2  17:27  upload 24  accepted  <- upload 1 is now more than 24 hours old

A refused run costs nothing but time: tests, archive and gates all passed, the bump is
reverted, and the same build number is used again on the retry.

External links

Exercise

Add a second CURRENT_PROJECT_VERSION line inside the share extension's target settings in SparkMobile's project.yml, then run bump.zsh and confirm both lines and the regenerated project carry the new number. Next, wrap the bump in the pipeline order: refuse if git status --porcelain shows changes under the app's inputs, remember HEAD, simulate a failed export by exiting 1, and show that the bump is left uncommitted with instructions to revert it.
Hint
git status --porcelain -- <paths> limits the check to the app's own inputs, which matters on a Mac where other sessions have unrelated files open. To revert, git checkout -- SparkMobile/project.yml plus the project file if your repository tracks it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.