0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSアプリ12本をApp Storeへ無人提出する基盤 後編 ― 審査の罠全部(betaビルド弾き・価格・名前衝突)

0
Posted at

解雇通知を受け取った日、手元の残高は0円でした。半年後、Claude Code で自律環境を組み直し、今は月商120万を超えています。その柱のひとつが、App Store への無人提出パイプラインです。

前回(前編)は JWT 認証の組み方と asc.py の骨格を解説しました。今回は実戦投入して判明した「審査リジェクトの罠」を3本まとめます。ITMS-90111 の真因が BuildMachineOSBuild だった話、appPriceSchedules の正解形、en-US ロケールの名前衝突回避ーー順番に実コードで掘り下げます。

なぜこの仕組みが効くのか

作業を自動化するのではなく、作業が起きない環境を作る。これがこのパイプラインの核心です。

iOSアプリを12本並走させると、ストアへの提出だけで途方もない手数が発生します。ひとつのアプリを手作業で審査提出しようとすると、Xcode でアーカイブ → Organizer でアップロード → App Store Connect でバージョン情報を入力 → スクリーンショットを差し替え → 審査提出ボタンを押す、という流れを踏みます。慣れても1本あたり20〜30分。12本では丸一日消えます。それを月に数回繰り返せば、個人開発者のリソースはすぐに枯渇します。

ところが App Store Connect API(以下 ASC API)は、JWT+.p8 秘密鍵さえあれば 2FA なしで叩ける仕様です。これがすべてを変えます。人間のログインセッションを必要としないため、Claude Code が深夜3時に自律実行しても同じ権限で動作します。

~/.appstoreconnect/asc.py の JWT 生成部分はこうなっています。

def _jwt():
    h = _b64(json.dumps({"alg":"ES256","kid":KEY_ID,"typ":"JWT"},separators=(",",":")).encode())
    p = _b64(json.dumps({"iss":ISSUER,"iat":int(time.time())-30,"exp":int(time.time())+900,
                         "aud":"appstoreconnect-v1"},separators=(",",":")).encode())
    signing = h + b"." + p
    key = serialization.load_pem_private_key(open(P8,"rb").read(), password=None)
    der = key.sign(signing, ec.ECDSA(hashes.SHA256()))
    r,s = decode_dss_signature(der)
    return (signing + b"." + _b64(r.to_bytes(32,"big")+s.to_bytes(32,"big"))).decode()

aud:"appstoreconnect-v1" と exp が900秒(15分)というのが Apple の要求する形式です。標準ライブラリの PyJWT を使わず手書きにしているのは、依存を最小化してどこでも動かすためです。cryptography パッケージひとつで ES256 署名が完結します。

認証情報は ~/.appstoreconnect/keys.json 一か所に集約し、どのアプリに対しても同じスクリプトを使い回せる設計にしました。CFG = json.load(open(os.path.expanduser("~/.appstoreconnect/keys.json"))) でキー読み込みが終わり、あとはアプリIDを引数に渡すだけです。

もうひとつ重要なのが状態管理です。ASC API はバージョンが編集可能な状態かどうかを厳密に管理します。asc.py の _EDITABLE 定数がその判断基準です。

_EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED",
            "METADATA_REJECTED", "INVALID_BINARY"}

この5状態にある場合だけ versionString の変更やビルドの差し替えが許可されます。IN_REVIEW や WAITING_FOR_REVIEW 中に PATCH を投げてもエラーになるだけです。スクリプトが事前にここを確認してから操作を分岐させることで、無駄なAPIコールを排除しています。

「作業をしない」ための仕組みはもうひとつあります。冪等性です。たとえば add_tester() はテスター追加の前に _has_tester() で既登録かどうかを確認し、登録済みなら処理をスキップします。submit() も READY_FOR_REVIEW 状態の reviewSubmission が既にあれば新規作成せず再利用します。こうしておくと、Claude Code が同じコマンドを二度実行しても副作用が出ません。

月商120万の実態は「仕事量の積み増し」ではありません。同じ仕組みが並列で動いているだけです。アプリが増えても、スクリプトへ渡すアプリIDが増えるだけで構造は変わりません。

全体の流れ

パイプライン全体を俯瞰すると、次の4レイヤーに分かれます。

┌─────────────────────────────────────────────────────────┐
│ 1. 署名準備                                               │
│    setup_signing.py                                      │
│    ・SHA1 で Distribution 証明書を特定                    │
│    ・App ID(bundleId)を作成または確認                   │
│    ・.mobileprovision を生成して Keychain に配置           │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│ 2. ビルド&アーカイブ                                     │
│    tools/archive.sh                                      │
│    ・xcodegen generate でプロジェクト再生成               │
│    ・xcodebuild archive(Manual Signing)                │
│    ・xcodebuild -exportArchive → .ipa 生成               │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│ 3. バイナリ投入                                           │
│    eas submit / altool                                   │
│    ・.ipa を App Store Connect へアップロード             │
│    ・処理完了まで待機(processingState: VALID)           │
└────────────────────┬────────────────────────────────────┘
                     │
┌────────────────────▼────────────────────────────────────┐
│ 4. メタデータ&審査提出                                   │
│    asc.py release → dedup → submit                      │
│    ・make_version: 編集可能バージョンを確保               │
│    ・attach_build: VALID ビルドを紐づけ                   │
│    ・set_whatsnew: 全ロケールの更新情報を設定              │
│    ・dedup: スクショ重複を除去                            │
│    ・submit: reviewSubmission 作成→item追加→提出         │
└─────────────────────────────────────────────────────────┘

レイヤー1:署名準備

~/dev/auraly-ios/tools/setup_signing.py が担う署名の自動化は、証明書の突合から始まります。

LOCAL_SHA1 = "EC06777A693874E920CECFE390D467670552CCCE".lower()

def find_cert():
    _, d = asc.call("GET", "/v1/certificates?limit=200")
    for c in d.get("data", []):
        content = c["attributes"].get("certificateContent")
        if not content:
            continue
        der = base64.b64decode(content)
        sha1 = hashlib.sha1(der).hexdigest()
        if sha1 == LOCAL_SHA1:
            return c["id"]

/v1/certificates が返す certificateContent は Base64 エンコードの DER です。これを SHA1 でフィンガープリントし、ローカルの Keychain にある Distribution 証明書と照合します。名前検索ではなくフィンガープリント照合にしているのは、「Apple Distribution: ...(複数)」のように名前が重複するケースを確実に回避するためです。

一致する証明書が見つかったら、App ID(bundleId)を確認または作成し、AppStore タイプのプロビジョニングプロファイルを生成します。生成されたプロファイルは ~/Library/MobileDevice/Provisioning Profiles/{uuid}.mobileprovision へ書き込まれ、xcodebuild がそのまま認識できる状態になります。既存の同名プロファイルは事前に DELETE してから作り直す設計で、証明書ローテーション後に古いプロファイルが残ってビルドが失敗する問題を防いでいます。

レイヤー2:ビルド&アーカイブ

~/dev/auraly-ios/tools/archive.sh の核心部は17行です。

xcodegen generate
rm -rf build/Auraly.xcarchive build/export

xcodebuild archive \
  -project Auraly.xcodeproj \
  -scheme Auraly \
  -configuration Release \
  -archivePath build/Auraly.xcarchive \
  -destination 'generic/platform=iOS' \
  CODE_SIGN_STYLE=Manual \
  CODE_SIGN_IDENTITY="Apple Distribution" \
  PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" \
  -allowProvisioningUpdates

xcodebuild -exportArchive \
  -archivePath build/Auraly.xcarchive \
  -exportOptionsPlist ExportOptions.plist \
  -exportPath build/export

CODE_SIGN_STYLE=Manual と PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" の組み合わせで、Xcode の自動署名を完全に無効化し、直前のレイヤーで生成したプロファイルを名前で指定して使います。-allowProvisioningUpdates は残してありますが、プロファイルが既にローカルに置かれているため実際にはApple サーバーへの問い合わせは起きません。

xcodegen generate を毎回先頭で実行しているのは意図的です。project.yml を唯一の真実の情報源とし、.xcodeproj の手動編集差分がビルドに紛れ込まないようにするためです。

レイヤー3:バイナリ投入

生成された .ipa を App Store Connect へ届けるのは eas submit か altool です。このレイヤーはスクリプト制御の対象外で、処理完了(processingState: VALID)まで非同期に待ちます。asc.py の _build_for_version() が、指定バージョンで VALID になったビルドが存在するかどうかをポーリングする役割を担います。

def _build_for_version(app_id, version_string):
    _, b = call("GET", f"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate"
                       f"&include=preReleaseVersion")
    incl = {i["id"]: i for i in b.get("included", []) if i["type"] == "preReleaseVersions"}
    for x in b.get("data", []):
        if x["attributes"].get("processingState") != "VALID":
            continue
        pr = x.get("relationships", {}).get("preReleaseVersion", {}).get("data")
        ver = incl.get(pr["id"], {}).get("attributes", {}).get("version") if pr else None
        if ver == version_string:
            return x["id"], x["attributes"].get("version")
    return None

processingState が VALID 以外(PROCESSING や FAILED)のビルドは添付対象外として読み飛ばします。include=preReleaseVersion で marketing version を一発で取得し、バージョン文字列で絞り込むことで、複数ビルドが存在する状況でも意図したバージョンだけを掴めます。

レイヤー4:メタデータ&審査提出

asc.py submit() が踏む手順は3ステップです。

def submit(app_id, platform="IOS"):
    # 1) reviewSubmission を作成(既存 in-progress があれば再利用)
    _, rs = call("GET",
        f"/v1/reviewSubmissions?filter[app]={app_id}&filter[state]=READY_FOR_REVIEW&limit=1")
    sub = (rs.get("data") or [None])[0]
    if not sub:
        st, r = call("POST", "/v1/reviewSubmissions",
            {"data": {"type": "reviewSubmissions", "attributes": {"platform": platform},
                      "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}}})
        sub = r["data"]
    sid = sub["id"]
    # 2) バージョンを item として追加
    ver = _latest_version(app_id); vid = ver["id"]
    st, r = call("POST", "/v1/reviewSubmissionItems",
        {"data": {"type": "reviewSubmissionItems",
                  "relationships": {
                      "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": sid}},
                      "appStoreVersion": {"data": {"type": "appStoreVersions", "id": vid}}}}})
    # 3) submitted=true で提出
    st, r = call("PATCH", f"/v1/reviewSubmissions/{sid}",
        {"data": {"type": "reviewSubmissions", "id": sid,
                  "attributes": {"submitted": True}}})

旧来の /v1/apps/{id}/appStoreVersions/{vid}/... 系エンドポイントではなく、reviewSubmissions / reviewSubmissionItems の新系エンドポイントを使っています。Apple はドキュメントでこちらを推奨しており、旧系は段階的に廃止される方向です。3ステップのうち最初の「既存チェック」を省くと、同じアプリへの二重送信でエラーになるため、GET で状態確認してから POST するのがポイントです。

スクショ重複の dedup は submit の直前に必ず実行します。deliver や fastlane でスクリーンショットを同期し直すと、前回分と今回分が同一ファイルのまま2枚追加されることがあります。dedup_screenshots() は sourceFileChecksum と fileName の組み合わせをキーとし、2枚目以降を DELETE します。apply=False で dry-run、--apply フラグで実際に削除するため、誤削除リスクを事前確認で排除できます。


ここまでが「動くパイプライン」の全体像です。次章からは、このパイプラインを12アプリへ本番投入して実際に踏んだリジェクト理由を、エラーコードと修正コードを添えて解説します。

実装の詳細

reject() ― 「差し戻し」なしには次の提出ができない

12本を並走させると、審査中に軽微なバグを発見して「今すぐ直したい」という状況が必ず発生します。WAITING_FOR_REVIEW や IN_REVIEW 状態のアプリに make_version() や attach_build() を投げても、ASC API は無言で 422 を返すだけです。編集可能な状態へ戻すために必要なのが reject() です。

def reject(app_id):
    _, rs = call("GET", f"/v1/reviewSubmissions?filter[app]={app_id}&limit=10")
    done = False
    for x in rs.get("data", []):
        state = x["attributes"].get("state")
        if state in ("WAITING_FOR_REVIEW", "IN_REVIEW", "UNRESOLVED_ISSUES"):
            st, r = call("PATCH", f"/v1/reviewSubmissions/{x['id']}",
                {"data": {"type": "reviewSubmissions", "id": x["id"],
                          "attributes": {"canceled": True}}})
            print("  reject", x["id"], state, "->", st if st < 400 else json.dumps(r)[:400])
            done = True
    if not done:
        print("  審査中の submission 無し")

canceled: True を PATCH するだけで審査が取り下がり、バージョンが DEVELOPER_REJECTED に遷移します。_EDITABLE にはこの状態が含まれているため、直後に make_version() → attach_build() → submit() の流れへ戻れます。UNRESOLVED_ISSUES を対象に含めているのは、App Review から追記質問が来ている状態でも同じコマンドで取り下げられるようにするためです。

Developer Rejection はペナルティなしの操作です。審査履歴には記録されますが、審査回数のカウントには響きません。むしろ不備のあるバイナリを通そうとして REJECTED を食らうほうが、次サイクルまでの待機時間が伸びます。「直したくなったら即 reject → 修正 → 再 submit」がサイクルコストを最小化する正しい動き方です。

release() ― 3ステップをひとつの引数で動かすオーケストレーター

def release(app_id, version, whatsnew, platform="IOS"):
    print("release", app_id, version)
    vid = make_version(app_id, version, platform)
    if not vid: return
    attach_build(app_id, vid, version)
    set_whatsnew(app_id, whatsnew, vid)
    print("  done. 確認後に `submit` を実行。")

release() は make_version → attach_build → set_whatsnew を直列に束ねる薄いオーケストレーターです。意図的に submit() は含めていません。ビルドが添付されてメタデータが揃った段階でいったん止まり、status コマンドで確認してから submit を投げる設計です。全自動にするとミスを発見する間もなく審査へ流れてしまうため、最後の 1 コマンドだけは意図的に分離しました。

Claude Code からはこの 3 行で完結します。

python3 ~/.appstoreconnect/asc.py release 6782624281 1.4.0 "バグ修正と安定性の向上"
python3 ~/.appstoreconnect/asc.py dedup 6782624281 --apply
python3 ~/.appstoreconnect/asc.py submit 6782624281

「バージョン準備 → スクショ重複除去 → 審査提出」が 3 行です。これを 12 アプリ分並列実行しても、ASC API のレートリミット(1 分あたり 1000 リクエスト程度が目安)に引っかかる前にさばき切れます。

add_tester() ― 「グループ経由で POST」が唯一の正解

def add_tester(app_id, email=DEFAULT_TESTER_EMAIL, first="Lily", last="Tester"):
    if _has_tester(app_id, email):
        print(f"  {email} は既にテスター(skip)"); return
    _, g = call("GET", f"/v1/apps/{app_id}/betaGroups?limit=50")
    internal = next((x for x in g.get("data", []) if x["attributes"].get("isInternalGroup")), None)
    if not internal:
        st, r = call("POST", "/v1/betaGroups",
            {"data": {"type": "betaGroups",
                      "attributes": {"name": "Internal", "isInternalGroup": True},
                      "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}}})
        if st >= 400:
            print("  create internal group failed", st, json.dumps(r)[:600]); return
        internal = r["data"]; print("  created internal group", internal["id"])
    gid = internal["id"]
    st, r = call("POST", "/v1/betaTesters",
        {"data": {"type": "betaTesters",
                  "attributes": {"email": email, "firstName": first, "lastName": last},
                  "relationships": {"betaGroups": {"data": [{"type": "betaGroups", "id": gid}]}}}})
    if st >= 400 and not _has_tester(app_id, email):
        print("  add tester failed", st, json.dumps(r)[:600]); return
    print(f"  テスター {email} を内部グループ {gid} に追加")

コメントに書いてある通り、betaGroups/{id}/relationships/betaTesters への直接 POST は 409 STATE_ERROR("Tester cannot be assigned")で弾かれます。テスターを追加するときは /v1/betaTesters への POST に betaGroups リレーションシップを含めて 「テスター作成と同時にグループへ紐づける」 一発オペレーションが唯一の正解です。「グループを作ってからテスターを追加する」という直感的な手順を踏むと、延々と 409 が出るだけです。

内部テスターは Beta App Review 不要でインストールできるため、審査提出後すぐ自分の iCloud でインストール確認できます。add_tester を提出後に呼ぶのはパイプラインの定番ルーティンになりました。


私が詰まった話

12本を本番投入するまでの間に、3種類のリジェクトがパイプラインを止めました。順番に「症状 → 原因 → 直し方」を書きます。

罠①:ITMS-90111 の真因は「beta macOS でビルドした」だった

症状。 archive.sh を実行して .ipa を App Store Connect へアップロードすると、処理完了後のメールに ITMS-90111 が届きました。

ERROR ITMS-90111: "Invalid Binary.
The value for key BuildMachineOSBuild in the Info.plist
file at Payload/Auraly.app/Info.plist is not valid."

App Store Connect の web 画面にはバイナリが「無効」と表示され、_build_for_version() がいつまで経っても processingState == "VALID" のビルドを返さない状態になります。再アップロードしても同じ。asc.py status で build 欄を確認すると FAILED と出るだけです。

原因。 xcodebuild はアーカイブ実行時に、ビルドを走らせた macOS のバージョンを BuildMachineOSBuild キーとして Info.plist へ自動書き込みします。archive.sh のコマンド自体は何も悪くありません。

xcodebuild archive \
  -project Auraly.xcodeproj \
  -scheme Auraly \
  -configuration Release \
  -archivePath build/Auraly.xcarchive \
  -destination 'generic/platform=iOS' \
  CODE_SIGN_STYLE=Manual \
  CODE_SIGN_IDENTITY="Apple Distribution" \
  PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" \
  -allowProvisioningUpdates

問題はこのコマンドを実行した macOS が beta 版だったことです。macOS 15 Sequoia の GA リリース前に beta(ビルド番号 25A5xxx 系)で動かしていたため、beta を示す文字列が BuildMachineOSBuild に埋め込まれ、Apple のバリデーターが弾きました。xcodebuild に BuildMachineOSBuild を上書きするオプションは存在せず、ビルドマシンの OS 自体を正式リリース版に戻すしかありません。

直し方。 macOS を beta から正式リリース版へダウングレードして再アーカイブしました。ダウングレードが難しい環境では、GitHub Actions の macos-latest ランナー(常に GA 版)でアーカイブするのが現実的です。以降は「開発作業は beta OK・アーカイブだけは GA 専用マシン」という運用に切り替えました。このルールを忘れて再び踏まないよう、archive.sh の先頭に sw_vers -buildVersion の出力を表示する行を加えてあります。

罠②:appPriceSchedules は「included と null の startDate」が正解

症状。 新規アプリを無料(Free)で登録しようと価格設定エンドポイントへ POST したところ、422 を返し続けました。

422 {"errors":[{"status":"422","code":"INVALID_ENTITY",
"detail":"The provided entity includes invalid relationship data."}]}

エラーメッセージが汎用すぎて何が間違っているかわかりません。Apple のドキュメントには「manualPrices を relationships に渡せ」と書いてあるだけで、具体的なボディ構造の例が少ない。最初に試したのはこの形でした。

{
  "data": {
    "type": "appPriceSchedules",
    "attributes": {},
    "relationships": {
      "app": {"data": {"type": "apps", "id": "<app_id>"}},
      "manualPrices": {"data": [{"type": "appPrices", "id": "p1"}]}
    }
  }
}

原因。 manualPrices は インライン resource(included)を使う形式です。id: "p1" で参照しているにも関わらず、その p1 の実体を included 配列に入れていないため、「参照先のデータが存在しない」として 422 になります。さらに落とし穴がもうひとつあり、「即時有効」を意味する startDate は空文字列 "" ではなく JSON の null でなければなりません。"" を入れると別の 422 が返ります。

直し方。 included に appPrices オブジェクトを明示的に含め、startDate を null にします。

{
  "data": {
    "type": "appPriceSchedules",
    "attributes": {},
    "relationships": {
      "app": {"data": {"type": "apps", "id": "<app_id>"}},
      "manualPrices": {"data": [{"type": "appPrices", "id": "p1"}]}
    }
  },
  "included": [
    {
      "type": "appPrices",
      "id": "p1",
      "attributes": {
        "startDate": null,
        "customerPrice": "0"
      },
      "relationships": {
        "territory": {"data": {"type": "territories", "id": "JPN"}}
      }
    }
  ]
}

ただし、175 か国すべての included を列挙するのは実用的ではありません。最終的に asc.py へ価格設定関数を組み込むのはやめ、価格は最初に App Store Connect の web UI で設定しておき、API はメタデータとバイナリ操作に専念する設計に落ち着きました。再提出のたびに価格が上書きされるリスクを排除できる点でも、この分担が理にかなっています。

罠③:en-US ロケールの名前衝突

症状。 set_whatsnew() を実行すると、特定のアプリだけ 409 が返って更新が止まりました。

whatsNew en-US -> 409 {"errors":[{"status":"409","code":"STATE_ERROR",...}]}

前後で ja や zh-Hans などほかのロケールは正常に更新されているため、スクリプト自体は壊れていません。en-US だけが毎回失敗する状態です。

原因。 set_whatsnew() は ASC API が返す localization リストを全件 PATCH します。

_, loc = call("GET", f"/v1/appStoreVersions/{vid}/appStoreVersionLocalizations")
for x in loc.get("data", []):
    lid = x["id"]; locale = x["attributes"].get("locale")
    st, r = call("PATCH", f"/v1/appStoreVersionLocalizations/{lid}",
        {"data": {"type": "appStoreVersionLocalizations", "id": lid,
                  "attributes": {"whatsNew": text}}})
    print("  whatsNew", locale, "->", st if st < 400 else json.dumps(r)[:300])

問題はそのアプリのバージョンに en-US と en の両方が存在していたことです。App Store Connect の web UI で英語名を en で作成した後、deliver や Fastlane で en-US のメタデータを追加すると二重登録が発生します。App Store は en と en-US を別エンティティとして管理するため、重複状態は許容されますが、一方が「プライマリロケール」として設定されていると、もう一方への PATCH が 409 を返します。

直し方。 応急処置として、locale の言語コード(- 以前)を見て同じ基底言語が既に処理済みならスキップする絞り込みを追加しました。

processed_base = set()
for x in loc.get("data", []):
    locale = x["attributes"].get("locale", "")
    base = locale.split("-")[0]   # "en-US" → "en"
    if base in processed_base:
        print(f"  skip {locale} (already processed base {base})")
        continue
    processed_base.add(base)
    lid = x["id"]
    st, r = call("PATCH", f"/v1/appStoreVersionLocalizations/{lid}",
        {"data": {"type": "appStoreVersionLocalizations", "id": lid,
                  "attributes": {"whatsNew": text}}})
    print("  whatsNew", locale, "->", st if st < 400 else json.dumps(r)[:300])

根本解決は App Store Connect の web UI で重複ロケールを削除し en-US に一本化することです。スクリプト側のスキップはあくまで応急処置であり、プライマリが en のアプリと en-US のアプリが混在する場合はスキップロジックが逆効果になる可能性があります。asc.py apps でアプリ一覧を取得した後、各アプリのロケール状態を定期確認する運用を加えることで、再発を防いでいます。


次節では、12本を同時回転させたときのレートリミット対策と、Claude Code が自律で「リジェクト → バグ修正 → 再提出」を判断するエージェントループの設計を解説します。

つまずきポイント(実例と実コードで網羅)

前章の3大リジェクト(ITMS-90111 / appPriceSchedules / en-US衝突)を除いた、実装と運用で踏んだ細かな落とし穴を一気に列挙します。レートリミット対策もここで扱います。

① JWT の iat を −30 秒にする意味を消してはいけない

~/.appstoreconnect/asc.py の _jwt() はこう書いてあります。

p = _b64(json.dumps({"iss":ISSUER,"iat":int(time.time())-30,"exp":int(time.time())+900,
                     "aud":"appstoreconnect-v1"},separators=(",",":")).encode())

iat を -30 しているのは、Apple のサーバーとのクロックドリフト対策です。Mac の時刻が NTP からわずかにずれた状態で JWT を発行すると、Apple 側が「未来の発行時刻だ」と判断して 401 を返します。ビルドサーバーで自動実行するほど発生頻度が上がるため、この -30 は絶対に外してはいけません。exp の 900 秒(15 分)も Apple が要求する上限値です。900 を超える値を入れると即 401 になります。

② /v1/certificates?limit=200 のページネーション未実装

~/dev/auraly-ios/tools/setup_signing.py の find_cert() は次のように書いてあります。

LOCAL_SHA1 = "EC06777A693874E920CECFE390D467670552CCCE".lower()

def find_cert():
    _, d = asc.call("GET", "/v1/certificates?limit=200")
    for c in d.get("data", []):
        content = c["attributes"].get("certificateContent")
        if not content:
            continue
        der = base64.b64decode(content)
        sha1 = hashlib.sha1(der).hexdigest()
        if sha1 == LOCAL_SHA1:
            return c["id"]
    print("ERROR: no ASC cert matches local SHA1", LOCAL_SHA1)
    sys.exit(1)

ASC API のページネーション(cursor パラメータ)は実装していません。証明書の総数が 201 本以上あると、対象の証明書が 201 番目以降に落ちた瞬間に sys.exit(1) で止まります。個人アカウントで 12 本程度なら現実的には起きませんが、チームアカウントや長年使っているアカウントでは要注意です。定期的に失効済み証明書を削除するか、cursor ベースの全件舐めを追加することで防げます。

③ ensure_profile() は「同名のプロファイルを全て消す」

for p in d.get("data", []):
    if p["attributes"].get("name") == PROFILE_NAME:
        asc.call("DELETE", f"/v1/profiles/{p['id']}")
        print("deleted stale profile", p["id"])

PROFILE_NAME = "Auraly AppStore" に完全一致するプロファイルをすべて DELETE してから新規作成します。複数アプリで同じプロファイル名を使い回していると、他アプリのプロファイルがまとめて消えて次のビルドが通らなくなります。"{アプリ名} AppStore" のようにアプリごとにユニークな命名を徹底することで防げます。

④ _latest_version() は最新 1 件を盲目的に返す

def _latest_version(app_id):
    _, v = call("GET", f"/v1/apps/{app_id}/appStoreVersions?limit=1")
    return v["data"][0] if v.get("data") else None

ソート条件の明示がないため、API のデフォルト(作成日降順)で先頭 1 件が返ります。「1.3.0(審査中)」と「1.4.0(編集中)」が同時に存在するとき、submit() は 1.4.0 の ID を掴んで提出しようとします。12 本を並走させるとこの二重バージョン状態が意外と長く残ります。操作前に asc.py status <app_id> を実行して状態を確認する習慣が必要です。

⑤ processingState: FAILED のビルドはスクリプトが黙って無視する

for x in b.get("data", []):
    if x["attributes"].get("processingState") != "VALID":
        continue

VALID 以外をスキップするだけで、FAILED を検知して報告する処理は入っていません。Apple がバイナリを受け取ったものの処理に失敗した場合、_build_for_version() は何もないかのように振る舞います。Claude Code に「VALID になるまで待て」と指示すると、FAILED のまま永遠にポーリングします。タイムアウト(30 分程度)を設けて超過したら通知して停止する仕組みが必要です。

⑥ dedup の重複判定は sourceFileChecksum + fileName の複合キー

key = (sh["attributes"].get("sourceFileChecksum"), sh["attributes"].get("fileName"))
if key in seen:

同じ画像でもファイル名が変われば別キーとみなされます。Fastlane のメタデータフォルダで screen_01.png → screen_1.png のようにリネームした場合、同一画像でも重複として検出されません。--apply を実行する前に引数なし(dry-run)で重複件数を確認し、予想外の件数なら手作業で照合します。

⑦ set_whatsnew() は全ロケールへ同一テキストを流す

for x in loc.get("data", []):
    lid = x["id"]; locale = x["attributes"].get("locale")
    st, r = call("PATCH", f"/v1/appStoreVersionLocalizations/{lid}",
        {"data": {"type": "appStoreVersionLocalizations", "id": lid,
                  "attributes": {"whatsNew": text}}})

渡した text をロケール問わず全件に PATCH します。英語・中国語・韓国語のロケールに日本語の更新情報がそのまま入ります。英語圏のユーザーレビューに「更新情報が読めない」と書かれるより前に対処が必要です。ロケール別対応は {"ja": "...", "en-US": "..."} を受け取れるよう set_whatsnew() を拡張するか、Claude Code に言語ごとの翻訳を生成させた辞書を渡す設計が現実的です。

⑧ whatsNew フィールドの上限は 4000 文字

App Store Connect の API 仕様上限です。超えると 422 が返ります。Claude Code に「更新情報を自動生成して submit まで流して」と指示すると、長文を生成してそのまま渡しやすい状況が生まれます。呼び出し側で whatsnew = whatsnew[:4000] とクリップしておくか、プロンプトに「4000 文字以内でまとめる」と明記しておくことで防げます。

⑨ IN_REVIEW 状態では全 PATCH が 422 になる

asc.py の _EDITABLE は次の 5 状態しか含みません。

_EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED",
            "METADATA_REJECTED", "INVALID_BINARY"}

WAITING_FOR_REVIEW と IN_REVIEW はこのセットの外です。審査中にメタデータを変えようとしても API は無言で 422 を返すだけです。reject() で取り下げてから編集する必要があります。12 本同時運用だとどれかが常に審査中になり、「なぜこのアプリだけ更新できないのか」と混乱しやすい状態になります。

⑩ 12 本同時提出でレートリミット(429)に当たる

call() 関数には retry / backoff が実装されていません。

try:
    r = urllib.request.urlopen(req); raw = r.read()
    return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
    return e.code, json.loads(e.read() or b"{}")

12 本分を一斉に dedup → submit するループを組むと、数分で 1000 リクエスト/分(目安)の制限に近づきます。429 が返ってもエラーを上位に返すだけで再試行しないため、ループ後半のアプリが黙って失敗します。各 call() の後に time.sleep(0.1) を挟む最低限の対策か、429 を検知したら指数バックオフで再試行するラッパーを追加することで安定します。

⑪ eas submit / altool の完了は asc.py の外

バイナリのアップロードは eas submit または altool が担い、asc.py はその完了を感知できません。アップロードから Apple サーバー側の VALID 判定まで 3〜10 分かかります。この待ち時間を考慮せずに release → submit を連続実行すると、「VALID build が見つからない」のまま空振りします。asc.py status でビルド欄を確認してから release を呼ぶか、_build_for_version() を使ったポーリングをラッパーとして挟みます。

⑫ archive.sh の set -euo pipefail は意図した設計だが見落としやすい

set -euo pipefail
cd "$(dirname "$0")/.."

xcodegen generate の失敗でも xcodebuild archive の失敗でも即座にスクリプト全体が停止します。Claude Code が archive.sh を実行したとき、終了コード 1 のログを見落とすと「失敗した」と気づかない場合があります。archive.sh の末尾に echo "ARCHIVE_SUCCESS: $ROOT/build/export/Auraly.ipa" を確認マーカーとして追加し、Claude Code への指示に「このメッセージが出なければ失敗として報告する」を明記しておくと誤認識を防げます。


ベストプラクティス

実装と失敗の往復から蒸留した 12 本の原則です。

1. アーカイブは GA macOS 専用マシンで行う

beta macOS でビルドすると BuildMachineOSBuild に beta 文字列が埋め込まれ ITMS-90111 で弾かれます。開発は beta 環境で行っても、archive.sh を実行するマシンは正式リリース版の macOS だけに限ります。GitHub Actions の macos-latest ランナーは常に GA 版が使われるため、CI でアーカイブする設計が最も安全です。

2. archive.sh の先頭に OS ビルド番号を出力する

echo "macOS build: $(sw_vers -buildVersion)"

CI ログにビルド番号が残るため、ITMS-90111 が再発したとき「beta でビルドした」と即座に特定できます。24G85 は GA、25A5xxx 系は beta というビルド番号のパターンを覚えておくだけで診断速度が上がります。

3. 価格設定は Web UI で初回のみ行い API に任せない

appPriceSchedules を API で操作するには 175 か国分の included が必要で、実用的ではありません。最初の 1 回だけ Web UI で「無料」または任意のティアを設定し、以降の提出では触れない設計が正解です。再提出のたびに価格が上書きされるリスクもゼロになります。

4. submit の直前に必ず dedup を実行する

python3 ~/.appstoreconnect/asc.py dedup <app_id> --apply
python3 ~/.appstoreconnect/asc.py submit <app_id>

この順番を崩さないことを Claude Code への指示テンプレートに埋め込みます。順番が逆になると、重複スクショがそのまま審査へ進みます。

5. ロケールは en-US に一本化し en を残さない

既存の en ロケールは Web UI から削除し en-US のみにします。スクリプト側の「基底言語重複スキップ」は応急処置であり、根本解決はロケールの一本化です。プライマリロケールが en のアプリと en-US のアプリが混在すると、スキップロジックが逆効果になるケースもあります。

6. status を「提出前」と「提出後 24 時間」の 2 点で確認する

提出前:バージョン状態・VALID ビルドの有無を確認。提出後 24 時間:reviewSubmission.state が COMPLETE に変わっているかを確認します。Claude Code にこのチェックをルーティンとして組み込むと、承認とリジェクトの検知が自動化できます。

7. Developer Rejection はペナルティなし・気づいたら即 reject する

審査中に不具合を発見したら reject() → 修正 → submit を即座に回します。「審査に通るか試してみる」姿勢のほうが、リジェクト待機時間で損をします。Developer Rejection は審査カウントに影響しません。

8. _EDITABLE 状態を操作前に確認する

status コマンドで appStoreState が _EDITABLE の 5 状態にあることを確認してから release を実行します。IN_REVIEW 中に操作しても 422 が返るだけで副作用はありませんが、時間と API コールを無駄にします。

9. プロファイル名はアプリ固有にする

"{アプリ名} AppStore" 形式で命名し、複数アプリが同名にならないようにします。ensure_profile() は同名を全削除するため、命名が被ると他アプリのプロファイルが消えて次のビルドが失敗します。

10. call() に 429 対応の指数バックオフを追加する

def call_with_retry(method, path, body=None, max_retry=3):
    for attempt in range(max_retry):
        st, data = call(method, path, body)
        if st == 429:
            time.sleep(2 ** attempt)
            continue
        return st, data
    return st, data

12 本同時提出のループが安定します。現状の call() はそのまま残し、レートリミットが気になる操作だけラッパー経由で呼ぶ形で段階的に差し替えられます。

11. VALID 待ちにタイムアウトを設ける

Claude Code に「processingState: VALID になるまで待て」と指示するとき、必ず「30 分経っても VALID でなければ FAILED 扱いで通知して停止する」条件を添えます。タイムアウトなしのポーリングは、深夜の自律実行で詰まったまま気づかない原因になります。

12. whatsNew は呼び出し前に 4000 文字でクリップする

whatsnew = generated_text[:4000]

Claude Code が生成した更新情報を無検査で渡すと上限超過で 422 が出ます。クリップを呼び出し側の責任にしておくと、set_whatsnew() 本体を汚さずに済みます。


まとめ

前編・後編を通じて、~/.appstoreconnect/asc.py(272 行)・archive.sh(24 行)・setup_signing.py(72 行)の 3 スクリプトで、iOS アプリ 12 本の審査提出が完全に無人化できることを示しました。

審査を止めた罠は、Apple サーバー固有の非自明な挙動(ITMS-90111 の真因・appPriceSchedules の included 形式・en と en-US の二重登録)と、実装の小さな前提(ページネーション・命名衝突・ロケール統一・タイムアウト・レートリミット)の組み合わせでした。どれも実際に 12 本を並走させながら踏み抜いたものです。ドキュメントを読むだけでは予防できません。

月商 120 万の実態は「作業量を増やした」のではなく、「同じ 3 スクリプトが 12 アプリ分並列で動いている」だけです。パイプラインが正しく組まれていれば、アプリ ID を 1 行追加するだけでスケールします。ここまで書いてきた 3 つのスクリプトが、その土台になっています。

次回は Claude Code が「リジェクト検知 → バグ修正 → 再提出」を自律で判断するエージェントループの設計を解説します。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?