Valider les décisions de mise à jour iOS avec SemVer
Tous les tests automatisés passent sur le Mac dans le cloud, mais le client considère 1.10.0 comme une version plus ancienne que 1.9.0. Le problème ne vient pas du build : la décision de mise à jour repose sur un tri de chaînes de caractères. Autre erreur fréquente : traiter un échec d’analyse comme une absence de mise à jour. Dès que le serveur envoie un format incorrect, l’accès à la mise à jour disparaît sans bruit.
Nous allons isoler les tests de décision de version en trois étapes : analyser strictement les entrées, comparer leur précédence, puis appliquer la politique de mise à jour. Tous les cas s’exécutent en local, sans réseau ni ressources de signature.
Définir les versions admises à la comparaison
Cet article traite des versions applicatives conformes à SemVer : trois composantes numériques, éventuellement suivies d’identifiants de préversion et de métadonnées de build. Par exemple : 2.4.0-rc.1+build.7. Il ne s’agit pas d’un validateur universel des champs de version des plateformes de distribution d’applications. Il ne remplace donc pas directement les règles de publication de CFBundleShortVersionString.
Les composantes numériques principales ne doivent pas comporter de zéros initiaux. La même règle s’applique aux identifiants de préversion entièrement numériques. Les identifiants de préversion et les métadonnées ne peuvent contenir que des lettres ASCII, des chiffres et des traits d’union ; aucun élément séparé par un point ne doit être vide. Les métadonnées autorisent les zéros initiaux et n’interviennent pas dans la comparaison de précédence.
« Version supérieure » ne signifie pas « mise à jour obligatoire ». La version minimale prise en charge, le périmètre du déploiement progressif, les canaux de test et le retrait d’une publication relèvent de la politique de mise à jour, pas du comparateur.
Fixons d’abord une règle : une entrée invalide provoque un échec d’analyse, jamais un repli sur 0.0.0. En production, il faut également limiter la longueur des chaînes à l’entrée, par exemple à 256 octets, afin que des données anormales ne consomment pas inutilement des ressources.
Implémenter un comparateur sans conversion en entier
Placez le code suivant dans un fichier Swift accessible à la cible de test. Les composantes numériques sont comparées d’abord par leur longueur, puis selon l’ordre ASCII. Il n’est donc pas nécessaire de convertir en Int des nombres très longs. Les métadonnées de build sont supprimées après validation : l’égalité synthétisée correspond ainsi à une même précédence.
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
}
}
Cette implémentation ne supprime pas automatiquement les espaces et n’accepte pas le préfixe v. Une validation stricte ne doit pas corriger implicitement les écarts au protocole. Si une interface existante autorise ces notations, effectuez leur conversion dans une couche de compatibilité distincte et documentez les règles appliquées. Le client et le serveur n’auront ainsi pas à deviner chacun leurs propres conventions.
Verrouiller la précédence avec des cas de test fixes
Il faut couvrir au moins quatre catégories : l’augmentation des composantes numériques principales, la précédence d’une version stable sur une préversion, la précédence inférieure des identifiants numériques par rapport aux identifiants non numériques et, à préfixe identique, la précédence supérieure d’une liste d’identifiants de préversion plus longue. Les tests suivants peuvent être placés directement dans un fichier de tests unitaires. Si l’implémentation se trouve dans le module de l’application, ajoutez l’import de ce module pour les tests.
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)
}
}
}
Ne testez pas uniquement les versions adjacentes. Ajoutez une vérification de transitivité sur des triplets quelconques : si A est inférieur à B et B inférieur à C, A doit être inférieur à C. Vérifiez également qu’une version n’est jamais strictement inférieure à elle-même et que, pour deux entrées de même précédence, aucune n’est strictement inférieure à l’autre. Ces propriétés révèlent plus facilement les défauts du comparateur qu’un cas isolé de mise à jour.
Relier la comparaison à la politique de mise à jour
Un échec d’analyse ne signifie pas qu’aucune mise à jour n’est nécessaire
L’interface métier devrait renvoyer explicitement trois résultats : versions comparables, version actuelle invalide ou version distante invalide. Si les données distantes sont erronées, conservez les fonctionnalités de l’application en cours et émettez un événement d’erreur consultable. Ne traduisez pas cette erreur par « Vous disposez déjà de la dernière version ». Si une politique de mise à jour obligatoire reçoit une version minimale invalide, elle doit suivre une procédure de gestion d’erreur définie à l’avance, et non improviser une décision.
Les canaux de test doivent eux aussi être évalués séparément. Le canal stable ne doit pas proposer l’installation d’une préversion au seul motif que 2.0.0-beta.1 est supérieur à 1.9.0. Filtrez d’abord les canaux autorisés, comparez ensuite les versions, puis appliquez les règles de déploiement progressif et de version minimale prise en charge.
Lorsqu’une même version est repackagée, les métadonnées peuvent servir à sa traçabilité, mais elles ne doivent pas modifier l’ordre SemVer. S’il faut mettre à jour ce type d’artefact, conservez en plus un numéro séquentiel de publication, testez sa monotonie et précisez si les comparaisons entre canaux sont autorisées.
Conserver des résultats de validation vérifiables sur les nœuds cloud
Ajoutez ces tests à l’exécution des tests unitaires du Scheme partagé existant, en conservant la destination de simulateur fixée par le projet. Consignez le commit source, la version de la chaîne d’outils, les résultats des tests et les entrées ayant échoué. Aucune connexion à l’interface de mise à jour de production n’est nécessaire. Pour un même commit, l’environnement local et le cloud vérifient ainsi le même contrat.
Avant la fusion, vérifiez les points suivants : tous les cas valides sont analysés ; aucune entrée invalide n’est corrigée puis acceptée ; les métadonnées n’affectent pas la précédence ; les nombres très longs ne provoquent aucun dépassement de capacité ; les erreurs d’analyse ne sont pas présentées comme une absence de mise à jour ; le canal stable ne sélectionne pas de préversion par erreur.
Enfin, vérifiez les tests eux-mêmes en introduisant volontairement quelques défauts dans l’implémentation : remplacez la comparaison numérique par un ordre lexicographique, faites intervenir les métadonnées dans le tri, puis autorisez les zéros initiaux. Chaque modification doit faire échouer le test correspondant. Ce n’est que si ce garde-fou détecte ces erreurs qu’il protège réellement l’accès à la mise à jour, au lieu d’ajouter simplement une ligne verte au tableau des tests.
Questions fréquentes
Un tri de chaînes suffit-il pour proposer une mise à jour ?
Non. Il peut mal ordonner 1.10.0 et 1.9.0 et ne respecte pas les règles des préversions. Il faut analyser les composants, puis les comparer selon SemVer.
Les métadonnées de build changent-elles la priorité ?
Non. SemVer ignore la partie située après le signe plus pour établir la priorité. Pour distinguer deux builds de même version, utilisez un numéro de publication distinct.
Un Mac cloud dédié pour vos builds
Une machine physique entièrement dédiée, pas une machine virtuelle. Choisissez le modèle et la durée selon votre volume de builds, et gardez archives, signatures et caches sur votre propre nœud.
Choisir une configuration Mac cloud