クラウド Mac で検証する iOS 更新判定と SemVer の境界

クラウド Mac で検証する iOS 更新判定と SemVer の境界

クラウド Mac で検証する iOS 更新判定と SemVer の境界

クラウド Mac 上の自動テストはすべて通っているのに、クライアントが 1.10.0 を 1.9.0 より古いと判定する。これはビルドの不具合ではなく、更新判定で文字列順の比較を使っていることが原因です。もう一つよくあるのが、解析失敗を「更新不要」として扱う問題です。サーバーが不正な形式のバージョンを返すと、更新の導線が気づかれないまま消えてしまいます。

ここでは、バージョン判定を独立してテストできるよう、厳密な解析、優先順位の比較、更新ポリシーの適用という順に責務を分けます。すべてのサンプルはローカルで実行でき、ネットワークにも署名用アセットにも依存しません。

比較対象として受け入れるバージョンを定義する

本記事で扱うのは、SemVer に準拠したアプリケーションのバージョンです。3 つの数値フィールドで構成され、プレリリース識別子とビルドメタデータを付加できます。たとえば 2.4.0-rc.1+build.7 です。アプリ配信プラットフォームのバージョンフィールド全般を検証するものではなく、CFBundleShortVersionString のリリース規則の代わりには使えません。

コアバージョンの数値では、先頭のゼロを認めません。プレリリース識別子が数字のみの場合も同様です。プレリリースとメタデータで使える文字は ASCII の英字、数字、ハイフンのみで、ドットで区切った各要素を空にはできません。メタデータでは数値の先頭にゼロを付けられますが、優先順位の比較には使いません。

「バージョンが新しい」ことと「更新が必須」であることは別です。最低サポートバージョン、段階的ロールアウトの対象範囲、テストチャネル、リリースの取り下げはポリシー層の責務であり、比較器に組み込んではいけません。

まず、不正な入力は解析失敗として返し、0.0.0 に置き換えないと決めます。本番環境の入力受付では、異常なデータによる不要なリソース消費を避けるため、文字列長にも上限を設けます。たとえば最大 256 バイトです。

整数変換を使わない比較器を実装する

以下のコードを、テストターゲットから参照できる Swift ファイルに配置します。数値フィールドは、まず桁数を比較し、同じ桁数なら ASCII 順で比較するため、非常に長い数値も Int に変換する必要がありません。ビルドメタデータは検証後に破棄するので、自動合成される等価判定も「優先順位が同じ」という意味に一致します。

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
    }
}

この実装では、空白を自動で除去せず、v 接頭辞も受け入れません。厳密な入力処理が、暗黙のうちにプロトコル違反を補正すべきではありません。既存のインターフェースがこうした表記を許容している場合は、独立した互換レイヤーで変換し、その規則を記録します。クライアントとサーバーがそれぞれ独自に解釈する事態を避けるためです。

固定サンプルで優先順位を保証する

最低限、次の 4 種類をカバーします。コアバージョンの数値が増えること、正式版がプレリリース版より上位になること、数字のみの識別子がそれ以外の識別子より下位になること、共通する接頭部分が同じならプレリリース識別子のリストが長いほうが上位になることです。以下のテストは単体テストファイルにそのまま配置できます。実装がアプリのモジュール内にある場合は、そのモジュールのテスト用インポートを追加してください。

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)
        }
    }
}

隣り合うバージョンのテストだけでは不十分です。任意の 3 つのバージョンについて、推移性も検証します。A が B より小さく、B が C より小さいなら、A は C より小さくなければなりません。また、自分自身より小さいとは判定されないこと、優先順位が同じ 2 つの入力は互いに相手より小さいとは判定されないことも確認します。こうした性質の検証は、個別の更新ケースを調べるよりも比較ロジックの欠陥を見つけやすくなります。

比較結果をポリシー層につなぐ

解析失敗は「更新不要」ではない

アプリケーション側のインターフェースでは、「比較可能」「現在のバージョンが不正」「リモートのバージョンが不正」の 3 つを明示的に返す設計が望まれます。リモートデータが不正な場合は、現在のアプリ機能を維持し、検索可能なエラーイベントを記録します。「すでに最新バージョンです」に置き換えてはいけません。強制更新ポリシーで最低バージョンの値が不正だった場合も、その場で推測せず、あらかじめ定義した障害処理の分岐に進めます。

テストチャネルも独立して判定する必要があります。正式版チャネルでは、2.0.0-beta.1 が 1.9.0 より上位だからという理由だけで、プレリリース版のインストールを案内してはいけません。まず許可されたチャネルで候補を絞り、次にバージョンを比較し、最後に段階的ロールアウトと最低サポートバージョンの規則を適用します。

同じバージョンを再パッケージする場合、メタデータは追跡に利用できますが、SemVer の順序を変えるためには使えません。このような成果物の更新が必要なら、別途リリース通し番号を保持して単調増加をテストし、チャネル間の比較を許可するかどうかも明確にします。

クラウドノードに再確認できる検証結果を残す

これらのテストを既存の共有 Scheme の単体テスト実行対象に追加し、プロジェクトで固定しているシミュレータの実行先をそのまま使います。ソースコードのコミット、ツールチェーンのバージョン、テスト結果、失敗したサンプルを記録すれば、本番の更新 API に接続する必要はありません。これにより、同じコミットに対してローカルでもクラウドでも同一の契約を検証できます。

マージ前には、正しいサンプルがすべて解析できること、不正な入力が補正されて通過することがないこと、メタデータが優先順位に影響しないこと、非常に長い数値でもオーバーフローしないことを確認します。加えて、解析エラーが「更新不要」と表示されないこと、正式版チャネルが誤ってプレリリース版を選ばないことも確認してください。

最後に、意図的に壊した実装をいくつか用意し、テスト自体を検証します。数値比較を辞書順に変える、メタデータを並べ替えに含める、先頭のゼロを許可するといった変更です。どの変更でも、対応するテストが失敗しなければなりません。こうした誤りを品質ゲートで検出できて初めて、単にテスト画面の緑色の行を増やすのではなく、更新判定の入口を実際に守れたと言えます。

よくある質問

文字列のソートだけで更新を判定できますか?

できません。1.10.0 と 1.9.0 の順序を誤る可能性があり、プレリリースの規則も反映できません。厳密に解析してから構成要素を比較します。

ビルドメタデータが変わると優先順位も変わりますか?

変わりません。SemVer はプラス記号以降を優先順位の計算に使いません。同一バージョンの再ビルドを区別するなら、別の配信番号と判定規則を用意します。

ビルド専用のクラウド Mac を

物理マシン1台をまるごと専有でき、仮想マシンではありません。ビルド量に合わせてマシンと利用期間を選べば、アーカイブ、署名、キャッシュを専用ノードに保管できます。

クラウド Mac のプランを選ぶ

次のアーカイブは、専有クラウド Mac でビルド

ビルド量に合わせてマシンを選び、チームの所在地に合わせてノードを選択。プランを確認してから、ツールチェーンの移行を始めましょう。