📱 iOSアプリ12本をApp Storeへ無人提出する基盤 前編 ― 2FA不要のASC APIキー運用とJWT署名の落とし穴 — リーダー×
📱

iOSアプリ12本をApp Storeへ無人提出する基盤 前編 ― 2FA不要のASC APIキー運用とJWT署名の落とし穴

#ios#swift#automation#claudecode2026-07-13 · 約38

大学生のころ月10万だった収入が掛け持ちで60万まで伸び、会社都合で解雇されてゼロに戻り、半年でClaude Codeの自律環境を一から建てた結果、今は月商120万を超えています。その環境の中核にあるのが「人間がApp Store Connectにログインしなくてもアプリを審査提出できる基盤」です。

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

iOSアプリを量産しようとすると、開発よりも「提出作業」が先に詰まります。Xcodeを開き、Archiveをクリックし、App Store Connectにログインし、2FAのSMSを待ち、ビルドを選んで「審査に提出」を押す。1本なら苦ではありませんが、同時に5本・10本を管理するとこの手順が純粋な工数として毎週のしかかってきます。

さらに根本的な問題があります。2FA前提の操作はボットに渡せない、という点です。fastlane の deliver は便利ですが、セッションCookieが切れるたびに対話認証が走ります。CI上では詰みます。

App Store Connect APIキー(.p8ファイル)はこの問題を根本から解消します。キーを一度発行してしまえば、二要素認証なしでAPIを叩ける。有効期限もなく、revoke操作をしない限り永続します。つまりこのキーが手元にある環境ならば、夜中の2時にClaude Codeが自律的に「審査提出」を実行できます。

実際に私が管理しているアプリは現在12本です。このうち何本かは同日に新バージョンを出すこともあります。人間が画面の前に座って作業できる時間は有限ですが、APIは並列で叩けるfor app_id in $(cat app_ids.txt); do python3 ~/.appstoreconnect/asc.py submit "$app_id"; done の一行でループが回れば、自分がコーヒーを飲んでいる間に全アプリへの提出が完了します。

「作業」と「環境」の違い

「毎回Xcodeを開く」はタスクです。「APIキーがあれば誰でも(何でも)提出できる」は環境です。

タスクをこなしても収入の上限は時間で頭打ちになります。環境を整えると、自分が寝ていてもシステムが動きます。大学生のころから月商が12倍になった理由の大半は、自分の作業量を増やしたのではなく、自分の代わりに動くものを増やしたからです。ASC APIキーはその象徴的な一例です。

なぜ「落とし穴」と書いたか

「APIキーがあればJWTを生成して叩くだけ」は正しいのですが、実装が一歩ずれると401が返り続けます。AppleのES256 JWTはRFC 7518が定義する生の r‖s エンコードを要求します。Pythonの暗号ライブラリが返すデフォルトはDER形式なので、そのまま使うと必ず壊れたJWTになります。このズレは初見では原因がまったく見えません。エラーが「Invalid signature」ではなく「401 Unauthorized」として返ってくるからです。

次の章でこの落とし穴の正体と、実際に私が使っているコードを具体的に見ていきます。


全体の流れ

まず全体像を掴みます。バイナリ生成からApp Store審査提出まで、私の環境では次の3レイヤーに分かれています。

┌─────────────────────────────────────────────────────────┐
│ Layer 1: バイナリ生成                                    │
│   xcodebuild archive  (tools/archive.sh)                │
│   または eas build --local  (Expo系アプリ)              │
└──────────────────┬──────────────────────────────────────┘
                   │ .ipa
                   ▼
┌─────────────────────────────────────────────────────────┐
│ Layer 2: バイナリ転送                                    │
│   eas submit  (Transporter相当・クラウド枠消費ゼロ)      │
└──────────────────┬──────────────────────────────────────┘
                   │ processingState: VALID
                   ▼
┌─────────────────────────────────────────────────────────┐
│ Layer 3: 状態確認 / メタ編集 / 審査提出                 │
│   python3 ~/.appstoreconnect/asc.py {apps|status|submit}│
│   2FA不要・JWT認証・アカウント横断で使える              │
└─────────────────────────────────────────────────────────┘

Layer 3が今回のテーマです。asc.py はたった272行のスクリプトですが、審査ライフサイクルに必要な操作のほぼすべてをカバーしています。

# 全アプリ一覧
python3 ~/.appstoreconnect/asc.py apps

# 特定アプリの審査状態・ビルド状態を確認
python3 ~/.appstoreconnect/asc.py status <app_id>

# 審査に提出
python3 ~/.appstoreconnect/asc.py submit <app_id>

これが2FA不要で動く理由と、その内部実装を順に見ていきます。

APIキーの置き場と設定ファイルの構造

App Store Connect APIキーは ~/.appstoreconnect/ 以下に2ファイルで管理します。

~/.appstoreconnect/keys.json

{
  "key_id":    "XXXXXXXXXX",
  "issuer_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "key_path":  "~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8"
}

~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8(ASCから一度しかダウンロードできない秘密鍵本体)

asc.py の先頭では keys.json を読み込み、この3値を定数として保持します。

CFG = json.load(open(os.path.expanduser("~/.appstoreconnect/keys.json")))
KEY_ID, ISSUER = CFG["key_id"], CFG["issuer_id"]
P8 = os.path.expanduser(CFG["key_path"])

秘密は .p8 ファイル1つだけです。keys.json にはキーIDとIssuer IDしか書きません。この分離が重要で、keys.json はGitに含めても(含めることは推奨しませんが)直ちに漏洩事故にはなりません。.p8 だけを厳密に管理すれば十分です。私の環境では .p8~/.appstoreconnect/ に置いてディレクトリごと chmod 700 しています。CI/CD環境に載せる場合はシークレットストアから実行時にファイルとして書き出す形にします。

ES256 JWTの落とし穴:DERではなく生のr‖s

JWT認証の核心は _jwt() 関数です。実際のコードをそのまま引用します。

def _b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=")

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

落とし穴は最後の2行にあります。

key.sign() はECDSA署名をDER形式で返します。DERはASN.1構造を持ち、30 xx 02 xx [r-bytes] 02 xx [s-bytes] という形式です。AppleはこのDERを受け付けません

AppleのES256 JWTが要求するのは、RFC 7518 Section 3.4で定義された「固定64バイトの生エンコード」です。つまり r を32バイト・s を32バイトのビッグエンディアンで並べて連結した合計64バイト、それをBase64URLエンコードしたものです。

# NG: DERをそのままBase64URLにしても401になる
_b64(der)

# OK: DERをデコードしてr,sを取り出し、生の32バイトで連結する
r, s = decode_dss_signature(der)
_b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))

decode_dss_signaturecryptography ライブラリの関数で、DER形式の署名を (r, s) のPython整数タプルに変換します。ここから to_bytes(32, "big") で各32バイトのバイト列に変換し、連結してからBase64URLエンコードする——これが正しい手順です。

なぜ 32 バイトなのかというと、ES256はNIST P-256曲線を使い、この曲線の位数が32バイト(256ビット)に収まるからです。rs がたまたま小さい値になったとき(先頭バイトが0)も必ず32バイトにゼロパディングしなければなりません。r.to_bytes(32, "big") はその処理を自動で行います。

この実装がずれると、Apple側から返ってくるのは常に 401 Unauthorized です。署名検証の失敗なのに「署名が不正」ではなく「認証失敗」として返るため、初見でJWT構造の問題だと気づくのに時間がかかります。

iat を30秒引く理由

もう一つ細かいが重要な点があります。

"iat": int(time.time()) - 30,

iat(issued at)を現在時刻より30秒過去に設定しています。理由はクロックスキューです。AppleのAPIサーバーとローカルマシンで時刻がわずかにずれていると、「まだ有効になっていないJWT」として弾かれることがあります。実際に一度これで数十分ハマりました。30秒の余裕を持たせることで、ほぼすべての環境差を吸収できます。

有効期限は現在時刻から900秒(15分)後に設定しています。1リクエストで使い捨てるJWTとしては十分な長さです。

全コマンドの設計思想

asc.py が提供するサブコマンドの一覧です。

コマンド用途
apps管理中の全アプリ一覧(ID・Bundle ID・名前)を出力
status <id>バージョン状態・審査状態・最新ビルドの処理状態を確認
submit <id>編集中バージョンを審査提出(reviewSubmission作成→item追加→submitted=true)
make-version <id> <ver>App Store上のバージョン文字列を用意・更新
attach-build <id> <ver>処理済みビルドをバージョンに紐づけ
whatsnew <id> <text>全ロケールの「新機能」テキストを一括設定
release <id> <ver> <whatsnew>上3つをまとめて実行(確認用。提出はしない)
reject <id>審査中のsubmissionをDeveloper Rejectで取り下げ
dedup <id> [--apply]スクショ重複を検出・削除(dry-run / apply 切替)
add-tester <id>TestFlight内部テスターを冪等追加

設計の軸は冪等性です。たとえば submit は審査提出前に READY_FOR_REVIEW のreviewSubmissionが既存かどうかを確認し、あれば再利用します。

def submit(app_id, platform="IOS"):
    _, 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", {...})
        ...
    sid = sub["id"]
    # バージョンをitemとして追加
    ...
    # submitted=true で提出
    st, r = call("PATCH", f"/v1/reviewSubmissions/{sid}",
        {"data": {"type": "reviewSubmissions", "id": sid,
                  "attributes": {"submitted": True}}})

自動化スクリプトは「同じコマンドを2回叩いても壊れない」ことが必須条件です。Claude Codeがリトライしたとき、ネットワークエラーで再実行したとき、二重提出や重複エラーが起きてはなりません。GETで現状を確認してから操作する、というパターンをすべての書き込み系コマンドで徹底しています。

Xcodeアーカイブ側の設計(archive.sh)

バイナリを生成する tools/archive.sh も確認しておきます。

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 です。Automatic Signingを使うとXcodeがプロビジョニングプロファイルを自動管理しようとするため、ヘッドレス実行時に認証ダイアログが出ることがあります。Manualにして、プロファイルを名前で指定することで、GUI不要のビルドが安定します。

プロビジョニングプロファイル自体も tools/setup_signing.py でASC APIから自動生成・配置します。/v1/profiles APIでApp Store配布用のプロファイルを作成し、~/Library/MobileDevice/Provisioning Profiles/ に直接書き込むため、Xcodeを一度も開かずに署名環境が整います。証明書の照合にはSHA1フィンガープリントを使い、

LOCAL_SHA1 = "EC06777A693874E920CECFE390D467670552CCCE".lower()
...
der = base64.b64decode(content)
sha1 = hashlib.sha1(der).hexdigest()
if sha1 == LOCAL_SHA1:
    return c["id"]

ローカルの配布証明書とASC上の証明書を突合します。これにより「どのキーチェーンの証明書を使うか」を人間が画面で確認する必要がなくなります。


次回(後編)では、このasc.pyをClaude Codeの自律ループに組み込み、12本のアプリを管理するパイプライン全体と、実際に提出がINVALID_BINARYで弾かれ続けたときの診断・突破手順を詳しく書きます。

実装の詳細

call():外部ライブラリを一切使わないHTTPレイヤー

asc.py の依存は cryptography ライブラリ1本だけです。HTTP通信には urllib.request を使っています。

def call(method, path, body=None):
    url = path if path.startswith("http") else BASE + path
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
        headers={"Authorization":"Bearer "+_jwt(), "Content-Type":"application/json"})
    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"{}")

requests を使わない理由は単純で、Pythonの標準ライブラリだけで動くスクリプトはどの環境にも無条件で持ち込めるからです。新しいMacをセットアップするとき、CI環境にデプロイするとき、pip install requests という手順が1つ減るだけで摩擦がまったく違います。

もう一点、call() は毎回 _jwt() を呼んで新しいJWTを生成します。JWTには900秒(15分)の有効期限があるので、1つのスクリプト実行の中では同じJWTをキャッシュしても問題ありません。しかし意図的にキャッシュしていません。理由は、「長時間かかるバッチ処理の途中でJWTが切れてあとのリクエストだけ401になる」という中途半端な失敗を防ぐためです。毎回生成する方が少しだけ遅くなりますが、そのコストは1リクエストあたりECDSA署名1回分、つまりマイクロ秒単位です。12本のアプリをまとめて回しても体感できる差はありません。

HTTPError のハンドリングも最小限にしています。ステータスコードとレスポンスボディをそのまま返し、呼び出し側で if st >= 400: ... return するパターンを貫いています。例外で制御フローを分岐させるとスタックトレースが混ざって読みにくくなるため、「数値で判断して早期リターン」という一貫したスタイルにしています。


_build_for_version():マーケティングバージョンの突合

最初に実装したとき一番手間取ったのが、「処理済みビルドをマーケティングバージョン(0.3.2 のような表示用バージョン)で特定する」という処理です。

/v1/builds エンドポイントのレスポンスにはビルド番号(整数のbuild番号)は含まれますが、マーケティングバージョン文字列は含まれません。マーケティングバージョンは preReleaseVersion という別リソースにあり、クエリ時に明示的に include=preReleaseVersion を渡さないと返ってきません。

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

include=preReleaseVersion を付けると、レスポンスの included 配列に preReleaseVersions 型のオブジェクトが入ってきます。これをIDをキーにして辞書化し(incl)、各ビルドの relationships.preReleaseVersion.data.id でルックアップするのがこのコードの肝です。

processingState != "VALID" のスキップも重要です。Apple側でのバイナリ処理が完了する前のビルドは PROCESSING または INVALID の状態にあります。これを誤ってバージョンに紐づけようとすると 409 が返ります。VALID に絞ることで、アップロード直後の「まだ処理中」状態のビルドを自動的に除外できます。


dedup_screenshots():スクショ重複を提出前に刈り取る

fastlane deliversync_screenshots: false の設定で複数回実行すると、スクリーンショットが毎回追記されます。1回目で正しいスクショが5枚入っていても、2回目の実行後には10枚になり、3回目には15枚になります。App Storeのバリデーションは「1サイズあたり最大10枚」を超えると弾くので、これが地味ながら提出失敗の原因になります。

dedup_screenshots() はこの問題を sourceFileChecksumfileName の組み合わせをキーにして解決します。

def dedup_screenshots(app_id, apply=False):
    ...
    for sh in shots.get("data", []):
        key = (sh["attributes"].get("sourceFileChecksum"),
               sh["attributes"].get("fileName"))
        if key in seen:
            if apply:
                st, _ = call("DELETE", f"/v1/appScreenshots/{sh['id']}")
                print(f"  [{locale}/...] DELETE {sh['id']} -> {st}")
            else:
                print(f"  [{locale}/...] dup {sh['id']} (dry-run)")
            total += 1
        else:
            seen.add(key)

apply=False がデフォルトの dry-run になっている点が重要です。asc.py dedup <app_id> だけ叩けば「何件重複があるか」だけ表示され、実際の削除は --apply を付けたときだけ走ります。12本を管理していると「うっかり全スクショを消した」という事故が起きるリスクがあるため、この2段構えにしています。

実際の運用では submit の直前に必ず dedup --apply を挟んでいます。バッチスクリプトの中で順番に呼ぶだけなので、人間が意識することはありません。


setup_signing.py:プロビジョニングプロファイルを完全に自動管理

tools/setup_signing.py は「証明書・Bundle ID・プロビジョニングプロファイルの3点セットをXcodeを開かずに用意する」スクリプトです。

証明書の突合はSHA1フィンガープリントで行います(前半で触れた箇所です)。理由は、ASC上の証明書には「どのキーチェーンの秘密鍵と対応するか」という情報が直接ありません。ローカルのキーチェーンに入っている配布証明書のSHA1を事前に控えておき、ASC APIから全証明書を取ってきてDERデコード+SHA1計算で照合する、というアプローチが最も確実です。

プロビジョニングプロファイルの管理は「古いものを消してから作り直す」パターンです。

def ensure_profile(cert_id, bundle_internal_id):
    _, d = asc.call("GET", "/v1/profiles?limit=200&filter[profileType]=IOS_APP_STORE")
    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"])
    st, r = asc.call("POST", "/v1/profiles", {...})
    ...
    uuid = attrs["uuid"]
    content = base64.b64decode(attrs["profileContent"])
    dest_dir = os.path.expanduser("~/Library/MobileDevice/Provisioning Profiles")
    dest = os.path.join(dest_dir, f"{uuid}.mobileprovision")
    with open(dest, "wb") as f:
        f.write(content)

同名プロファイルを先に削除する理由は、証明書を更新したときに古いプロファイルが残ると「プロファイルは存在するが証明書が古い」という不整合が起きるからです。毎回削除して作り直す方が「べき等で、常に正しい状態になる」という保証が得やすい。

~/Library/MobileDevice/Provisioning Profiles/ にUUIDファイル名で直接書き込む部分も重要で、これで xcodebuildPROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" を名前解決できるようになります。Xcodeのダウンロードボタンを押す必要はありません。


add_tester():内部テスターの冪等追加

TestFlightに自分のiCloudアドレスを内部テスターとして追加するのは、審査提出後に真っ先にやる作業です。これも asc.py add-tester <app_id> の1コマンドで完結します。

コードの末尾近くにこんなコメントがあります:

def add_tester(app_id, email=DEFAULT_TESTER_EMAIL, first="Lily", last="Tester"):
    """...
    注意: 外部グループや `betaGroups/{id}/relationships/betaTesters` 直リンクは
    409 STATE_ERROR(Tester cannot be assigned)になる。create-with-group が唯一通る。"""

このコメントは失敗の記録です(詳しくは次章で)。正しいパターンは POST /v1/betaTesters のボディに relationships.betaGroups を含めて作成する方法です。

st, r = call("POST", "/v1/betaTesters",
    {"data": {"type": "betaTesters",
              "attributes": {"email": email, "firstName": first, "lastName": last},
              "relationships": {"betaGroups": {"data": [{"type": "betaGroups", "id": gid}]}}}})

テスター作成とグループへの所属を1リクエストで同時に行うことで、Apple側のステート管理と衝突しない形にできます。

また、最初に _has_tester() で既存チェックをして二重登録を防いでいます。この冪等チェックがなければ、バッチで全アプリを回したときに同じアドレスを12回登録しようとして409が12回返ることになります。


私が詰まった話

自動化環境を作るときは「実装は割と早く終わって、謎の401や謎の409を潰すのに時間がかかる」というパターンを何度も経験しました。実際にハマった失敗を症状・原因・直し方の順で書きます。


失敗1:401が返り続けるのに原因がまったくわからない

最初に asc.py を実装した夜、JWTを組み上げてAPIを叩いたら 401 Unauthorized が返ってきました。

Pythonの key.sign(signing, ec.ECDSA(hashes.SHA256())) は正しく動いているように見えます。ヘッダーとペイロードのBase64URLも問題なく組めています。JWTの構造を目視で確認しても何もおかしくない。ところが叩くたびに必ず401です。

エラーレスポンスのボディを見ると {"errors":[{"status":"401","code":"NOT_AUTHORIZED","title":"Authentication credentials are missing or invalid."}]} という情報量ゼロのメッセージしかありません。「署名が壊れています」とは教えてくれません。

2時間ほどヘッダーのフォーマット、aud の値、exp の計算を何度も確認したあとで、ふとRFC 7518のSection 3.4を調べて気づきました。ES256のJWT署名は 「固定64バイト:r(32バイト)+ s(32バイト)」 でなければならないと書いてある。

key.sign() が返すのはDER形式の可変長バイト列です。先頭が 30 xx 02 xx... というASN.1エンコードになっていて、rとsの長さが変わります。これをそのままBase64URLにしていました。

# 書いていたコード(間違い)
sig_b64 = _b64(der)

# 正しいコード
r, s = decode_dss_signature(der)
sig_b64 = _b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))

decode_dss_signature でDERをPython整数 (r, s) に戻し、各32バイトのビッグエンディアンに変換して連結する——これだけで401が200になりました。「認証失敗」のエラーコードは、署名アルゴリズムのエンコード形式の問題を隠蔽する。これを最初に知っていれば2時間は取り戻せていました。


失敗2:時間帯によってJWTが通ったり通らなかったりする

DER問題を解決してしばらくしたあと、今度は「朝は動くのに昼過ぎに動かない」という現象が起きました。再現性も低く、数時間後に試すと直っていたりします。

Apple側のAPIサーバーとローカルマシンの時刻が数秒〜十数秒ずれていると、iat(issued at)が「まだ未来のJWT」として弾かれることがあります。Macのシステム時計は通常NTPで同期していますが、スリープ直後や負荷が高い状態では数秒のズレが生じることがあります。

解決策はシンプルで、iat を現在時刻から30秒引いた値にする。

"iat": int(time.time()) - 30,

これで「このJWTは30秒前に発行されたことになる」という申告になり、AppleのAPIサーバーから見れば「十分過去に発行されたJWT」として受け入れられます。有効期限の900秒から30秒削れるだけなので、実用上の問題は何もありません。


失敗3:betaGroups/{id}/relationships/betaTesters → 409 STATE_ERROR

TestFlightのテスター追加で、最初に試したのは「内部グループを先に取得して、そのグループにテスターを追加する」というアプローチでした。

# 失敗したパターン
asc.call("POST", f"/v1/betaGroups/{gid}/relationships/betaTesters",
    {"data": [{"type": "betaTesters", "id": tester_id}]})

これは 409 STATE_ERROR: Tester cannot be assigned を返します。内部グループへの外部テスター直リンクは、Appleのステートマシン上で許可されていない遷移として扱われます。

正しい方法は テスターを作成するときからグループを一緒に指定する ことです。POST /v1/betaTesters のリクエストボディに relationships.betaGroups を含めて送る。このワンショットで「テスターの作成」と「グループへの所属」が原子的に完了します。

AppleのAPIは外側から見ると「なんでもできそうな汎用REST」に見えますが、実態は審査ライフサイクルのステートマシンをAPIで公開しているものなので、正しい遷移順序でないとエラーになる操作が多数あります。ドキュメントには書かれていないことが多く、試行錯誤で見つけるしかありません。


失敗4:スクリーンショットが毎回倍増してsubmitが通らない

fastlane deliver でスクショをアップロードするとき、sync_screenshots: false(または省略時のデフォルト)で複数回実行すると、スクショが削除されずに追記されます。

最初の提出では問題ありません。リジェクト後に修正して再提出しようとしたとき、deliver を再実行するとスクショが倍になり、2回目の deliver で4枚→8枚になります。8枚はApp Storeの上限(1サイズあたり10枚)ギリギリなので気づきにくく、さらに再実行して12枚になったところで「METADATA_ERROR: Too many screenshots」というエラーが返ってきます。

症状だけ見ると「メタデータが壊れている」と読めてしまい、画像サイズや形式の問題を疑って1時間ほど無駄にしました。原因は単純で「同じスクショが複数枚入っている」でした。

対策として作ったのが dedup_screenshots() です。sourceFileChecksumfileName の両方が一致する2枚目以降を削除します。sourceFileChecksum だけでは稀に異なるファイルが同じチェックサムになるケースを考慮し、ファイル名を第2キーにしています。

--apply を付けない dry-run で事前確認できるようにしたのも、この失敗で「一気に全削除してしまうと元に戻せない」と学んだからです。


失敗5:VALIDなビルドがあるのに「見つからない」と言われる

attach_build() でビルドをバージョンに紐づけるとき、最初の実装は /v1/builds?filter[app]=xxx&limit=20&sort=-uploadedDate だけを叩いていました。App Store Connectの画面ではVALIDになっているビルドが確認できるのに、スクリプトは no VALID build for version 0.3.2 と言います。

原因は、レスポンスにマーケティングバージョン(0.3.2)が含まれていないことです。/v1/builds のレスポンスはビルドオブジェクトだけを返し、そのビルドに対応するマーケティングバージョンは preReleaseVersion という別リソースに紐づいています。これを include しない限りレスポンスには現れません。

# 修正後:include=preReleaseVersion を追加
_, 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"}

include を追加するとレスポンスの included 配列に preReleaseVersions 型のオブジェクトが入ってきます。これをIDをキーにした辞書にして、各ビルドの relationships.preReleaseVersion.data.id でルックアップすればマーケティングバージョンの文字列が取れます。

App Store Connect APIドキュメントには include パラメーターの説明がありますが、「どのエンドポイントがどのリソースをincludeできるか」の網羅的な表がないため、試してみるまでわかりません。preReleaseVersion を試したのは、ASCのUI上でビルドとバージョン文字列が関連付けられて表示されているのを見て「この結合がどこかに存在するはず」と推測したからです。


これらの失敗の共通点は、「正しいように見えるのに動かない」という状態が長続くことです。401も409も表面的には同じように見えますが、それぞれ別の原因を持っています。特効薬はなく、AppleのAPIレスポンスのボディを全部 json.dumps(r)[:800] で出力してじっくり読む、それだけです。

次回(後編)では、この asc.py をClaude Codeの自律ループに組み込み、12本のアプリを並列管理するパイプライン全体の設計を解説します。

つまずきポイント一覧

ここまでの内容で「なぜそう書いたか」の理由はほぼ語りました。ただ実装に取りかかると「その症状がそれとは気づかない」ことが問題の本質です。以下は、症状→原因→対策の形で網羅した索引です。同じ症状が出たときにここで引けるようにしています。


① 401が返るのにエラーボディに「signature」の文字がない

{"code":"NOT_AUTHORIZED","title":"Authentication credentials are missing or invalid."} は、ES256署名のエンコード形式ミスでも返ります。key.sign() が返すDERを _b64(der) に渡したときが典型例です。Apple側からは「認証失敗」としか見えません。cryptography.hazmat.primitives.asymmetric.utils にある decode_dss_signature(der) でPython整数 (r, s) に変換し、r.to_bytes(32,"big") + s.to_bytes(32,"big") を連結してからBase64URLに渡すのが正解です。インポートパスまで含めて間違えやすい箇所なので注意してください。


② 朝は通るのに昼過ぎに401が返る(再現性が低い)

MacのNTPズレ、スリープ復帰直後の時刻同期遅れが原因です。iatint(time.time()) のままにしていると、Appleサーバーの時刻と数秒ずれたとき「まだ有効になっていない」として弾かれます。asc.py では int(time.time()) - 30 で30秒引いています。Apple側の許容マージンは実測で±60秒前後なので、この値は十分な余裕があります。


③ 409 STATE_ERROR: Tester cannot be assigned(betaGroups直リンク)

POST /v1/betaGroups/{id}/relationships/betaTesters で既存テスターIDを渡す操作は、Appleのステートマシン上「許可されていない遷移」として弾かれます。エラーメッセージは正しいやり方を教えてくれません。asc.py のコメント(272行中の 注意: ブロック)に記した通り、POST /v1/betaTesters のボディに relationships.betaGroups を含めてテスター作成とグループ所属を1リクエストで完結させるのが唯一通るパターンです。


fastlane deliver 再実行後にMETADATA_ERROR: Too many screenshots

sync_screenshots: false または省略のデフォルトで deliver を複数回走らせると、スクリーンショットが削除されず追記されます。1サイズあたり10枚のApp Store上限を超えると審査提出が通りません。asc.py dedup <app_id> で重複チェックし、--apply を付けて削除します。sourceFileChecksum だけを重複キーにすると稀に誤削除が起きるため、(checksum, fileName) の両方をキーにしています(asc.pykey = (sh["attributes"].get("sourceFileChecksum"), sh["attributes"].get("fileName")) を参照)。


⑤ VALIDビルドが画面上にあるのに no VALID build for version X.X.X と言われる

/v1/builds のデフォルトレスポンスにはマーケティングバージョン文字列(0.3.2 等)が含まれません。&include=preReleaseVersion を付けないと included 配列が空のまま返り、バージョン文字列で突合できません。asc.py_build_for_version() がこのクエリパラメーターを明示している理由はここにあります。limit=20 で不足するほど短期間に大量アップロードした場合も同様の症状が出るので、ビルド数が多いアプリでは limit= を増やすか sort=-uploadedDate の並び順を確認してください。


⑥ submit前に version is WAITING_FOR_REVIEW (locked) エラー

WAITING_FOR_REVIEWIN_REVIEW 状態では appStoreVersion の編集も、新たな reviewSubmissionItem の追加もできません。asc.py では _EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED", "INVALID_BINARY"} の5状態のみ書き込みを許可し、それ以外ならガードを返します。審査をDeveloper Rejectで取り下げてから再提出するには asc.py reject <app_id> を使います(UNRESOLVED_ISSUESもこの関数が対象に含めています)。


⑦ INVALID_BINARYになったら新バージョンを作り直さないといけないと思っていた

INVALID_BINARY_EDITABLE に含まれます。つまり「ビルド処理でAppleに弾かれた」状態でも、バイナリを差し替えて同じバージョンのまま再提出できます。新しいバージョン文字列を用意してビルド番号を上げてアップロードし直したあと、asc.py attach-build <app_id> <ver> で上書きしてから submit を呼ぶだけです。バージョンを作り直す手間が省けます。


setup_signing.pyno ASC cert matches local SHA1 で終了する

setup_signing.py の先頭に LOCAL_SHA1 = "EC06777A...".lower() という定数があります。これはローカルキーチェーンにある配布証明書のSHA1フィンガープリントです。Apple Distributionの証明書は有効期限1年で、更新すると別のSHA1になります。年次更新後に setup_signing.py を実行したとき、この定数を更新していないと find_cert() が全件ループして一致せずに sys.exit(1) します。証明書を更新したら必ずキーチェーンアクセスでSHA1を確認して定数を書き換えてください。


archive.sh がプロビジョニングプロファイルを解決できずビルド失敗する

archive.shPROVISIONING_PROFILE_SPECIFIER="Auraly AppStore"setup_signing.pyPROFILE_NAME = "Auraly AppStore" と文字列一致で解決します。setup_signing.py を実行していない、または証明書更新後に再実行していない環境ではプロファイルが古いまま残り、xcodebuild が「該当するプロファイルが見つからない」か「証明書が一致しない」でコード署名エラーになります。パイプラインの先頭で setup_signing.pyarchive.sh の順を守ってください。また archive.sh の6行目に xcodegen generate があり、project.yml を変更した場合はこのステップが .xcodeproj を再生成します。これを省いて古い .xcodeproj のままビルドすると、スキーム構成の不整合でアーカイブが失敗することがあります。


release コマンドを呼んだのに submit されていない

asc.py release <app_id> <ver> "<whatsnew>" は「バージョン用意→ビルド添付→What's New設定」の3ステップをまとめて実行しますが、提出はしません。コード末尾のコメント通り "確認後 submit する" という意図です。release で準備してASCの画面で問題がないことを確認してから asc.py submit <app_id> を別に呼ぶ設計です。自動化の末尾で release を呼んで「提出完了」と思い込む誤解が起きやすいのでご注意ください。


dedup --apply で削除したはずのスクショが再び出てくる

dedup_screenshots() は現在の _latest_version() が返すバージョンの全ロケール・全サイズをスキャンします。このバージョンが _EDITABLE 以外の状態(例:WAITING_FOR_REVIEW)のときは apply=True でも早期リターンしてメッセージを出すだけで何も削除しません(asc.py 186行目のガード)。その状態のまま --apply を繰り返しても何も変わらないため、reject で編集可能状態に戻してから実行してください。


ベストプラクティス

実際に12本のアプリを運用してきてスタンダードになったルールをまとめます。


1. .p8 はディレクトリごと chmod 700 で隔離する

~/.appstoreconnect/ を作って chmod 700 ~/.appstoreconnect とするだけで、同マシンの他ユーザーからの読み取りを防げます。keys.jsonkey_idissuer_id しか持たないため、仮に流出しても秘密鍵本体がなければAPIは叩けません。CIに載せる場合はシークレットストアから実行時に ~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8 として書き出す形にし、コミット履歴に .p8 が含まれないことを必ず確認してください。


2. JWTは毎リクエストで生成する(キャッシュしない)

call() は呼び出しのたびに _jwt() を呼んでいます。900秒の有効期限があるのに毎回生成するのは非効率に見えますが、長時間バッチの途中でJWTが切れて後半のリクエストだけ401になる問題を防ぎます。ECDSA署名のコストはリクエストあたりマイクロ秒単位で、12本のバッチ全体でも体感できる差はありません。「キャッシュして少し速くする」より「どこで切れても同じ動作をする」を選ぶのがAPIクライアントの安定運用の原則です。


3. decode_dss_signatureto_bytes(32, "big") の連結は必ずテストする

ES256のJWT署名を実装したら、生成したJWT文字列を一度デコードして署名部が64バイト(Base64URLデコード後)になっているか確認してください。DERの場合は可変長(通常70〜72バイト)になります。len(base64.urlsafe_b64decode(jwt.split(".")[2] + "==")) で64が返れば正しいr‖sエンコードです。テストせずに「動いているはず」のままにしておくと、rまたはsがたまたま小さい値のときにゼロパディングが欠けて署名が壊れます。


4. iat は常に -30 秒する

本番環境のマシンがNTPと同期していても、スリープ復帰直後や高負荷時に数秒のズレが生じることがあります。Apple側が許容するマージンは±60秒程度と推測されますが、30秒の余裕はコストゼロで得られる安全マージンです。有効期限の900秒が870秒になるだけで実用上の問題はありません。


5. 全書き込み操作はGETで現状確認してからPOST/PATCHする

submit() はまず GET /v1/reviewSubmissions?filter[state]=READY_FOR_REVIEW で既存のsubmissionを確認し、あれば再利用します。add_tester() はまず _has_tester() で既存チェックをします。この「確認→操作」パターンをすべての書き込み系コマンドで徹底することで、Claude Codeがリトライした場合や手動で重複実行した場合でも二重提出・重複エラーが起きません。冪等性はAPIクライアントの最重要設計原則です。


6. _EDITABLE の5状態を頭に入れる

PREPARE_FOR_SUBMISSIONDEVELOPER_REJECTEDREJECTEDMETADATA_REJECTEDINVALID_BINARY の5状態のみ、バージョン属性の変更・ビルド差し替え・スクショ削除が可能です。それ以外の状態でこれらを試みると 409 または 422 が返ります。審査フローで詰まったとき、まず asc.py status <app_id> で現在の appStoreState を確認し、_EDITABLEに含まれない状態なら先に reject または reject 不要であれば審査完了を待つという判断ができます。


7. dedup --applysubmit の直前に毎回挟む

fastlane deliver の実行回数に関わらず、asc.py dedup <app_id> --applysubmit の直前に実行する習慣をつけてください。dry-runで0件でも実行コストは低く、二重化スクショによるMETADATA_ERRORを事前に排除できます。バッチスクリプトであれば for app_id in ...; do asc.py dedup "$app_id" --apply && asc.py submit "$app_id"; done と一行で直列に並べるだけで保証できます。


8. プロビジョニングプロファイルは削除→再作成サイクルで管理する

setup_signing.pyensure_profile() は、同名プロファイルを先に全削除してから新規作成します。「更新」ではなく「作り直し」です。証明書を更新したとき、古い証明書IDに紐づいたプロファイルが残ると「プロファイルは存在するが証明書が一致しない」という不整合が起きます。作り直しのサイクルにしておけば常に正しい証明書IDのプロファイルが1枚だけ存在し、xcodebuild が正しく解決できます。


9. CODE_SIGN_STYLE=Manual でヘッドレスビルドを安定させる

archive.shCODE_SIGN_STYLE=Manual を指定しているのは、Automatic Signingを使うとXcodeがプロビジョニングプロファイルを自動管理しようとして、ヘッドレス実行時にKeychain Access認証ダイアログが出たりApple Developer APIへの接続を試みたりするからです。ManualにしてProfile Specifierを名前で固定することで、setup_signing.py~/Library/MobileDevice/Provisioning Profiles/ に書き込んだプロファイルを xcodebuild が名前で解決するだけになり、GUI操作が一切不要になります。


10. 外部ライブラリは cryptography の1本に絞る

asc.py はHTTP通信に urllib.request を使い、requests を依存に含めません。新しいMacのセットアップ時、CI環境の初回実行時、pip install requests という手順が1つ減るだけで実際の摩擦は大きく変わります。cryptography だけは標準ライブラリで代替できないため最小限の依存として許容しています。スクリプトを別プロジェクトに持ち込むたびに pip install -r requirements.txt で詰まることがないのは、この設計判断の直接の恩恵です。


11. APIエラーは json.dumps(r)[:800] でレスポンスボディ全体を出す

asc.py の各関数は if st >= 400: print(json.dumps(r)[:800]); return というパターンで統一しています。Appleのエラーレスポンスは errors 配列の中に detail フィールドがあり、そこに原因のヒントが入っています。detail を表示せずに status だけ出力していると、409も401も422も同じ「エラー」にしか見えません。ボディを全部出す習慣が、原因特定の時間を半分以下にします。


12. releasesubmit は必ず別ステップで呼ぶ

release <app_id> <ver> "<whatsnew>" はバージョン準備・ビルド添付・What's New設定をまとめますが、意図的に提出は行いません。release 後に asc.py status <app_id> でメタデータが正しく設定されているか確認してから submit を呼ぶことで、「提出してから間違いに気づいてDeveloper Rejectする」という無駄なサイクルを防げます。自動化パイプラインに組み込む場合も、release の出力を確認するステップをClaude Codeが読める形でログに残し、問題がなければ submit へ進む設計にしています。


13. SHA1フィンガープリントは証明書更新時に必ず書き換える

setup_signing.pyLOCAL_SHA1 は配布証明書のSHA1で、Apple Distributionの証明書は1年で期限が切れます。更新後は「キーチェーンアクセス」で新しい配布証明書を選択し、「情報を見る」→「SHA-1フィンガープリント」で値を確認して定数を書き換えてください。書き換え忘れると find_cert() が200件の証明書を全件スキャンして一致せず sys.exit(1) で終了します。証明書更新をカレンダーに入れて、更新したその日に setup_signing.py の書き換えと動作確認までセットで済ませる運用が安定しています。


まとめ

「APIキーがあればJWTを生成して叩くだけ」は正しいのですが、そのJWTはES256の生r‖sエンコードでなければならず、iat に30秒のクロックスキュー余裕が必要で、include=preReleaseVersion を付け忘れればビルドが見えず、テスター追加はcreate-with-groupでなければ409になる——これらを全部自分で踏まないと、「2FA不要でAPIが叩けた」状態にはなりません。

今回紹介した asc.py は272行で、apps / status / submit の3コマンドが中核ですが、その背後には上で列挙したつまずきがすべて含まれています。ここに書いたことを読んだ上で実装すれば、私が2時間ハマったDER問題も、数十分溶けたクロックスキュー問題も、一発で通過できます。

最後に構造を振り返ります。Layer 1でバイナリを生成し(archive.sh のManual Signing)、Layer 2で転送し(eas submit)、Layer 3でASC APIを経由して審査提出する(asc.py submit)。この3レイヤーが揃って初めて「自分が寝ている間に12本のアプリへの審査提出が完走する」環境になります。コードは手元にあります。あとは動かすだけです。


次回(後編)では、この asc.py をClaude Codeの自律ループに組み込み、INVALID_BINARY再提出の突破手順と12本並列管理のパイプライン設計を解説します。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


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

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