Engineering Guide

Reliable iOS Build Numbers in Cloud Mac CI

Reliable iOS Build Numbers in Cloud Mac CI

Two iOS archive pipelines are running concurrently on cloud Macs: one is fixing a production issue, while the other is preparing a routine release. Both read the same CURRENT_PROJECT_VERSION from the project file and ultimately produce two artifacts with identical build numbers but different code. The conflict often remains hidden until submission to the release system, when filenames and commit records alone make it difficult to determine which artifact should be retained.

Build number governance is not simply about “automatically incrementing” a value. Its purpose is to ensure that every distributable artifact can answer three questions: who assigned the number, which commit it corresponds to, and what value is actually embedded in the archive.

Separate the version number from the build number

MARKETING_VERSION is the user-facing version, such as 3.8.0. CURRENT_PROJECT_VERSION is the build number and should be an incrementing numeric value. These fields have different lifecycles, so do not write a commit tag directly into both of them.

Field Example When it changes Primary purpose
MARKETING_VERSION 3.8.0 When the product version changes Identify the feature release
CURRENT_PROJECT_VERSION 18427 For every distributable archive Distinguish artifacts from the same version
Git commit a1b2c3d With every commit Locate the source code
Pipeline run number 5821 With every job run Locate the execution record

The repository can store a stable version number, but multiple runners should not modify and commit the build number concurrently. If concurrent jobs each perform a read-increment-write cycle, they can receive the same result even when every operation succeeds.

Treat the build number as a pipeline input, not as the result of modifying the source tree. The source code defines how the value is used, while the orchestration system guarantees its uniqueness.

Establish a single source for build numbers

Choose a monotonically increasing sequence

Production archives should obtain an integer from a centralized source, such as a global run sequence provided by the pipeline system or a sequence allocated atomically by an internal coordination job. The number must satisfy three requirements:

  1. It must not be reused for the same release target.
  2. Each new number must be greater than every previously submitted number.
  3. It must be traceable back to the commit, branch, and pipeline run.

git rev-list --count HEAD can work for a single-branch project whose history is never rewritten, but it is unsuitable for shallow clones, rebases, or multiple release branches. Different branches can produce the same count, and rewriting history can make the value decrease. It may be used for internal debug builds, but it cannot serve as the sole source of truth for a complex release workflow.

For parallel jobs on OpsVM, the orchestration layer can generate BUILD_SEQUENCE first and then pass it to the individual build nodes. Each node only consumes the number; it does not compete for or write back a value.

Reject invalid input at job startup

#!/bin/zsh
set -euo pipefail

: "${BUILD_SEQUENCE:?BUILD_SEQUENCE is required}"
: "${RELEASE_VERSION:?RELEASE_VERSION is required}"

if [[ ! "$BUILD_SEQUENCE" =~ ^[0-9]+$ ]]; then
  print -u2 "BUILD_SEQUENCE must contain digits only"
  exit 64
fi

if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
  print -u2 "RELEASE_VERSION must use major.minor.patch"
  exit 64
fi

Validate these inputs before installing dependencies or compiling. This prevents a missing variable from turning into an undistributable archive more than ten minutes into the job.

Inject the number during archiving

The pipeline does not need to modify project.pbxproj. Override the build settings at the end of the xcodebuild command instead. This avoids dirtying the repository and makes the build easier to reproduce from its logs.

archive_path="$PWD/output/App.xcarchive"
result_path="$PWD/output/Archive.xcresult"

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -destination "generic/platform=iOS" \
  -archivePath "$archive_path" \
  -resultBundlePath "$result_path" \
  MARKETING_VERSION="$RELEASE_VERSION" \
  CURRENT_PROJECT_VERSION="$BUILD_SEQUENCE" \
  clean archive

Pass both overrides in the same archive command. Otherwise, tests may use one number while the archive falls back to the project default. If the project contains extensions, confirm that the main app and its extensions inherit the same settings. Do not generate a separate number for each target unless the release policy explicitly requires it.

Before archiving, you can inspect the resolved settings:

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -showBuildSettings |
awk '/MARKETING_VERSION|CURRENT_PROJECT_VERSION/ { print }'

This check primarily detects unexpected overrides of command-line values by scripts, configuration files, or target-level settings.

Do not trust the logs; verify the archive directly

A successful command only means that archiving completed. It does not prove that the intended values reached the final application. The verification script should read the main app’s Info.plist from the .xcarchive and compare its values with the pipeline inputs.

app_path="$(find "$archive_path/Products/Applications" \
  -maxdepth 1 -name '*.app' -type d -print -quit)"

if [[ -z "$app_path" ]]; then
  print -u2 "Application bundle not found"
  exit 65
fi

plist="$app_path/Info.plist"
actual_version=$(/usr/libexec/PlistBuddy \
  -c "Print :CFBundleShortVersionString" "$plist")
actual_build=$(/usr/libexec/PlistBuddy \
  -c "Print :CFBundleVersion" "$plist")

[[ "$actual_version" == "$RELEASE_VERSION" ]] || exit 66
[[ "$actual_build" == "$BUILD_SEQUENCE" ]] || exit 67

After verification succeeds, write the version number, build number, full commit hash, archive checksum, and pipeline run identifier to the same plain-text manifest, then store it with the artifact. A human-readable filename is useful, but it cannot replace the manifest or the fields embedded in the archive.

Handle retries, branches, and concurrency

Define retry rules at the pipeline level

If a failure occurs before compilation and no archive has been created, the same job may be retried with the original number. If an archive has already been created, uploaded, or passed to downstream processing, a new number should be allocated. Keep the old number in the records; do not recycle it merely to preserve a gapless sequence.

When multiple release branches share the same release target, they should also share one numbering space. Starting each branch at 1 may look tidy, but it creates collisions when branches converge. The branch name, commit hash, and version number describe an artifact’s origin; the build number is responsible only for uniqueness and monotonic growth.

Minimum checklist

  • Numbers are allocated atomically by one centralized source.
  • Build nodes only read the number and do not modify project files.
  • The archive command explicitly passes both version fields.
  • The inheritance relationship between the main app and its extensions has been verified.
  • After a successful archive, its embedded fields are read and compared.
  • Every artifact retains a mapping to its commit, job, and checksum.
  • Failed jobs that produced an artifact do not release their numbers for reuse.
  • After operators confirm the currently available configurations in the console, parallel nodes perform builds only and do not allocate numbers.

With these constraints in place, the build number is no longer a value edited immediately before release. It becomes a stable index connecting the source code, execution records, and final artifact. When a rollback or conflict between concurrent jobs occurs, the team can use the number to locate the archive first and then trace it back to the unique commit and pipeline run.

Frequently asked questions

Can the Git commit count be used as an iOS build number?

It works for a linear history that is never rewritten. With rebases, shallow clones, or multiple release branches, use a centrally allocated monotonic CI sequence instead.

Should a retried pipeline reuse its previous build number?

Reuse is reasonable only when the failed attempt produced no archive or published artifact. Otherwise allocate a new number and map both runs to the same commit.

Dedicated physical nodes

Deploy Cloud Mac workflows with OpsVM

Choose from three Apple Silicon configurations and six available nodes. Actual availability is based on the live status returned by the console.

Choose a configuration and order