iOS-Updateentscheidungen mit striktem SemVer prüfen
Auf dem Cloud-Mac bestehen alle automatisierten Tests, doch der Client stuft 1.10.0 als älter als 1.9.0 ein. Das ist kein Build-Fehler: Die Updateprüfung vergleicht Versionen als Zeichenketten. Ein weiteres häufiges Problem besteht darin, einen Parsefehler als „Kein Update erforderlich“ zu behandeln. Liefert der Server ein ungültiges Format, verschwindet die Updatemöglichkeit unbemerkt.
Im Folgenden grenzen wir die Versionsprüfung als eigenständig testbaren Bereich ab: erst strikt parsen, dann die Rangfolge vergleichen und zuletzt die Updaterichtlinien anwenden. Sämtliche Beispiele laufen lokal, ohne Netzwerkzugriff und ohne Signierungsressourcen.
Festlegen, welche Versionen verglichen werden dürfen
Dieser Artikel behandelt Anwendungsversionen nach SemVer. Sie bestehen aus drei numerischen Feldern und können zusätzlich Vorabversionskennungen und Build-Metadaten enthalten, etwa 2.4.0-rc.1+build.7. Der hier gezeigte Ansatz ist kein allgemeiner Validator für Versionsfelder von App-Veröffentlichungsplattformen und ersetzt nicht die Veröffentlichungsregeln für CFBundleShortVersionString.
Die Zahlen der Kernversion dürfen keine führenden Nullen enthalten. Dasselbe gilt für rein numerische Vorabversionskennungen. Vorabversionskennungen und Metadaten dürfen nur ASCII-Buchstaben, Ziffern und Bindestriche enthalten; kein durch Punkte getrennter Bestandteil darf leer sein. In Metadaten sind führende Nullen erlaubt. Die Metadaten fließen jedoch nicht in den Vergleich der Rangfolge ein.
„Höhere Version“ bedeutet nicht „Update zwingend erforderlich“. Mindestversion, Zielgruppe eines gestaffelten Rollouts, Testkanäle und zurückgezogene Releases gehören zur Richtlinienebene, nicht in den Vergleicher.
Die erste Festlegung lautet daher: Ungültige Eingaben führen zu einem Parsefehler, nicht zu einem Rückfall auf 0.0.0. Am produktiven Eingang sollte außerdem die Zeichenkettenlänge begrenzt werden, beispielsweise auf maximal 256 Byte, damit fehlerhafte Daten nicht unnötig Ressourcen beanspruchen.
Einen Vergleicher ohne Ganzzahlkonvertierung implementieren
Der folgende Code gehört in eine Swift-Datei, auf die das Test-Target zugreifen kann. Numerische Felder werden zuerst nach Länge und bei gleicher Länge nach ASCII-Reihenfolge verglichen. Dadurch müssen auch sehr lange Zahlen nicht in Int umgewandelt werden. Build-Metadaten werden nach der Validierung verworfen. Damit entspricht auch die automatisch synthetisierte Gleichheitsprüfung der Bedeutung „gleiche Rangfolge“.
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
}
}
Diese Implementierung entfernt keine Leerzeichen und akzeptiert kein vorangestelltes v. Eine strikte Eingabeprüfung darf Protokollabweichungen nicht stillschweigend korrigieren. Falls eine bestehende Schnittstelle solche Schreibweisen erlaubt, sollten sie in einer separaten Kompatibilitätsschicht umgewandelt werden. Die Umwandlungsregeln müssen dokumentiert sein, damit Client und Server nicht jeweils eigene Annahmen treffen.
Die Rangfolge mit festen Testfällen absichern
Mindestens vier Fälle müssen abgedeckt sein: aufsteigende Zahlen in der Kernversion, eine höhere Rangfolge regulärer Releases gegenüber Vorabversionen, eine niedrigere Rangfolge numerischer gegenüber nicht numerischen Kennungen sowie eine höhere Rangfolge längerer Vorabversionslisten bei identischem gemeinsamen Präfix. Die folgenden Tests lassen sich direkt in eine Unit-Test-Datei übernehmen. Liegt die Implementierung im App-Modul, muss dessen Testimport ergänzt werden.
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)
}
}
}
Tests benachbarter Versionen allein reichen nicht aus. Zusätzlich sollte für beliebige Dreiergruppen die Transitivität geprüft werden: Ist A kleiner als B und B kleiner als C, muss auch A kleiner als C sein. Ebenso ist zu prüfen, dass kein Wert kleiner als er selbst ist und dass bei zwei Eingaben mit gleicher Rangfolge keine kleiner als die andere ist. Solche Eigenschaften decken Fehler in der Vergleichslogik leichter auf als einzelne Updatefälle.
Das Vergleichsergebnis an die Richtlinienebene übergeben
Ein Parsefehler bedeutet nicht „Kein Update erforderlich“
Die Anwendungsschnittstelle sollte ausdrücklich drei Ergebnisse unterscheiden: vergleichbar, aktuelle Version ungültig und entfernte Version ungültig. Bei fehlerhaften Serverdaten bleiben die bestehenden App-Funktionen verfügbar; zugleich wird ein durchsuchbares Fehlerereignis erfasst. Daraus darf nicht die Meldung „Die aktuelle Version ist bereits die neueste“ werden. Ist bei einer erzwungenen Aktualisierung die angegebene Mindestversion ungültig, muss ein vorab definierter Fehlerbehandlungspfad greifen, statt spontan eine Annahme zu treffen.
Auch Testkanäle müssen separat bewertet werden. Im regulären Releasekanal darf nicht allein deshalb die Installation einer Vorabversion angeboten werden, weil 2.0.0-beta.1 höher eingestuft wird als 1.9.0. Zuerst werden die zulässigen Kanäle ausgewählt, danach die Versionen verglichen und schließlich die Regeln für gestaffelte Rollouts und Mindestversionen angewendet.
Wird dieselbe Version neu paketiert, können Metadaten der Nachverfolgung dienen, dürfen aber die SemVer-Reihenfolge nicht verändern. Sollen solche Artefakte aktualisiert werden können, ist eine zusätzliche Release-Sequenznummer zu speichern und auf monotones Wachstum zu testen. Außerdem muss klar festgelegt sein, ob kanalübergreifende Vergleiche zulässig sind.
Nachprüfbare Abnahmeergebnisse auf dem Cloud-Knoten hinterlassen
Die Tests werden in die Unit-Test-Ausführung des bestehenden gemeinsam genutzten Schemes aufgenommen. Als Testziel dient weiterhin der im Projekt festgelegte Simulator. Zu protokollieren sind Quellcode-Commit, Toolchain-Version, Testergebnisse und fehlgeschlagene Testfälle. Eine Verbindung zur produktiven Updateschnittstelle ist nicht nötig. So wird für denselben Commit lokal und in der Cloud derselbe Vertrag geprüft.
Vor dem Merge ist zu kontrollieren: Alle gültigen Beispiele lassen sich parsen; keine ungültige Eingabe wird nach einer stillschweigenden Korrektur akzeptiert; Metadaten beeinflussen die Rangfolge nicht; sehr lange Zahlen verursachen keinen Überlauf; Parsefehler erscheinen nicht als „Kein Update erforderlich“; und der reguläre Releasekanal wählt nicht versehentlich eine Vorabversion aus.
Zum Schluss werden die Tests selbst mit gezielt fehlerhaften Implementierungen überprüft: den numerischen Vergleich durch einen lexikografischen ersetzen, Metadaten in die Sortierung einbeziehen und führende Nullen zulassen. Jede dieser Änderungen muss den zugehörigen Test fehlschlagen lassen. Erst wenn das Quality-Gate solche Fehler erkennt, sichert es die Updateprüfung tatsächlich ab, statt nur eine weitere grüne Zeile in der Testübersicht zu erzeugen.
Häufig gestellte Fragen
Reicht das Sortieren von Zeichenketten für die Updateprüfung?
Nein. Dabei können 1.10.0 und 1.9.0 falsch eingeordnet werden; auch Vorabversionen werden nicht korrekt behandelt. Erst strikt parsen, dann die Bestandteile nach SemVer vergleichen.
Ändern Build-Metadaten den Rang einer Version?
Nein. Der Teil hinter dem Pluszeichen beeinflusst den SemVer-Rang nicht. Für neu gebaute Pakete derselben Version braucht es eine separate Veröffentlichungsnummer und eine eigene Updateregel.
Ein exklusiver Cloud-Mac für Ihre Build-Pipeline
Ein kompletter physischer Mac nur für Sie – keine virtuelle Maschine. Wählen Sie Modell und Laufzeit passend zu Ihrem Build-Volumen und behalten Sie Archive, Signaturen und Caches auf Ihrem eigenen System.
Cloud-Mac konfigurieren