34言語×2ストアのメタデータ運用を、正本1セット+fastlane でストアごと1コマンド化した話
| 意思決定 | ピアノ
タグ: #fastlane #Ruby #iOS #Android #ストア運用 #自動化
ピアノアプリ(広告なし版)は34言語で App Store と Google Play に出しています。掲載文言の管理対象は68通り。手作業のコピペはとっくに破綻していました。1言語1ファイルの正本と Ruby パーサ、生成時の文字数上限検証、fastlane のレーン分離で、ストアごと1コマンドに集約した記録です。
ストア掲載文言の組み合わせ爆発
ピアノアプリ「シンプルなピアノ」の広告なし版は、App Store と Google Play で34言語に対応しています。アプリ名・説明文・リリースノートといった掲載文言の管理対象は、単純計算で 34言語 × 2ストア = 68通りです。
これまでの運用は、正直に言えば手作業でした。説明文を1か所直すたびに、App Store Connect と Play Console のフォームへ34言語分コピペしていく。当然、更新は後回しになります。「iOS 側だけ新しい」「Play 側は数年前の累計ダウンロード数表記のまま」という不整合が、静かに溜まっていました。
決定打は、fastlane でのメタデータ管理を進める途中で気づいた構造的な問題です。Play へのアップロードに使う supply は、ローカルの管理ディレクトリにあるロケールだけを更新します。管理下に en-US しか置いていない状態では、公開中の残り33言語分の掲載は正本管理の外です。コードから全ロケールを再登録できる体制がないと、掲載が古いまま残っても差分に気づけません。万一ストア側の掲載が失われたとき、復元する手段もありません。「一部だけコード管理」は、手作業と同じ弱点を抱えたままでした。
そこで全ロケールの掲載文言を1セットの「正本」に集約しました。そこから両ストア向けの成果物を生成して fastlane で反映するところまで、ストアごとに1コマンドで済むように自動化します。
正本は「1言語1ファイル」のプレーンテキスト
正本は appstore_metadata/ ディレクトリに置いた、1言語1ファイルのテキストです。ファイル名は 01-en-英語.txt のような連番+言語コード+和名の形式。中身はセクションラベルで区切ります。
App Name:
(App Store 用のアプリ名)
Description:
(App Store 用の説明文)
What's New:
(リリースノート)
PlayStore App Name:
(Play 用のアプリ名・30文字以内)
Short Description (80文字以内・PlayStore用):
(Play 用の短い説明)
Full Description (4000文字以内・PlayStore用):
(Play 用の詳細説明)
App Store 用と Play 用のフィールドを、同じファイルに同居させています。ここがポイントです。翻訳を更新するとき、1言語につき開くファイルは1つだけ。ここから Ruby スクリプトが、Play 用の fastlane/metadata/android/<ロケール>/ 配下のファイル群を機械的に生成します。
パーサは素朴な行スキャンです。ただし壊れた正本を黙って通さないことは重視しました。ラベルの重複、ラベルより前に本文が書かれているケースは、その場でエラー終了します。
もう1つ地味に効いたのが、ファイル名からの言語コード抽出です。26-pt-BR-ポルトガル語(ブラジル).txt のように、言語コード自体にハイフンを含むケースがあります。単純な split("-") では誤抽出します。既知の言語コードの最長一致で判定するようにしました。
# ファイル名 "NN-<code>-<和名>.txt" から言語コードを取り出す。
# 和名にハイフンが入っても誤抽出しないよう、既知コードの最長一致で判定する
def appstore_code_from_filename(filename)
base = File.basename(filename, ".txt")
rest = base.sub(/^\d+-/, "")
code = APPSTORE_TO_PLAY.keys
.sort_by { |k| -k.length }
.find { |k| rest.start_with?("#{k}-") }
abort_with("ファイル名から既知の言語コードを特定できません: #{filename}") unless code
code
end
APPSTORE_TO_PLAY は、App Store の言語コードから Play のロケールへのマッピング表です。両ストアでロケール体系が微妙に違います(he と iw-IL、zh-Hans と zh-CN など)。さらに Play にはスペイン語とフランス語の地域差分ロケールがあります。だから es は es-ES と es-419 の2つへ、fr は fr-FR と fr-CA の2つへ同一内容を複製します。結果として、正本34言語から Play 側は36ロケールが生成されます。
文字数上限は「生成時」に落とす
Play の掲載文言には文字数上限があります。アプリ名 30、短い説明 80、詳細説明 4000。アップロード時に Google 側で弾かれてから気づくのでは遅い。生成スクリプトの時点で検証して、即エラーにします。
# supply はファイル内容をそのまま送信するため、書き込む文字列
# (末尾改行込み)を検証・出力の両方で使う
def payload(text)
"#{text}\n"
end
def each_locale_section(label, max_length:)
source_files.each do |path|
code = appstore_code_from_filename(path)
text = parse_sections(path)[label].to_s
abort_with("#{File.basename(path)}: #{label} が空です") if text.empty?
body = payload(text)
if body.length > max_length
abort_with("#{File.basename(path)}: #{label} が末尾改行込み#{body.length}文字。" \
"上限 #{max_length} 文字を超えています")
end
APPSTORE_TO_PLAY.fetch(code).each { |locale| yield locale, body }
end
end
細かいですが「末尾改行込みで数える」のが重要でした。supply はメタデータファイルの内容をそのまま API へ送ります。ファイル末尾の改行も文字数に含まれます。ぴったり上限で書いた翻訳が改行1文字で弾かれる。そんな事故を生成時点で検出できます。
「listing だけ」を安全に反映するレーン
supply(upload_to_play_store)は多機能です。バイナリ・掲載文言・リリースノート・画像類を、1回の呼び出しでまとめて送れます。裏を返すと、意図しないものまで送ってしまう怖さがあります。そこで用途ごとにレーンを分けました。それぞれのレーンで「触らないもの」を、スキップフラグで全部明示します。
掲載文言だけを反映するレーンはこうです。
desc "ストア掲載情報(title/short/full description)のみを全ロケール分アップロード"
lane :sync_listings do |options|
# 正本から再生成してから送る。生成スクリプトが非空・文字数上限を検証する
sh("ruby", File.join(FLUTTER_PROJECT_ROOT, "scripts", "generate_play_listings.rb"))
missing = locales_missing_listing_files
if missing.any?
UI.user_error!("掲載文言ファイルが不足しています: #{missing.join(', ')}")
end
# バイナリ・リリースノート・画像類には触れず、listing だけを反映する
upload_to_play_store(
track: "production",
version_code: options[:version_code] || pubspec_version_code,
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
レーンの先頭で毎回正本から再生成しています。生成物の手編集を無効化して、「正本とストアが必ず一致する」状態を保つためです。生成物を直接直したくなったら、それは正本を直すべきサインです。
リリースノート側にも同様の検証があります。supply は changelogs/<versionCode>.txt が欠けたロケールに、空のリリースノートを送ってしまいます。だからアップロード前に、全36ロケール分の存在と非空をチェックしてから進みます。
ハマりどころ1: precheck のアプリ内課金チェックは API キー認証で動かない
iOS 側は deliver(upload_to_app_store)で、メタデータとバイナリを同時にアップロードします。ここでデフォルトで走る precheck(審査リジェクトの事前チェック)が問題になりました。precheck のうちアプリ内課金のチェックは、App Store Connect API キー認証に対応していません。API キーで運用しているこの構成では、precheck がエラーで止まります。
課金項目のないアプリでも、チェック自体が失敗扱いになります。なので run_precheck_before_submit: false で無効化しました。precheck が見てくれるはずだった項目、たとえば説明文内の禁止ワードは、正本のレビューで担保する運用です。
ハマりどころ2: Bundler の環境を継承した flutter が CocoaPods を壊す
もう1つは環境変数の継承問題です。fastlane を Bundler(bundle exec)経由で動かすと、RUBYOPT や BUNDLE_GEMFILE が子プロセスへ継承されます。fastlane のレーン内から flutter build ipa を呼ぶ。すると flutter が内部で呼ぶ CocoaPods(Homebrew の Ruby)まで、このプロジェクトの Gemfile を強制ロードします。Ruby バージョン不一致で起動に失敗。flutter はこれを「CocoaPods is installed but broken」と誤判定し、pod install を黙ってスキップしていました。
ビルドは通るのに、中身は古い Pods のまま。一番嫌なタイプの静かな失敗です(気づくまで、直っているつもりのビルドを配っていました)。対策は、flutter 実行時だけ Bundler の環境を切り離すことです。
def build_flutter_ipa
Dir.chdir(FLUTTER_PROJECT_ROOT) do
Bundler.with_unbundled_env do
sh("flutter", "clean")
sh("flutter", "pub", "get")
sh("flutter", "build", "ipa", "--release")
end
end
end
Bundler.with_unbundled_env のブロック内では、Bundler が設定した環境変数が除去されます。flutter とその配下の CocoaPods は、素の環境で動きます。
教訓
- 正本は1つ、残りは生成物にする。 34言語 × 2ストアの掲載文言を人力で同期し続けるのは無理でした。触るファイルを1言語1つに絞る。ストアごとの形式差はスクリプトに吸収させる。更新コストがロケール数に比例しなくなります。
- 検証はアップロード時ではなく生成時に。 文字数上限・空フィールド・ロケールの過不足は、ストア API に弾かれる前にローカルで fail fast させる。エラーに「どのファイルの・どのセクションが・何文字超過か」まで入れると、修正は一瞬で終わります。
- 多機能ツールには「触らないもの」を明示したレーンを作る。 supply も deliver も1コマンドで何でも送れます。だからこそ用途別に分け、スキップフラグを省略せず全部書く。「このレーンは画像には絶対触れない」と読めることが、そのまま安心感になります。
- ツールチェーンをまたぐときは環境変数の継承を疑う。 Bundler → fastlane → flutter → CocoaPods と Ruby 製ツールが多段に連なると、良かれと設定された環境変数が下層で毒になります。「インストールされているのに壊れている」系のエラーを見たら、まず環境の継承を切って再現確認する。
1コマンド化と言うと Fastfile の話に聞こえます。でも振り返ると、本体は正本の設計と生成時検証でした。レーンはその薄い皮です。ストアメタデータのような「コードでないもの」も、コードと同じようにリポジトリで単一の正から生成・検証する。当たり前の原則が、34言語というスケールでは一番効きました。これで説明文を直すのが、ちょっと楽しくなりました。