tech.sinayaka.com

App Store申請をPythonで自動化した話

2026-06-06
2026-07-18
5分
930語
iOS iOSPythonAppStoreConnect
目次

    iOSアプリを9本管理していて、全部に同じバグ修正を入れてまとめて申請する機会があった。

    手作業でやると、Xcodeでアーカイブ→エクスポート→App Store Connectにアップロード→バージョン作成→ビルド紐付け→リリースノート入力→審査申請、という手順を9回繰り返す。考えただけで嫌になったので全部Pythonで自動化した。

    App Store Connect APIとは

    Apple公式のREST APIで、アプリの管理・申請・メタデータ編集などをプログラムから操作できる。認証にはApp Store Connectで発行した秘密鍵(.p8ファイル)を使う。

    APIキーの取得は App Store Connect → ユーザーとアクセス → キー から。

    JWT認証

    App Store Connect APIはJWT(JSON Web Token)で認証する。

    import jwt, time
    
    KEY_ID = "XXXXXXXXXX"          # APIキーのID
    ISSUER_ID = "xxxxxxxx-xxxx-..."  # 発行者ID
    KEY_PATH = "~/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8"
    
    key = open(KEY_PATH).read()
    token = jwt.encode(
        {
            "iss": ISSUER_ID,
            "iat": int(time.time()),
            "exp": int(time.time()) + 1200,  # 20分有効
            "aud": "appstoreconnect-v1"
        },
        key,
        algorithm="ES256",
        headers={"kid": KEY_ID}
    )

    PyJWTcryptography が必要。

    pip install PyJWT cryptography

    アーカイブ前の準備

    Info.plistに暗号化コンプライアンスを書いておく。これを忘れると申請時に403エラーになる。

    <key>ITSAppUsesNonExemptEncryption</key>
    <false/>

    アーカイブ後にAPIで設定することもできるが、毎回やるのは面倒なので最初から入れておく方がいい。

    アーカイブからアップロードまで

    Pythonから xcodebuildxcrun altool を呼ぶ。

    # アーカイブ
    xcodebuild -project App.xcodeproj -scheme App \
      -configuration Release \
      -archivePath /tmp/App.xcarchive \
      -allowProvisioningUpdates archive
    
    # IPAエクスポート
    xcodebuild -exportArchive \
      -archivePath /tmp/App.xcarchive \
      -exportPath /tmp/App_export \
      -exportOptionsPlist exportOptions.plist \
      -allowProvisioningUpdates
    
    # アップロード
    xcrun altool --upload-app \
      -f /tmp/App_export/App.ipa \
      --apiKey KEY_ID \
      --apiIssuer ISSUER_ID \
      --private-key ~/.appstoreconnect/private_keys/AuthKey_KEY_ID.p8

    exportOptions.plist には最低限これだけあればいい。

    <dict>
        <key>method</key><string>app-store-connect</string>
        <key>teamID</key><string>XXXXXXXXXX</string>
        <key>signingStyle</key><string>automatic</string>
    </dict>

    Game Center など特定のCapabilityを使っているアプリでは signingStyle: automatic を入れないとエクスポートに失敗する。

    申請フロー

    ここが一番ハマった。古い記事や公式ドキュメントに POST /v1/appStoreVersionSubmissions が書いてあるが、このエンドポイントは廃止済みで403が返る。

    正しい新しいフローは reviewSubmissions を使う。

    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    
    def api_post(path, body):
        data = json.dumps(body).encode()
        req = urllib.request.Request(
            f"https://api.appstoreconnect.apple.com{path}",
            data=data, headers=headers, method="POST"
        )
        resp = urllib.request.urlopen(req).read()
        return json.loads(resp) if resp else True
    
    def api_patch(path, body):
        data = json.dumps(body).encode()
        req = urllib.request.Request(
            f"https://api.appstoreconnect.apple.com{path}",
            data=data, headers=headers, method="PATCH"
        )
        resp = urllib.request.urlopen(req).read()
        return json.loads(resp) if resp else True  # 204 No Content の場合は空

    PATCHのレスポンスが空(204 No Content)でも正常なので、JSONパースしようとするとクラッシュする。if resp else True で対処する。

    申請までの手順

    APP_ID = "123456789"
    BUILD_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    
    # 1. バージョン作成
    ver = api_post("/v1/appStoreVersions", {
        "data": {
            "type": "appStoreVersions",
            "attributes": {"platform": "IOS", "versionString": "1.0.2"},
            "relationships": {"app": {"data": {"type": "apps", "id": APP_ID}}}
        }
    })
    VER_ID = ver["data"]["id"]
    
    # 2. ビルド紐付け
    api_patch(f"/v1/appStoreVersions/{VER_ID}/relationships/build",
        {"data": {"type": "builds", "id": BUILD_ID}})
    
    # 3. リリースノート設定
    locs = api_get(f"/v1/appStoreVersions/{VER_ID}/appStoreVersionLocalizations")
    for loc in locs["data"]:
        lid, lang = loc["id"], loc["attributes"]["locale"]
        note = "バグ修正" if "ja" in lang else "Bug fixes"
        api_patch(f"/v1/appStoreVersionLocalizations/{lid}", {
            "data": {"type": "appStoreVersionLocalizations", "id": lid,
                     "attributes": {"whatsNew": note}}
        })
    
    # 4. 審査申請
    sub = api_post("/v1/reviewSubmissions", {
        "data": {
            "type": "reviewSubmissions",
            "attributes": {"platform": "IOS"},
            "relationships": {"app": {"data": {"type": "apps", "id": APP_ID}}}
        }
    })
    sid = sub["data"]["id"]
    
    # 5. バージョンをアイテムとして追加
    api_post("/v1/reviewSubmissionItems", {
        "data": {"type": "reviewSubmissionItems", "relationships": {
            "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": sid}},
            "appStoreVersion": {"data": {"type": "appStoreVersions", "id": VER_ID}}
        }}
    })
    
    # 6. 申請確定
    api_patch(f"/v1/reviewSubmissions/{sid}",
        {"data": {"type": "reviewSubmissions", "id": sid,
                  "attributes": {"submitted": True}}})

    ビルドのVALID待ち

    アップロード直後はビルドが処理中(PROCESSING)で、VALID になるまで紐付けできない。ポーリングで待つ。

    for i in range(20):
        builds = api_get(f"/v1/builds?filter[app]={APP_ID}&sort=-uploadedDate&limit=5")
        for b in builds["data"]:
            if b["attributes"]["version"] == "1.0.2.0":
                state = b["attributes"]["processingState"]
                if state == "VALID":
                    BUILD_ID = b["id"]
                    break
        if BUILD_ID:
            break
        time.sleep(30)

    アップロード後だいたい2〜5分で VALID になる。

    結果

    9アプリのアーカイブ→アップロード→申請が全自動で動いた。手作業なら半日かかる作業が30分程度のスクリプト実行で完了した。

    申請の取り下げ・再申請もAPIでできるが、WAITING_FOR_REVIEW 状態の申請をAPIでキャンセルすることはできないので、その場合はApp Store Connectのウェブ画面で取り下げてから再申請する必要がある。




    Copyright 2026
    サイトマップ