Cloud Mac tests all pass, yet the client treats 1.10.0 as older than 1.9.0. This is not a build failure: the upgrade check is sorting versions as strings. Another common mistake is treating a parse failure as “no update needed.” If the server sends a malformed version, the update option quietly disappears.
The approach below gives version handling its own test boundary: parse strictly, compare precedence, then apply the upgrade policy. All test cases run locally, with no network access or signing assets required.
Define Which Versions Are Eligible for Comparison
This article covers application versions that follow SemVer: three numeric components, optionally followed by prerelease identifiers and build metadata. For example, 2.4.0-rc.1+build.7. This is not a general-purpose validator for app distribution platforms’ version fields, and it cannot directly replace the release rules for CFBundleShortVersionString.
Core numeric components must not contain leading zeros; the same applies to purely numeric prerelease identifiers. Prerelease identifiers and build metadata may contain only ASCII letters, digits, and hyphens, with no empty dot-separated segments. Numeric identifiers in build metadata may have leading zeros, and build metadata does not affect precedence.
“Higher version” does not mean “mandatory upgrade.” Minimum supported versions, staged rollout eligibility, testing channels, and withdrawn releases belong in the policy layer, not in the comparator.
Establish the contract up front: invalid input produces a parse failure, not a fallback to 0.0.0. Production entry points should also limit string length—for example, to 256 bytes—to keep malformed data from consuming unnecessary resources.
Implement a Comparator Without Integer Conversion
Place the following code in a Swift file accessible to the test target. Numeric components are compared first by length, then by ASCII order, so even extremely long numbers never need to be converted to Int. Build metadata is validated and then discarded, which also makes synthesized equality mean “equal precedence.”
struct SemVersion: Comparable {
let core: [String]
let prerelease: [String]
private static func numeric(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
}
private static func validNumber(_ value: String) -> Bool {
numeric(value) && (value == "0" || !value.hasPrefix("0"))
}
private static func validIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0)
|| (97...122).contains($0) || $0 == 45
}
}
private static func fields(_ value: String) -> [String] {
value.split(separator: ".", omittingEmptySubsequences: false)
.map(String.init)
}
init?(_ raw: String) {
guard raw.utf8.count <= 256 else { return nil }
let parts = raw.split(
separator: "+", omittingEmptySubsequences: false
).map(String.init)
guard parts.count <= 2 else { return nil }
if parts.count == 2 {
guard Self.fields(parts[1]).allSatisfy(Self.validIdentifier)
else { return nil }
}
let version = parts[0].split(
separator: "-", maxSplits: 1,
omittingEmptySubsequences: false
).map(String.init)
let core = Self.fields(version[0])
guard core.count == 3, core.allSatisfy(Self.validNumber)
else { return nil }
let pre = version.count == 2 ? Self.fields(version[1]) : []
guard pre.allSatisfy({
Self.validIdentifier($0)
&& (!Self.numeric($0) || Self.validNumber($0))
}) else { return nil }
self.core = core
self.prerelease = pre
}
private static func numberLess(_ lhs: String, _ rhs: String) -> Bool {
lhs.count == rhs.count ? lhs < rhs : lhs.count < rhs.count
}
static func < (lhs: Self, rhs: Self) -> Bool {
for (a, b) in zip(lhs.core, rhs.core) where a != b {
return numberLess(a, b)
}
if lhs.prerelease.isEmpty { return false }
if rhs.prerelease.isEmpty { return true }
for (a, b) in zip(lhs.prerelease, rhs.prerelease) where a != b {
let an = numeric(a)
let bn = numeric(b)
if an && bn { return numberLess(a, b) }
if an != bn { return an }
return a < b
}
return lhs.prerelease.count < rhs.prerelease.count
}
}
This implementation neither trims whitespace nor accepts a v prefix. A strict parser should not silently repair protocol input. If an existing API permits those forms, normalize them in a separate compatibility layer and document the rules, rather than letting the client and server make independent guesses.
Lock Down Precedence with Fixed Test Cases
Cover at least four cases: increasing core numbers, a stable release taking precedence over a prerelease, numeric identifiers ranking below nonnumeric identifiers, and a longer prerelease identifier list ranking higher when the shared prefix is identical. The following tests can go directly into a unit test file. If the implementation lives in the app module, add a testable import for that module.
import XCTest
final class SemVersionTests: XCTestCase {
func testPrecedence() throws {
let ordered = [
"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta",
"1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11",
"1.0.0-rc.1", "1.0.0", "1.9.0", "1.10.0",
"999999999999999999999999.0.0"
]
for (a, b) in zip(ordered, ordered.dropFirst()) {
let left = try XCTUnwrap(SemVersion(a))
let right = try XCTUnwrap(SemVersion(b))
XCTAssertLessThan(left, right)
XCTAssertFalse(right < left)
}
}
func testMetadataAndInvalidInput() throws {
let first = try XCTUnwrap(SemVersion("1.2.3+001"))
let second = try XCTUnwrap(SemVersion("1.2.3+build.9"))
XCTAssertEqual(first, second)
for raw in [
"", "1.2", "01.2.3", "1.2.3-01", "1.2.3-",
"1.2.3+", "1.2.3+a..b", "1.2.3+a+b",
" 1.2.3", "1.2.3\n", "1.2.3"
] {
XCTAssertNil(SemVersion(raw), raw)
}
}
}
Do not test only adjacent versions. Add transitivity checks for arbitrary triples: if A is less than B and B is less than C, A must be less than C. Also verify that no version is less than itself, and that neither of two inputs with equal precedence is less than the other. These properties are more likely to expose comparator defects than an isolated upgrade scenario.
Feed Comparison Results into the Policy Layer
A Parse Failure Does Not Mean No Upgrade Is Needed
The application-facing API should explicitly distinguish three outcomes: the versions can be compared, the current version is invalid, or the remote version is invalid. When remote data is malformed, keep the current app functional and emit a searchable error event. Do not turn that failure into “You’re up to date.” If a mandatory-upgrade policy receives an invalid minimum version, follow a predefined failure-handling path instead of improvising.
Testing channels also need a separate eligibility check. A stable-channel client should not offer a prerelease merely because 2.0.0-beta.1 has higher precedence than 1.9.0. First filter for permitted channels, then compare versions, and finally apply staged rollout and minimum-supported-version rules.
When the same version is repackaged, metadata can help track the artifact, but it cannot change SemVer ordering. If those artifacts need to trigger updates, store a separate release sequence number, test that it increases monotonically, and explicitly define whether cross-channel comparisons are allowed.
Keep Reviewable Acceptance Results on Cloud Nodes
Add these tests to the unit test action of the existing shared Scheme, using the project’s pinned simulator destination. Record the source commit, toolchain version, test results, and failing cases. There is no need to connect to the production update API. This ensures that local and cloud runs for the same commit verify the same contract.
Before merging, check that every valid sample parses; no invalid input is silently repaired and accepted; metadata has no effect on precedence; very long numbers do not overflow; parse errors are not presented as “no update needed”; and the stable channel never selects a prerelease by mistake.
Finally, validate the tests themselves with a few deliberately broken implementations: replace numeric comparison with lexicographic ordering, include metadata in precedence, and allow leading zeros. Each change should make the corresponding test fail. Only when the gate catches these errors does it truly protect the upgrade flow, rather than merely adding another green row to the test report.
Frequently asked questions
Can string sorting decide whether an update is newer?
No. It can misorder 1.10.0 and 1.9.0 and does not implement prerelease precedence. Parse the version strictly, then compare its components under SemVer rules.
Should different build metadata trigger an upgrade?
SemVer excludes metadata after the plus sign from precedence. If rebuilt packages of the same version need different treatment, define a separate release sequence and upgrade policy.
Give your build pipeline a dedicated cloud Mac
Get an entire physical machine, not a virtual machine. Choose a model and rental term to match your build volume, and keep archives, signing, and caches on your own node.
Choose a cloud Mac plan