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}
)
PyJWT と cryptography が必要。
pip install PyJWT cryptography
アーカイブ前の準備
Info.plistに暗号化コンプライアンスを書いておく。これを忘れると申請時に403エラーになる。
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
アーカイブ後にAPIで設定することもできるが、毎回やるのは面倒なので最初から入れておく方がいい。
アーカイブからアップロードまで
Pythonから xcodebuild と xcrun 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のウェブ画面で取り下げてから再申請する必要がある。