
Приложение поддерживает 20+ языков. Каждое обновление — это не только новая сборка, но и правки в обоих магазинах: название, подзаголовок, описание, «что нового». Для каждого языка, на каждой платформе, через медленный веб-интерфейс.
При двух десятках языков и двух платформах обновление «что нового» вручную занимает 30–40 минут. При этом легко ошибиться: скопировать не ту версию текста, забыть один язык, перепутать поля между iOS и Android.
В статье покажу, как свести всё это к одному коммиту.
Fastlane в двух словах
fastlane — Ruby-инструментарий для автоматизации мобильного CI/CD. Два инструмента нас интересуют:
-
deliver — загружает метаданные в App Store Connect
-
supply — загружает метаданные в Google Play
Главная идея: оба читают данные из файлов на диске, а не из веб-интерфейса. Достаточно положить правильные .txt-файлы в правильные папки — и fastlane сделает остальное.
Устанавливать fastlane локально не обязательно. Достаточно Gemfile в репозитории — CI скачает всё нужное сам при каждом запуске:
# Gemfilesource "https://rubygems.org"gem "fastlane", "~> 2.235"# Явно прописываем транзитивную зависимость, чтобы избежать сбоев на CI-агентахgem "multi_json", ">= 1.14.1"
Архитектура: один файл на язык
Fastlane ожидает метаданные в виде дерева .txt-файлов:
fastlane/metadata/ ios/ en-US/ name.txt subtitle.txt keywords.txt promotional_text.txt description.txt release_notes.txt de-DE/ ... android/ en-US/ title.txt short_description.txt full_description.txt changelogs/ default.txt de-DE/ ...
Поддерживать эту структуру вручную неудобно: много мелких файлов, неочевидный diff при изменениях, легко сбиться с маппингом кодов локалей.
Решение: хранить все тексты в читаемых Markdown-файлах — по одному на язык. Python-скрипт генерирует из них структуру для fastlane прямо в CI перед каждой загрузкой. Папка fastlane/metadata/ нигде не хранится — это временный артефакт сборки, который создаётся и исчезает вместе с агентом.
stores/ ← исходные тексты, версионированы в git STORES.md ← английский (дефолтный, en-US) STORES-de.md STORES-fr-FR.md STORES-ja.md STORES-ru.md ...scripts/ prepare_metadata.py ← генерирует fastlane/metadata/ из stores/Gemfile ← fastlane и зависимости для CI
Формат STORES-файла
Это не какой то стандарт, а придуманный под данную задачу формат. Каждый файл содержит секции с фиксированными заголовками. Контент — в блоках ```text (чтобы символы разметки не мешали и лимиты символов были видны прямо в заголовке). В конце файла — секция «Что нового» с историей версий, новейшая запись первая:
<!-- Language: German -->### Title```textMeineApp - Kurzer Slogan```### Appstore keywords (100 characters max)```textproduktivität,aufgaben,planung,timer,fokus,notizen,erinnerung,kalender```### Appstore subtitle (30 characters max)```textAufgaben einfach erledigen```### Appstore promotional text (170 characters max)```textJetzt mit neuen Widgets für den Startbildschirm!Behalte deine Aufgaben immer im Blick.```### Google Play short description (80 characters max)```textEinfache Aufgabenverwaltung mit Timer und Erinnerungen.```### Full description (Google Play and AppStore)```textMeineApp hilft dir, den Überblick über deine Aufgaben zu behalten.FUNKTIONEN- Aufgaben mit Fälligkeitsdatum und Priorität- Integrierter Fokus-Timer- Erinnerungen und Benachrichtigungen- Widgets für den Startbildschirm- Synchronisierung zwischen Geräten```### What's New#### 2.4Neue Widgets für den Startbildschirm. Performance-Verbesserungen.#### 2.3Fehlerbehebungen und Stabilitätsverbesserungen.
Переводчик работает с одним файлом на язык. git diff на PR показывает ровно то, что изменилось. Перед релизом — дописываешь новую запись #### 2.5 вверх в What's New.
Скрипт: из Markdown в fastlane
Скрипт scripts/prepare_metadata.py читает все STORES-*.md, парсит секции и пишет .txt-файлы в нужную структуру. Запускается в CI перед загрузкой в магазины.
#!/usr/bin/env python3"""prepare_metadata.py — генерирует fastlane/metadata/ из stores/STORES-*.md.Использование: python scripts/prepare_metadata.py [--dry-run]Создаёт: fastlane/metadata/ios/{locale}/ name.txt, subtitle.txt, keywords.txt, promotional_text.txt, description.txt, release_notes.txt fastlane/metadata/android/{locale}/ title.txt, short_description.txt, full_description.txt, changelogs/default.txt"""import reimport sysfrom pathlib import PathROOT = Path(__file__).resolve().parent.parentSTORES_DIR = ROOT / "stores"IOS_META = ROOT / "fastlane" / "metadata" / "ios"AND_META = ROOT / "fastlane" / "metadata" / "android"DRY_RUN = "--dry-run" in sys.argv# Маппинг: ключ файла → (ios_locale | None, android_locale | None)# None означает, что платформа не поддерживает этот язык — пропускаем.# Ключ "" соответствует STORES.md (английский по умолчанию, en-US).LOCALE_MAP: dict[str, tuple[str | None, str | None]] = { "": ("en-US", "en-US"), "ar": ("ar-SA", "ar"), "bg": (None, "bg"), # App Store не поддерживает bg "cs": ("cs", "cs-CZ"), "da": ("da", "da-DK"), "de": ("de-DE", "de-DE"), "el": ("el", "el-GR"), "en-AU": ("en-AU", "en-AU"), "en-CA": ("en-CA", "en-CA"), "en-GB": ("en-GB", "en-GB"), "es-ES": ("es-ES", "es-ES"), "es-MX": ("es-MX", "es-419"), # Google Play: es-419 для Латинской Америки "fi": ("fi", "fi-FI"), "fr-CA": ("fr-CA", "fr-CA"), "fr-FR": ("fr-FR", "fr-FR"), "he": ("he", "iw-IL"), # Google Play использует устаревший код iw "hi": ("hi", "hi-IN"), "hr": ("hr", "hr"), "hu": ("hu", "hu-HU"), "id": ("id", "id"), "it": ("it", "it-IT"), "ja": ("ja", "ja-JP"), "ko": ("ko", "ko-KR"), "nl": ("nl-NL", "nl-NL"), "no": ("no", "no-NO"), # App Store: "no", Google Play: "no-NO" "pl": ("pl", "pl-PL"), "pt-BR": ("pt-BR", "pt-BR"), "pt-PT": ("pt-PT", "pt-PT"), "ro": ("ro", "ro"), "ru": ("ru", "ru-RU"), "sv": ("sv", "sv-SE"), "th": ("th", "th"), "tr": ("tr", "tr-TR"), "uk": ("uk", "uk"), "vi": ("vi", "vi"), "zh": ("zh-Hans", "zh-CN"), "zh-Hant": ("zh-Hant", "zh-TW"),}# Локали Google Play без отдельного STORES-файла.# Берём контент из ближайшего подходящего файла.ANDROID_FALLBACKS: dict[str, str] = { "en-IN": "", # English (India) → en-US "en-SG": "", # English (Singapore) → en-US "en-ZA": "", # English (South Africa) → en-US "es-US": "es-MX", # Spanish (United States) → es-MX "zh-HK": "zh-Hant", # Chinese (Hong Kong) → zh-Hant}def extract_section(text: str, heading: str) -> str: """Возвращает содержимое блока ```text ... ``` под заголовком heading.""" pattern = rf"### {re.escape(heading)}[^\n]*\n```text\n(.*?)```" m = re.search(pattern, text, re.DOTALL) return m.group(1).strip() if m else ""def extract_whatsnew(text: str) -> str: """Возвращает текст первой (новейшей) записи из секции ### What's New.""" section_m = re.search(r"### What's New\n(.*?)(?=\n### |\Z)", text, re.DOTALL) if not section_m: return "" section = section_m.group(1) # Разбиваем по заголовкам версий и берём первый блок parts = re.split(r"^#### .+$", section, flags=re.MULTILINE) return parts[1].strip() if len(parts) >= 2 else ""def parse_stores_file(path: Path) -> dict[str, str]: content = path.read_text(encoding="utf-8") return { "title": extract_section(content, "Title"), "keywords": extract_section(content, "Appstore keywords"), "subtitle": extract_section(content, "Appstore subtitle"), "promo": extract_section(content, "Appstore promotional text"), "short_desc": extract_section(content, "Google Play short description"), "description": extract_section(content, "Full description"), "whatsnew": extract_whatsnew(content), }def write_file(path: Path, content: str) -> None: if DRY_RUN: print(f" [dry] {path}") return path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content + "\n", encoding="utf-8")def write_ios(locale: str, data: dict[str, str]) -> None: base = IOS_META / locale fields = { "name.txt": data["title"], "subtitle.txt": data["subtitle"], "keywords.txt": data["keywords"], "promotional_text.txt": data["promo"], "description.txt": data["description"], "release_notes.txt": data["whatsnew"], } for filename, content in fields.items(): if content: write_file(base / filename, content)def write_android(locale: str, data: dict[str, str]) -> None: base = AND_META / locale fields = { "title.txt": data["title"], "short_description.txt": data["short_desc"], "full_description.txt": data["description"], } for filename, content in fields.items(): if content: write_file(base / filename, content) if data["whatsnew"]: write_file(base / "changelogs" / "default.txt", data["whatsnew"])def stores_key(path: Path) -> str: """STORES-fr-FR.md → 'fr-FR', STORES.md → ''""" stem = path.stem return "" if stem == "STORES" else stem[len("STORES-"):]def main() -> None: store_files = sorted(STORES_DIR.glob("STORES*.md")) parsed_cache: dict[str, dict[str, str]] = {} unmapped: list[str] = [] # Сначала парсим все файлы, чтобы en-US мог стать фолбэком для What's New for path in store_files: key = stores_key(path) if key not in LOCALE_MAP: unmapped.append(key) continue parsed_cache[key] = parse_stores_file(path) # Английский changelog из STORES.md — фолбэк, когда у локали нет своего # What's New. Можно вести changelog только на английском, и все языки возьмут # его; при желании позже добавляются per-locale секции What's New. default_whatsnew = parsed_cache.get("", {}).get("whatsnew", "") for key, data in parsed_cache.items(): ios_locale, android_locale = LOCALE_MAP[key] if not data["whatsnew"]: data["whatsnew"] = default_whatsnew label = key or "en-US (default)" parts = [] if ios_locale: write_ios(ios_locale, data) parts.append(f"iOS:{ios_locale}") if android_locale: write_android(android_locale, data) parts.append(f"Android:{android_locale}") print(f" {label:12s} → {', '.join(parts)}") # Фолбэки для Google Play (локали без отдельного STORES-файла) for android_locale, source_key in ANDROID_FALLBACKS.items(): source_path = STORES_DIR / (f"STORES-{source_key}.md" if source_key else "STORES.md") data = parsed_cache.get(source_key) or parse_stores_file(source_path) if not data["whatsnew"]: data["whatsnew"] = default_whatsnew write_android(android_locale, data) print(f" {android_locale:12s} → Android:{android_locale} (via {source_key or 'en-US'})") if unmapped: print(f"\nWarning: нет маппинга для: {', '.join(unmapped)}") print(" Добавьте записи в LOCALE_MAP.") if not DRY_RUN: print(f"\niOS → {IOS_META}") print(f"Android → {AND_META}")if __name__ == "__main__": main()
Пример вывода при запуске:
en-US (default) → iOS:en-US, Android:en-US de → iOS:de-DE, Android:de-DE fr-FR → iOS:fr-FR, Android:fr-FR he → iOS:he, Android:iw-IL es-MX → iOS:es-MX, Android:es-419 ... en-IN → Android:en-IN (via en-US) zh-HK → Android:zh-HK (via zh-Hant)
Флаг --dry-run выводит только пути файлов, ничего не пишет на диск — удобно прогнать локально перед коммитом, чтобы убедиться, что все секции распарсились и локали разложились по нужным папкам.
Учётные данные
Fastlane авторизуется через файлы с ключами, а не через переменные среды — это удобнее при работе с секретными файлами в Azure DevOps. Оба ключа хранятся в Secure Files (Pipelines → Library → Secure Files) и скачиваются в CI задачей DownloadSecureFile@1.
App Store Connect — API Key
Рекомендуемый способ авторизации: не требует Apple ID и пароля, не ломается от двухфакторки.
Где создать:
App Store Connect → Users and Access → Integrations → App Store Connect API → кнопка +

Заполнить форму:
-
Name — произвольное, например
CI Fastlane -
Access — роль App Manager

После создания нажмите Download API Key — скачается файл AuthKey_XXXXXXXXXX.p8.
Важно: файл можно скачать только один раз. Если потеряли — придётся создавать новый ключ.

Как передать fastlane:
Fastlane принимает ключ в виде JSON-файла со всеми тремя полями сразу. Создайте файл api_key.json вручную:
{ "key_id": "XXXXXXXXXX", "issuer_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "key": "-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----"}
-
key_id— Key ID со страницы App Store Connect API -
issuer_id— Issuer ID вверху той же страницы -
key— содержимое скачанного.p8-файла; переносы строк заменить на\n
Загрузите api_key.json в Azure DevOps: Pipelines → Library → Secure Files → Upload file.
В пайплайне файл скачивается задачей DownloadSecureFile@1 и путь к нему передаётся через --api_key_path:
- task: DownloadSecureFile@1 name: appStoreApiKey inputs: secureFile: 'api_key.json'- script: | bundle exec fastlane deliver \ --api_key_path $(appStoreApiKey.secureFilePath) \ ...
Google Play — Service Account
Авторизация в три шага: связать Play Console с Google Cloud, создать сервисный аккаунт и выдать ему права.
Шаг 1. Связать Play Console с Google Cloud
Google Play Console → Setup → API access
Если аккаунт ещё не привязан — страница предложит выбрать или создать Google Cloud проект.
📸 Скриншот: Google Play Console → Setup → API access
Показать: блок с привязанным Google Cloud проектом и кнопку Create new service account

Шаг 2. Создать сервисный аккаунт и скачать ключ
На той же странице — кнопка Create new service account. Откроется Google Cloud Console.
В Google Cloud Console: IAM & Admin → Service Accounts → Create Service Account
-
Name:
fastlane-ci(или любое понятное) -
Роли на уровне проекта на этом шаге не нужны
После создания: перейдите в аккаунт → вкладка Keys → Add Key → Create new key → тип JSON.

Скачается файл google_play_key.json — это стандартный ключ сервисного аккаунта Google Cloud.
Шаг 3. Выдать права в Play Console
Вернитесь в Google Play Console → Setup → API access. Найдите созданный аккаунт, нажмите Grant access.
-
App permissions → добавьте своё приложение
-
Роль: Release manager

Загрузите google_play_key.json в Azure DevOps Secure Files.

В пайплайне путь к файлу передаётся через --json_key:
- task: DownloadSecureFile@1 name: googlePlayKey inputs: secureFile: 'google_play_key.json'- script: | bundle exec fastlane supply \ --json_key $(googlePlayKey.secureFilePath) \ ...
Azure Pipelines
Соберём всё вместе. Пайплайн запускается на каждый merge в ветку release и создаёт новый релиз сразу в обоих магазинах: загружает свежие бинарники и локализованные метаданные за один проход.
Саму сборку iOS/Android в этой статье опускаю — она зависит от стека (Xcode, Gradle) и тянет на отдельный разговор. Вместо неё стоит шаг-заглушка, который в реальном пайплайне должен подготовить подписанные IPA и AAB и выставить переменную APP_VERSION. Дальше пайплайн:
-
Скачивает ключи из Secure Files
-
Устанавливает fastlane через Bundler (с кешем)
-
Запускает
prepare_metadata.py -
Загружает бинарники и метаданные в оба магазина
# azure-pipelines.ymltrigger: branches: include: - releasepool: vmImage: 'macos-15'variables: - group: AppSecretsstages: - stage: UpdateStoreMetadata displayName: 'Update store metadata' jobs: - job: UploadMetadata timeoutInMinutes: 30 steps: - task: UseRubyVersion@0 displayName: 'Use Ruby 4.0' inputs: versionSpec: '4.0' # Кеш Ruby-гемов между запусками — ускоряет CI примерно вдвое - task: Cache@2 displayName: 'Cache Ruby gems (Bundler)' inputs: key: 'bundler | "$(Agent.OS)" | Gemfile' restoreKeys: | bundler | "$(Agent.OS)" path: '$(Pipeline.Workspace)/.bundle-cache' - script: | gem install bundler --no-document export BUNDLE_PATH='$(Pipeline.Workspace)/.bundle-cache' bundle install --jobs 4 --retry 3 displayName: 'Install fastlane' # --- Здесь должна быть сборка бинарников --- # В реальном пайплайне этот шаг собирает и подписывает IPA и AAB # и выставляет APP_VERSION. Команды зависят от стека (Xcode, Gradle) # и в этой статье опущены. - script: | echo "TODO: собрать подписанный IPA → $(Build.ArtifactStagingDirectory)/app.ipa" echo "TODO: собрать подписанный AAB → $(Build.ArtifactStagingDirectory)/app.aab" echo "##vso[task.setvariable variable=APP_VERSION]2.4.0" displayName: 'Build binaries (placeholder)' # Ключ App Store Connect скачивается как файл - task: DownloadSecureFile@1 name: appStoreApiKey displayName: 'Download App Store API Key' inputs: secureFile: 'api_key.json' # Ключ Google Play скачивается как файл - task: DownloadSecureFile@1 name: googlePlayKey displayName: 'Download Google Play Service Account Key' inputs: secureFile: 'google_play_key.json' # Генерируем fastlane/metadata/ из STORES-*.md - script: | python3 scripts/prepare_metadata.py displayName: 'Prepare store metadata' # Создаём релиз в App Store Connect: бинарник + локализованные метаданные - script: | export BUNDLE_PATH='$(Pipeline.Workspace)/.bundle-cache' bundle exec fastlane deliver \ --api_key_path $(appStoreApiKey.secureFilePath) \ --app_identifier com.example.myapp \ --app_version $(APP_VERSION) \ --ipa $(Build.ArtifactStagingDirectory)/app.ipa \ --metadata_path fastlane/metadata/ios \ --skip_screenshots \ --run_precheck_before_submit false \ --force displayName: 'Upload iOS build + metadata to App Store Connect' # Создаём релиз в Google Play: бинарник + локализованные метаданные - script: | export BUNDLE_PATH='$(Pipeline.Workspace)/.bundle-cache' bundle exec fastlane supply \ --json_key $(googlePlayKey.secureFilePath) \ --package_name com.example.myapp \ --aab $(Build.ArtifactStagingDirectory)/app.aab \ --metadata_path fastlane/metadata/android \ --track internal \ --skip_upload_screenshots \ --skip_upload_images displayName: 'Upload Android build + metadata to Google Play'
Несколько деталей о структуре:
Gemfile вместо глобальной установки. Fastlane не устанавливается на агент заранее — он скачивается через Bundler при каждом запуске пайплайна. Gemfile фиксирует версию (~> 2.235), поэтому обновление fastlane — это осознанный коммит в репо, а не случайный сюрприз от агента.
Кеш Bundler. Без кеша bundle install занимает 2–3 минуты. С кешем — несколько секунд. Ключ кеша включает Gemfile, поэтому при смене зависимостей кеш инвалидируется автоматически. BUNDLE_PATH нужно экспортировать явно перед каждым bundle exec — задача Cache@2 восстанавливает файлы в папку, но переменную окружения не выставляет.
Secure Files вместо переменных среды. Оба ключа — полноценные JSON-файлы. Хранить их содержимое как строки в Variable Group неудобно: экранирование, переносы строк, риск ошибки. Secure Files решают это аккуратнее: файл загружается один раз через UI, скачивается задачей DownloadSecureFile@1, путь к нему доступен через $(taskName.secureFilePath).
--force в deliver. Без этого флага fastlane запрашивает интерактивное подтверждение перед загрузкой — в CI это вешает пайплайн.
Бинарник и метаданные за один вызов. И deliver (--ipa), и supply (--aab) умеют в одном проходе загрузить сборку и обновить тексты. Для iOS это ещё и важно технически: deliver обновляет метаданные у редактируемой версии приложения. Почти все поля App Store — name, subtitle, keywords, description, release notes («What’s New») — можно менять только когда есть версия в статусе черновика (исключение — promotional text и пара ссылок: их Apple разрешает править у живого приложения в любой момент). Передавая --ipa вместе с --app_version, мы как раз создаём такую версию-черновик, поэтому все локализованные тексты обновляются без ошибок. Если бы мы грузили только метаданные (--skip_binary_upload) и черновика версии не было, deliver упал бы на этих полях. У Google Play такого ограничения нет — метаданные там можно обновлять независимо от сборки, но раз мы всё равно публикуем релиз, удобно сделать это одним вызовом supply. При желании загрузку AAB можно вынести в отдельную задачу (например, GooglePlayRelease@4), оставив supply только для метаданных с флагами --skip_upload_aab --version_code $(VERSION_CODE).
--run_precheck_before_submit false в deliver. Отключает встроенные проверки контента от Apple перед отправкой — они медленные и нередко дают ложные срабатывания на не-английских текстах.
Версия образа агента. macos-15 зафиксирован намеренно — это гарантирует предсказуемую версию Xcode и инструментов. При настройке у себя берите актуальный на тот момент образ из документации Microsoft — старые со временем выводятся из эксплуатации.

Подводные камни
Лимиты символов. У каждого поля свой предел: App Store — name и subtitle по 30 символов, keywords — 100; Google Play — title 30, short description 80. Fastlane проверяет это на своей стороне и вернёт ошибку при превышении, так что битый релиз в магазин не уедет. Минус — ошибка всплывает уже во время загрузки, в середине прогона, и не всегда сразу видно, в какой именно локали проблема. Если локалей много, имеет смысл добавить быструю проверку длины прямо в prepare_metadata.py — она даст понятный отчёт ещё до запуска fastlane.
Коды локалей отличаются между магазинами. Это самая неочевидная часть. Примеры расхождений:
|
Язык |
App Store |
Google Play |
|---|---|---|
|
Иврит |
|
|
|
Испанский (Латинская Америка) |
|
|
|
Норвежский |
|
|
|
Словенский |
|
|
Маппинг в LOCALE_MAP закрывает все эти случаи — главное не забыть добавить новую локаль в оба места: STORES-файл и LOCALE_MAP.
Android-фолбэки. Google Play требует метаданные для ряда локалей (например en-IN, en-ZA), под которые нет смысла делать отдельный перевод. ANDROID_FALLBACKS покрывает это: берёт контент из ближайшего подходящего файла.
Пустой What's New — старые release notes в магазине. Если у локали нет своей секции ### What's New, скрипт подставит английский changelog из STORES.md (фолбэк). Но если забыть обновить What’s New в самом STORES.md, пустыми окажутся release notes уже во всех локалях — fastlane тогда не тронет текст и оставит предыдущий. Поэтому следите, чтобы свежая запись попала хотя бы в английский файл.
api_key.json создаётся вручную. Apple не отдаёт ключ в формате JSON — она даёт .p8-файл плюс два ID на странице. JSON для fastlane нужно собрать самостоятельно: взять key_id и issuer_id со страницы App Store Connect API, вставить содержимое .p8 в поле key (с \n вместо реальных переносов строк).
.p8-ключ скачивается один раз. Если потеряли файл — создавайте новый ключ и обновляйте Secure File. Рекомендую сразу сохранить api_key.json в защищённом хранилище (1Password, Azure Key Vault) — а не только в CI.
Итог
Итоговая схема:
Переводчик редактирует STORES-de.md ↓Merge в release ↓CI собирает бинарники и скачивает fastlane через Bundler (кеш) ↓prepare_metadata.py генерирует fastlane/metadata/ ↓fastlane deliver + supply → новый релиз со сборкой и метаданными в App Store Connect и Google Play
Что получили:
-
Единый источник истины — весь контент магазинов в читаемых Markdown-файлах под системой контроля версий
-
Экономия времени — обновление метаданных для 20+ языков сократилось с часа ручной работы до одного коммита и ~10 минут CI
-
Читаемые диффы — изменение одной строки в описании видно как одна строка в PR
Ссылки:
ссылка на оригинал статьи https://habr.com/ru/articles/1062986/