アプリを作り終えてからストアに出すまでの間に、地味にいちばん消耗する作業があります。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形式dsaEncodingieee-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が戻る

これが一番危ない地雷でした。取り下げ→再提出をすると、手動公開に設定していたはずのreleaseTypeAFTER_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.plistITSAppUsesNonExemptEncryption = NOを書いておくと、アップロードのたびに聞かれる質問が出なくなります(HTTPSと標準暗号のみを使う場合)
  • スクリーンショットはシミュレータで撮る — 必須サイズは6.9インチですが、手元の実機がそのサイズとは限りません。シミュレータなら必要なサイズを確実に用意できます
  • GETで構造を調べる — CLIとして叩けるようにしておくと、IDの取得やレスポンス構造の確認が一瞬で済みます

まとめ

  • App Store Connect APIは依存ゼロで使える。詰まるのはES256署名の形式(ieee-p1363)くらい
  • 効くのは説明文・キーワード・年齢制限・審査メモといった「入力量が多くて何度も直す」領域
  • 設計はPATCHのみで冪等にして--dry-runを付ける。これで再実行が怖くなくなる
  • 一方でAppプライバシー質問票や提出物への課金追加は画面での操作が残る。全自動は狙わない
  • 提出フェーズの地雷は覚えておく価値がある。特に取り下げ後にreleaseTypeが自動公開へ戻る

ストアに出す作業は、クリエイティブではないのに間違えられない、という一番疲れる種類の仕事です。間違えたらすぐ直せる形にしておくだけで、リリース前の心理的な負担はかなり軽くなります。