アプリを作り終えてからストアに出すまでの間に、地味にいちばん消耗する作業があります。App Store Connectのフォーム埋めです。
アプリ名、サブタイトル、説明文4000字、キーワード100字、年齢制限の質問票、審査メモ、App内課金の情報 — これを全部Web画面でポチポチ入力し、文字数超過で弾かれてはコピペし直す。しかも修正のたびに同じ画面を開き直すことになります。
今回、猫のアルバムアプリを1本リリースするにあたって、この入力作業をApp Store Connect APIでコードにしました。この記事では、実際に書いたスクリプト、APIでは越えられなかった壁、そして提出フェーズで踏んだ4つの地雷を公開します。
この記事で分かること
- ASC APIの認証(JWTを自分で作る)で最初に詰まるポイント
- メタデータ投入スクリプトの実物と、送信前の文字数チェック
- APIではできなかったことの切り分け
- 提出フェーズで踏んだ4つの地雷(取り下げ・releaseType・配信地域・文字コード)
結論: 入力作業はコードにできる。提出ボタンだけ人間に残す
- ASC APIは依存ゼロで叩けます。Node標準の
node:cryptoでJWTを作るだけ - 効果は時短より、やり直しが怖くなくなること。スクリプトはPATCHしかしないので、何度実行しても同じ結果になります
- ただしAPIで届かない領域があります。Appプライバシーの質問票がその代表
- そして意図的に、提出(reviewSubmission)はスクリプトに入れませんでした。整えるところまでがコードの仕事です
認証: JWTを自分で作る
ASC APIの認証は、Connectで発行した秘密鍵(.p8)を使ってES256で署名したJWTをBearerトークンとして送る方式です。ライブラリは要りません。
// ES256 の JWT(有効期限は最大20分。ここでは10分)
export function makeToken() {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "ES256", kid: KEY_ID, typ: "JWT" };
const payload = { iss: ISSUER_ID, iat: now, exp: now + 600, aud: "appstoreconnect-v1" };
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const key = createPrivateKey(readFileSync(KEY_PATH));
const signer = createSign("SHA256");
signer.update(signingInput);
// ES256 は DER ではなく r‖s の raw 形式
const sig = signer.sign({ key, dsaEncoding: "ieee-p1363" });
return `${signingInput}.${b64url(sig)}`;
}
最初に必ず詰まるのが最後の1行です。Nodeのsign()は既定でDER形式の署名を返しますが、JWTのES256が求めるのはr‖sを連結したraw形式。dsaEncodingにieee-p1363を指定しないと、正しい鍵を使っていても認証が通りません。
あとはfetchで叩くだけなので、クライアント本体は78行に収まりました。CLIとしても使えるようにしてあります。
node tools/asc/asc.mjs GET /v1/apps
APIの構造を調べるとき、この1行が効きます。ドキュメントを読むより、実際のレスポンスを見た方が早いからです。
メタデータを流し込む
本体のスクリプトがやっているのは、5回のPATCHだけです。
| # | 対象 | エンドポイント |
|---|---|---|
| ① | 名前・サブタイトル・プライバシーURL | appInfoLocalizations |
| ② | カテゴリ(主・副) | appInfos |
| ③ | 年齢制限 | ageRatingDeclarations |
| ④ | 説明・キーワード・プロモ文・サポートURL | appStoreVersionLocalizations |
| ⑤ | App内課金の審査メモ | inAppPurchases |
このうち③が効きます。Web画面では20問以上のプルダウンを1つずつ選ぶあの質問票が、コードではこう書けます。
// 年齢制限: すべて「なし/いいえ」→ 4+
const ageRating = {
alcoholTobaccoOrDrugUseOrReferences: "NONE",
gamblingSimulated: "NONE",
horrorOrFearThemes: "NONE",
violenceCartoonOrFantasy: "NONE",
// …以下、質問票の全項目
userGeneratedContent: false,
unrestrictedWebAccess: false,
kidsAgeBand: null,
};
差分がレビューできる形で残るので、「前回どう答えたっけ」が消えます。
送信前に文字数を確認する
地味にいちばん助かっているのがこれです。APIに送る前に、ローカルで上限チェックして止める。
const limits = [
["name", appInfoLocalization.name, 30],
["subtitle", appInfoLocalization.subtitle, 30],
["promotionalText", promotionalText, 170],
["description", description, 4000],
["keywords", keywords, 100],
];
for (const [k, v, max] of limits) {
if (v.length > max) {
console.error(`${k} が上限超過: ${v.length}/${max}`);
process.exit(2);
}
console.log(` ${k}: ${v.length}/${max}`);
}
実行するたびにkeywords: 97/100のように出るので、キーワードの残り枠が一目で分かります。Web画面で貼り付けて弾かれてから削る、という往復が消えました。
冪等にする、dry-runを付ける
設計で意識したのは2点だけです。
- PATCHしかしない — 何度実行しても同じ状態になる。文言を直したら再実行するだけ
--dry-runを付ける — 送る内容をJSONで表示して終わる。本番に触る前に確認できる
この2つがあると、スクリプトを気軽に再実行できるようになります。これがコード化の一番の効用で、時短よりずっと大きい価値でした。
なお文言の正本は、コードではなくMarkdownのドキュメントにしています。スクリプトはあくまで投入係で、推敲は文章ファイル側でやる、という分担です。
APIでは越えられなかった壁
正直に、できなかったことも書きます。
- Appプライバシーの質問票 — 「データを収集しない」等の申告はWeb画面で行いました
- 提出物へのApp内課金の追加 — ここが最大の壁でした(後述)
- アプリ本体の作成・Sandboxテスターの作成 — Connectの画面で
- Archiveとアップロード — Xcodeの仕事
つまり全自動にはなりません。それでも、入力量が多くて修正が頻発する部分(説明文・キーワード・年齢制限・審査メモ)を押さえられるので、体感の負担はかなり減ります。
提出で踏んだ4つの地雷
ここからが本題かもしれません。提出フェーズで実際に踏んだものです。
1. 提出物にApp内課金をAPIで追加できない
初回リリースでは、アプリ本体とApp内課金を同じ提出物にまとめて出す必要があります。ところが提出物への課金アイテムの追加は、公開APIに該当する関係が見当たりませんでした。
解決策は合わせ技です。ConnectのApp内課金ページで「審査用に追加」して下書きの提出物を作り、そこへバージョン1.0のほうをAPIで追加する。片方ずつ別の道具で同じ下書きに入れる形になりました。
2. 一度送信された提出物は「取り下げ」からやり直す
先に作った提出物(バージョンのみ)が送信状態になっていて、課金を足せなくなりました。ここは提出物をキャンセルし、バージョンがDEVELOPER_REJECTEDになってから作り直しています。
「間違えたら取り下げて作り直せる」と分かっていれば怖くありませんが、初回は取り返しがつかないのではと固まる場面でした。
3. 取り下げるとreleaseTypeが戻る
これが一番危ない地雷でした。取り下げ→再提出をすると、手動公開に設定していたはずのreleaseTypeがAFTER_APPROVAL(承認後に自動公開)に戻っていました。
気付かなければ、審査に通った瞬間、スモークテストも待たずに公開されていたことになります。提出したあとにもう一度releaseTypeを確認する — これは手順書に赤字で書き足しました。
4. App内課金の配信地域が日本だけになっていた
提出直前に気付いた設定漏れです。App内課金の配信可能地域が日本のみになっていたので、inAppPurchaseAvailabilitiesで全175地域に変更しました。アプリ本体の配信地域とは別管理なので、見落としやすいところです。
おまけ: 説明文に使えない文字がある
Connectは説明文に罫線素片の─(U+2500)を受け付けません(409で弾かれます)。見た目の似ているダッシュ—に置き換えて解決しました。原稿をエディタで書いているときほど踏みやすい罠です。
何を自動化して、何を人間に残したか
最終的な分担はこうなりました。
| 作業 | 担当 |
|---|---|
| プライバシーポリシー・利用規約の公開 | GitHub Pages |
| アプリ・App内課金・Sandboxテスターの作成 | Connect(手動) |
| 名前・サブタイトル・カテゴリ・年齢制限 | API |
| 説明・キーワード・プロモ文・サポートURL | API |
| 審査メモ | API(原稿はテキストファイルが正本) |
| Appプライバシー質問票 | Connect(手動) |
| スクリーンショット | シミュレータで撮影して投入 |
| Archive・アップロード | Xcode(手動) |
| 審査へ提出 | 人間(手動公開を選択) |
この線引きは、以前アプリ運用の自動化について書いた記事と同じ結論です。AIとスクリプトは提出台まで整える。ボタンは自分で押す。
今回それがはっきり正しかったと分かったのは、地雷3(releaseTypeが戻る)を自分の目で見つけられたからです。全自動にしていたら、気付かないまま公開されていました。
ついでに効いた小技
- 輸出コンプライアンスの質問をスキップする —
Info.plistにITSAppUsesNonExemptEncryption = NOを書いておくと、アップロードのたびに聞かれる質問が出なくなります(HTTPSと標準暗号のみを使う場合) - スクリーンショットはシミュレータで撮る — 必須サイズは6.9インチですが、手元の実機がそのサイズとは限りません。シミュレータなら必要なサイズを確実に用意できます
GETで構造を調べる — CLIとして叩けるようにしておくと、IDの取得やレスポンス構造の確認が一瞬で済みます
まとめ
- App Store Connect APIは依存ゼロで使える。詰まるのはES256署名の形式(
ieee-p1363)くらい - 効くのは説明文・キーワード・年齢制限・審査メモといった「入力量が多くて何度も直す」領域
- 設計はPATCHのみで冪等にして
--dry-runを付ける。これで再実行が怖くなくなる - 一方でAppプライバシー質問票や提出物への課金追加は画面での操作が残る。全自動は狙わない
- 提出フェーズの地雷は覚えておく価値がある。特に取り下げ後に
releaseTypeが自動公開へ戻る件
ストアに出す作業は、クリエイティブではないのに間違えられない、という一番疲れる種類の仕事です。間違えたらすぐ直せる形にしておくだけで、リリース前の心理的な負担はかなり軽くなります。



