Автоматизация метаданных App Store и Google Play с fastlane и Azure Pipelines

от автора

Приложение поддерживает 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. Дальше пайплайн:

  1. Скачивает ключи из Secure Files

  2. Устанавливает fastlane через Bundler (с кешем)

  3. Запускает prepare_metadata.py

  4. Загружает бинарники и метаданные в оба магазина

# 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

Иврит

he

iw-IL (устаревший код)

Испанский (Латинская Америка)

es-MX

es-419

Норвежский

no

no-NO

Словенский

sl-SI

sl

Маппинг в LOCALE_MAP закрывает все эти случаи — главное не забыть добавить новую локаль в оба места: STORES-файл и LOCALE_MAP.

Android-фолбэки. Google Play требует метаданные для ряда локалей (например en-INen-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/