tech.sinayaka.com

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

2026-06-06
2026-06-05
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
サイトマップ