アプリ申請を多言語対応しよう
00要約Overview
01物語Story
Situation
1.2.1 でアプリ本体を多言語化した。次は、それをストアに出すための申請情報 — タイトル、サブタイトル、説明文、キーワード — である。
Complication
これも各言語で登録が要る。1 言語だけなら、ストアの管理画面に手入力すればいい。しかし 50 言語となると、管理画面での手入力は現実的でない。しかもキーワードは、ただ訳すだけでなく、各国のローカル検索語に置き換えて ASO (アプリストア最適化) しないと効かない。
Question
大量の言語の申請情報を、どう管理し、どう登録するか?
02解決Solution
Criteria
- 申請情報を Git で一元管理できること
- AI が各言語の文化・検索語に合わせて翻訳できること
- ストアへ自動で登録できること
Answer
申請メタデータを、アプリごとに 1 つの設定ファイル (store.config.json.txt) にまとめて Git で管理する。日本語を起点に、AI が意味を理解して 50 ロケールへ翻訳し (機械翻訳ではなく)、GitHub Actions から App Store Connect の API 経由で自動登録する。キーワードは各言語で 100 字の上限まで詰め、その国のローカル検索語に置き換えて ASO を最適化する。
Reason
申請情報も、ソースコードと同じく Git で管理できるからである。 ファイルにまとめておけば、履歴も差分も追え、Actions から自動で登録できる。手入力のミスも消える。文字数制限 (タイトル 30 / サブタイトル 30 / 説明 4000 / キーワード 100 など) の検証も、スクリプトで自動化できる。
テキストである以上、コードと同じ流儀が効く。
一か所に集約して、自動化する。
もう一つ、翻訳を AI に任せる利点がある。単なる直訳では、文化に合わない表現や、検索されない語になってしまう。AI に意味を理解させて訳せば、各国のトーンやローカル検索語に合わせられる。センシティブな話題も、地域ごとに本文での触れ方を変えられる (検索流入のためキーワードには共通で残しつつ、本文の明記は国の受容度で調整する)。
Options
- 各ストアの管理画面で手入力する — 1〜2 言語なら問題ない。しかし言語が増えるほど手作業が破綻し、履歴も残らない。だから最初から Git 管理 + 自動登録にした。
03結果Result
Good
一度この仕組みを作れば、新しい言語の追加は「翻訳して、登録ワークフローを叩く」だけになる。50 言語でも、言語を 1 つ足すコストはほとんど変わらない。文字数オーバーやローカル検索語の最適化も、スクリプトと AI に載せられる。
Bad
自動化ゆえの事故もある。翻訳文にダブルクオートが混ざって設定ファイルの JSON が壊れたり、文字数が上限を超えたり。だから書き込みのたびに、JSON が壊れていないか (再読み込み) と文字数が制限内かを、必ず検証する工程を挟んでいる。
Follow-up
テキスト (本体・申請) の多言語化が済んだ。最後に残るのが、いちばん手強い画像 — スクリーンショットである。続きは 1.2.3 スクリーンショットを多言語対応しよう。
■ 再現できる方法
この構成を自分で再現したい人向けのメモ。読み物ではないので、指示文は数行だけ表示してスクロールにしてある。
submit ができていれば、下記の設定は既に入っていると思う。App Store Connect API Key を発行 (Users and Access → Integrations → App Store Connect API)。.p8 ファイル・Key ID・Issuer ID の 3 点を控える (.p8 は再ダウンロード不可なので必ず保存)。GitHub Secrets に 3 つ追加: APPLE_API_KEY (.p8 の全文) / APPLE_API_KEY_ID / APPLE_API_KEY_ISSUER_ID。
そのうえで、下記を 1 回のコピペで貼り付けて実行 (プロンプト + Gemfile + workflow + Fastfile + 設定テンプレをまとめて渡す)。
App Store Connect のメタデータを GitHub Actions から fastlane
(spaceship 直叩き)で自動アップロードする仕組みを作りたい。
前提:
・ASC の API キーは GitHub Secrets に APPLE_API_KEY_ID /
APPLE_API_KEY_ISSUER_ID / APPLE_API_KEY_P8 の3つで登録済み
・アプリは SKU=<自分のSKU>、Bundle ID=<自分のBundleID> で ASC 登録済み
作ってほしいもの:
1. Gemfile … fastlane 2.228 系を固定
2. fastlane/Fastfile に upload_metadata_direct レーンを spaceship 直叩きで実装
・apps/<SKU>/store.config.json.txt を読む
・ASC の編集中バージョンに title/subtitle/description/keywords/
promoText/releaseNotes/copyright をロケール別に書き込む
・空欄のフィールドはスキップ、初回1.0では releaseNotes を自動除外
3. .github/workflows/ に metadata 用の workflow_dispatch ワークフローを1つ。
env に APP_SKU / APP_BUNDLE_ID と3つの Secret を渡す
4. apps/<SKU>/store.config.json.txt の空テンプレ(apple.info の下に
50ロケール分のキーだけ用意、値は空)
source "https://rubygems.org"
gem "fastlane", "~> 2.228.0"
name: <SKU> — metadata
on:
workflow_dispatch:
jobs:
upload:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Run fastlane upload_metadata_direct
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY_ISSUER_ID: ${{ secrets.APPLE_API_KEY_ISSUER_ID }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APP_SKU: <SKU>
APP_BUNDLE_ID: <BundleID>
FASTLANE_SKIP_UPDATE_CHECK: "1"
run: bundle exec fastlane ios upload_metadata_direct
<SKU> / <BundleID> が分からなかったら聞いて、確認して。
deliver を使わず spaceship で直接 API を叩いています。fastlane 2.228 に
残っているバグ(AppInfo のリレーション名間違い・エラーを握り潰す update
ラッパー)を回避するコードが入っているのがポイントです。ハマるので下記を参照。
default_platform(:ios)
platform :ios do
desc "Upload per-locale version metadata via spaceship, reading apps/<SKU>/store.config.json.txt directly"
lane :upload_metadata_direct do |options|
sku = options[:sku] || ENV["APP_SKU"]
bundle_id = options[:bundle_id] || ENV["APP_BUNDLE_ID"]
UI.user_error!("sku is required") if sku.nil? || sku.empty?
UI.user_error!("bundle_id is required") if bundle_id.nil? || bundle_id.empty?
config_path = File.expand_path("../apps/#{sku}/store.config.json.txt", __dir__)
UI.user_error!("store.config.json.txt not found: #{config_path}") unless File.exist?(config_path)
require "json"
config = JSON.parse(File.read(config_path, encoding: "UTF-8"))
locales_data = config.dig("apple", "info") || {}
copyright_value = config.dig("apple", "copyright")
UI.user_error!("apple.info missing in #{config_path}") if locales_data.empty?
require "spaceship"
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
key_id: ENV.fetch("APPLE_API_KEY_ID"),
issuer_id: ENV.fetch("APPLE_API_KEY_ISSUER_ID"),
key: ENV.fetch("APPLE_API_KEY_P8"),
)
# fastlane 2.228.0 のバグ回避:
# spaceship の post_app_info_localization が誤ったリレーション
# ("appStoreVersion"/"appStoreVersions") を送るので、正しい
# ("appInfo"/"appInfos") を送る実装で上書きする。
fix_module = Module.new do
def post_app_info_localization(app_info_id: nil, attributes: {})
body = {
data: {
type: "appInfoLocalizations",
attributes: attributes,
relationships: {
appInfo: {
data: {
type: "appInfos",
id: app_info_id,
},
},
},
},
}
tunes_request_client.post("v1/appInfoLocalizations", body)
end
end
Spaceship::ConnectAPI::Tunes::API.prepend(fix_module)
app = Spaceship::ConnectAPI::App.find(bundle_id)
UI.user_error!("app not found for bundle_id=#{bundle_id}") if app.nil?
version = app.get_edit_app_store_version
UI.user_error!("no edit version found on App Store Connect") if version.nil?
UI.message("Writing to version #{version.version_string} (#{version.app_store_state})")
# JSON key -> AppStoreVersionLocalization API field
field_map = {
"description" => :description,
"promoText" => :promotional_text,
"marketingUrl" => :marketing_url,
"supportUrl" => :support_url,
"releaseNotes" => :whats_new,
}
# spaceship の update は snake_case を受け付けるが、create を叩く
# post_app_store_version_localization は camelCase でないと
# Apple API に「unknown attribute」と弾かれる。
snake_to_camel = lambda do |sym|
parts = sym.to_s.split("_")
(parts[0] + parts[1..].map(&:capitalize).join).to_sym
end
to_camel_attrs = lambda do |attrs|
attrs.each_with_object({}) { |(k, v), h| h[snake_to_camel.call(k)] = v }
end
summary_lines = ["## Direct metadata upload to #{version.version_string}", ""]
# ---------------------------------------------------------------------
# 0) AppStoreVersion レベルの copyright(アプリ共通の1値)。
# ---------------------------------------------------------------------
if copyright_value && !copyright_value.to_s.strip.empty?
copyright_value = copyright_value.to_s.strip
if version.copyright == copyright_value
summary_lines << "Copyright unchanged: `#{copyright_value}`"
else
UI.message("Updating version copyright -> #{copyright_value}")
begin
version.update(attributes: { copyright: copyright_value })
summary_lines << "Copyright updated: `#{copyright_value}`"
rescue => e
UI.error("Failed to update copyright: #{e.message.lines.first.strip}")
summary_lines << "⚠️ Copyright update failed (#{e.message.lines.first.strip})"
end
end
summary_lines << ""
end
# ---------------------------------------------------------------------
# 1) 先に AppInfoLocalization(アプリレベルでロケールを登録)。
# 新規ロケールを版レベルの書き込み前に認識させるため、こちらが先。
# ---------------------------------------------------------------------
app_info = app.fetch_edit_app_info
app_updated = 0
app_created = 0
if app_info
summary_lines << "## App-level localization updates (AppInfo)"
# ローカライズされたアプリ名が Apple に弾かれる SKU(希望名が別
# アカウントで登録済み)は、name を送ると AppInfo リクエスト全体が
# 失敗して subtitle / privacyPolicyUrl まで巻き添えになる。該当 SKU は
# name を送らず、アプリ名は ASC 上で手動管理する。該当なければ空でよい。
skip_app_name_skus = %w[]
app_field_map = {
"title" => :name,
"subtitle" => :subtitle,
"privacyPolicyUrl" => :privacyPolicyUrl,
}
app_field_map.delete("title") if skip_app_name_skus.include?(sku)
existing_app_locs = app_info.get_app_info_localizations
.each_with_object({}) { |loc, h| h[loc.locale] = loc }
locales_data.each_key do |loc_code|
loc_data = locales_data[loc_code]
app_attrs = {}
app_field_map.each do |json_key, api_field|
val = loc_data[json_key]
app_attrs[api_field] = val.to_s.strip if val.is_a?(String) && !val.strip.empty?
end
if app_attrs.empty?
summary_lines << "- **#{loc_code}**: AppInfo skipped (no fields)"
next
end
app_fmt_err = lambda do |e|
msg = e.message.to_s.gsub(/\s+/, " ").strip
msg = msg[0, 400] + "…" if msg.length > 400
"#{e.class}: #{msg}"
end
if existing_app_locs.key?(loc_code)
UI.message("Updating AppInfo[#{loc_code}] (#{app_attrs.keys.join(', ')})...")
begin
# spaceship の AppInfoLocalization#update はエラーを握り潰すので、
# 低レベル PATCH を直接叩く。
Spaceship::ConnectAPI.patch_app_info_localization(
app_info_localization_id: existing_app_locs[loc_code].id,
attributes: to_camel_attrs.call(app_attrs),
)
summary_lines << "- **#{loc_code}**: AppInfo updated #{app_attrs.keys.length} field(s)"
app_updated += 1
rescue => e
err = app_fmt_err.call(e)
UI.error("Failed to update AppInfo[#{loc_code}]: #{err}")
summary_lines << "- **#{loc_code}**: ⚠️ AppInfo update failed (#{err})"
end
else
UI.message("Creating AppInfo[#{loc_code}] (#{app_attrs.keys.join(', ')})...")
begin
app_info.create_app_info_localization(
attributes: to_camel_attrs.call(app_attrs).merge(locale: loc_code),
)
summary_lines << "- **#{loc_code}**: AppInfo created #{app_attrs.keys.length} field(s)"
app_created += 1
rescue => e
err = app_fmt_err.call(e)
UI.error("Failed to create AppInfo[#{loc_code}]: #{err}")
summary_lines << "- **#{loc_code}**: ⚠️ AppInfo create failed (#{err})"
end
end
end
summary_lines << ""
else
summary_lines << "AppInfo edit version not available; skipped app-level localization update."
summary_lines << ""
end
# ---------------------------------------------------------------------
# 2) AppStoreVersionLocalization(版ごとの description / promo など)。
# AppInfo で作った新規ロケールが版レベルでも見えるよう再取得。
# ---------------------------------------------------------------------
summary_lines << "## Version-level localization updates"
updated = 0
created = 0
existing_version_locs = version.get_app_store_version_localizations
.each_with_object({}) { |loc, h| h[loc.locale] = loc }
# Apple は初回 1.0 では whatsNew(リリースノート)を弾く
# ("Attribute 'whatsNew' cannot be edited at this time")。前バージョンが
# 無いため。1.0 のときは自動除外して残りだけ上げる。
is_initial_version = version.version_string.to_s.strip == "1.0"
if is_initial_version
summary_lines << "_(initial 1.0 — releaseNotes/whatsNew skipped; not editable on first version)_"
end
locales_data.each_key do |loc_code|
loc_data = locales_data[loc_code]
attrs = {}
field_map.each do |json_key, api_field|
val = loc_data[json_key]
attrs[api_field] = val.to_s.strip if val.is_a?(String) && !val.strip.empty?
end
kw = loc_data["keywords"]
attrs[:keywords] = kw.join(",") if kw.is_a?(Array) && !kw.empty?
attrs.delete(:whats_new) if is_initial_version
if attrs.empty?
summary_lines << "- **#{loc_code}**: skipped (no non-empty fields)"
next
end
fmt_err = lambda do |e|
msg = e.message.to_s.gsub(/\s+/, " ").strip
msg = msg[0, 400] + "…" if msg.length > 400
"#{e.class}: #{msg}"
end
if existing_version_locs.key?(loc_code)
UI.message("Updating #{loc_code} (#{attrs.keys.join(', ')})...")
begin
# spaceship の update ラッパーは bare rescue でエラーを握り潰し、
# @locale だけ再 raise する。低レベル PATCH を直接叩いて本当の
# API エラーがログに出るようにする。
Spaceship::ConnectAPI.patch_app_store_version_localization(
app_store_version_localization_id: existing_version_locs[loc_code].id,
attributes: to_camel_attrs.call(attrs),
)
summary_lines << "- **#{loc_code}**: updated #{attrs.keys.length} field(s)"
updated += 1
rescue => e
err = fmt_err.call(e)
UI.error("Failed to update #{loc_code}: #{err}")
summary_lines << "- **#{loc_code}**: ⚠️ update failed (#{err})"
end
else
UI.message("Creating #{loc_code} (#{attrs.keys.join(', ')})...")
begin
version.create_app_store_version_localization(
attributes: to_camel_attrs.call(attrs).merge(locale: loc_code),
)
summary_lines << "- **#{loc_code}**: created #{attrs.keys.length} field(s)"
created += 1
rescue => e
err = fmt_err.call(e)
UI.error("Failed to create #{loc_code}: #{err}")
summary_lines << "- **#{loc_code}**: ⚠️ create failed (#{err})"
end
end
end
summary_lines << ""
summary_lines << "App-level: #{app_updated} updated, #{app_created} created. Version-level: #{updated} updated, #{created} created."
report = summary_lines.join("\n")
UI.message(report)
step_summary = ENV["GITHUB_STEP_SUMMARY"]
File.write(step_summary, report) if step_summary && !step_summary.empty?
end
end
{
"apple": {
"copyright": "2026 Your Name.",
"info": {
"ja": {
"title": "アプリ名",
"subtitle": "",
"promoText": "",
"description": "",
"keywords": [],
"releaseNotes": "",
"privacyPolicyUrl": "https://example.com/privacy",
"supportUrl": "https://example.com/support",
"marketingUrl": "https://example.com"
},
"en-US": { "...": "同じ構造で各ロケール分" }
}
}
}
細かい挙動については、GitHub Actions の実行結果をそのまま Claude にコピペして修正してもらってください。library のバージョンや Apple の仕様変更によって、挙動が変わったりします。