1.2.2 · 開発基盤

アプリ申請を多言語対応しよう

2026.07.12約5分

00Overview

01Story

Situation

1.2.1 でアプリ本体を多言語化した。次は、それをストアに出すための申請情報 — タイトル、サブタイトル、説明文、キーワード — である。

Complication

これも各言語で登録が要る。1 言語だけなら、ストアの管理画面に手入力すればいい。しかし 50 言語となると、管理画面での手入力は現実的でない。しかもキーワードは、ただ訳すだけでなく、各国のローカル検索語に置き換えて ASO (アプリストア最適化) しないと効かない。

Question

大量の言語の申請情報を、どう管理し、どう登録するか?

02Solution

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 管理 + 自動登録にした。

03Result

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 の仕様変更によって、挙動が変わったりします。