CI собирает и тестирует каждое изменение; continuous delivery держит каждую зелёную сборку готовой к релизу за одним ручным шагом; continuous deployment выкатывает её пользователям сама. В мобильной разработке на пути стоят ревью стора и обновления по воле пользователя.
CI: каждый PR и каждый мерж прогоняет линтер, юнит-тесты и сборку, поэтому main не остаётся сломанным надолго
Delivery: пайплайн подписывает сборку и выкладывает её в тестовый трек (Play internal testing, TestFlight); когда она попадёт в продакшен, решает человек
Почему не deployment: ревью занимает от часов до дней, пользователи обновляются когда хотят, а выпущенный бинарь не отозвать, поэтому каждый релиз живёт на устройствах месяцами
Что вместо этого: автоматическая доставка тестировщикам каждой сборки main и быстрые изменения на сервере через фиче-флаги и remote config
AndroidSenior
CI/CD мобилки
Fastlane, GitHub Actions, подпись, TestFlight, Play Internal, build flavors
Закрыто вопросов
0 / 19
- 0Повторить
Вопросы
Мобильный пайплайн на самом деле состоит из двух пайплайнов: быстрого, который обязан успеть, пока ревьюер смотрит PR, и медленного, которому можно и час.
Быстрый контур, на каждый PR: линтеры и статический анализ, потом юнит-тесты; без эмулятора, без подписи, без загрузок
Медленный контур, на main или ночью: UI-тесты на эмуляторе, подписанная релизная сборка, загрузка в TestFlight или Play internal testing, символы, staged rollout
Держите PR-сборку до пятнадцати минут: дальше её результаты просто перестают читать
Кешируйте и живите на Linux: сборка без кеша это в основном скачивание, а минута macOS стоит в разы дороже
Ключи и сертификаты подписи не лежат в репозитории: они приходят из зашифрованных секретов и никогда не печатаются в лог
Fastlane представляет собой Ruby-тулкит, превращающий релизный ритуал (собрать, подписать, залить символы, отправить в тест-трек) в именованные lanes, которые одинаково запускаются на ноутбуке и в CI.
Сборка и подпись: build_app на iOS, экшен gradle на Android
**match: синхронизирует iOS-сертификаты и provisioning-профили через приватный зашифрованный git-репозиторий, чтобы у всех машин и у раннера был один набор
Аутентификация в сторе: ключ App Store Connect API и сервисный аккаунт Play, а не личный логин (см. вопрос про авторизацию пайплайна)
Дистрибуция**: upload_to_testflight, upload_to_play_store, firebase_app_distribution, вместе с процентами staged rollout
Символы: upload_symbols_to_crashlytics, чтобы релизные краши были читаемыми
Раннер почти всегда прав: чего-то, что нужно сборке, нет в репозитории, либо оно называется иначе, когда файловая система перестаёт игнорировать регистр.
Неотслеживаемые файлы, которые читает сборка: google-services.json, xcconfig с ключами, .env; пайплайн обязан писать их из секретов и громко падать, когда их нет
Слишком широкое правило игнора: *.jar съедает Gradle wrapper; виноватую строку покажет git check-ignore -v
Регистр: macOS его игнорирует, а Linux-раннер нет, поэтому импорт с другой буквой собирается дома и падает в CI
Лок-файлы и бит исполняемости: коммитьте Podfile.lock и Gemfile.lock и ставьте +x на gradlew через git
Воспроизвести за тридцать секунд: склонировать во временную папку и собрать там
Вы подписываете AAB своим upload-ключом; с Play App Signing ключ подписи приложения хранит Google и переподписывает им APK, которые раздаёт, поэтому ключ, который проверяют устройства, никогда не лежит ни на ноутбуке, ни на CI-раннере.
Два ключа: upload key доказывает, что загрузка от вас; app signing key служит личностью приложения на каждом устройстве, и каждое обновление обязано быть подписано им
Почему это важно: потерянный или утёкший upload key можно сбросить в Play Console; потерянный app signing key без Play App Signing означает, что приложение больше никогда не обновить
Обязателен для AAB: новые приложения с августа 2021 года обязаны выходить в AAB, а AAB работает только с Play App Signing, потому что APK под конкретное устройство собирает и подписывает Google
На CI: upload keystore хранится как base64-секрет, декодируется на сборке и удаляется после; пароли приходят из переменных окружения, а не из закоммиченного gradle.properties
Схемы подписи APK: для напрямую распространяемого APK apksigner пишет схемы с v1 по v4, zipalign должен идти до подписи, а любая правка после ломает подпись; AAB подписывается только upload-ключом
Каждый стор получает машинные учётные данные, принадлежащие команде, а не человеку: сервисный аккаунт Google Cloud, приглашённый в Play Console, и командный API-ключ App Store Connect. И то и другое хранится в секретах CI и больше нигде.
Play: создайте сервисный аккаунт, включите Google Play Android Developer API, пригласите его email в разделе Users and permissions и выдайте только то, что делает пайплайн: выкладку в тестовые треки, а в продакшен только в задаче за защищённым окружением, без финансовых данных
App Store Connect: командный API-ключ (файл .p8, key id и issuer id) с самой узкой ролью, которой хватает на загрузку сборок и управление TestFlight; приватный ключ скачивается ровно один раз
Почему не личный логин: на запрос двухфакторки Apple ID на раннере ответить некому, а пайплайн ломается в день, когда этот человек уходит
Хранение: JSON сервисного аккаунта и .p8 лежат в секретах защищённого окружения; fastlane читает их через json_key_data и key_content, так что на диск ничего не пишется
Ротация: это долгоживущие учётные данные без второго фактора; ротируйте их по расписанию и каждый раз, когда уходит человек с доступом
Play переподписывает вашу загрузку app signing key, поэтому отпечаток сертификата на устройстве пользователя принадлежит Google, а не вашему upload key, и каждая интеграция, завязанная на отпечаток, должна знать оба.
Регистрируйте отпечатки обоих ключей: OAuth-клиенту и ограниченным API-ключам нужен SHA-1, assetlinks.json нужен SHA-256, Firebase принимает оба; возьмите их из сертификата app signing key в Play Console и добавьте рядом с upload и debug
App Links ломаются тихо: неверный отпечаток в assetlinks.json не даёт ошибки, ссылка просто открывается в браузере
Установленное приложение тоже не совпадает с вашим APK: Play режет AAB по ABI, плотности и языку, поэтому установка одного базового APK падает на старте
Тестируйте то, что реально получают пользователи: internal app sharing отдаёт по ссылке настоящий набор сплитов, подписанный Play, без правил version code и без ревью
Локально: bundletool build-apks --connected-device соберёт ровно те сплиты, что получило бы устройство
Раннер получает сертификаты и provisioning-профили команды в режиме readonly через fastlane match и импортирует их во временный разблокированный keychain; подпись проходит, только если сертификат, профиль, entitlements и bundle id совпадают.
Как это попадает на CI: fastlane match держит сертификаты и профили в приватном зашифрованном репозитории, а раннер ставит их в режиме readonly и потому не может перевыпустить и тем самым отозвать сертификаты всей команды
Как запускать без экрана (headless): импортируйте во временный keychain, который разблокирован, не блокируется по таймауту и имеет выставленный key partition list; без partition list codesign падает с «User interaction is not allowed». setup_ci создаёт и разблокирует временный keychain, а match выставляет partition list при импорте
Аутентифицируйтесь ключом App Store Connect API: двухфакторка делает пароль Apple ID непригодным для автоматики
Entitlements обязаны точно совпасть с профилем: перевыпуск профиля может молча потерять push или App Groups, а несовпадение вылезет на загрузке, после самой долгой части
**Не включайте -allowProvisioningUpdates на CI: он позволяет сборочной машине менять профили команды; состояние подписи должно рождаться и умирать вместе с задачей
Истёкший сертификат ломает новые сборки, а не установленные из App Store приложения**: поэтому всплывает всегда в дедлайн
На Android это productFlavors, на iOS это build configurations на xcconfig, которые выбирают schemes, а --flavor во Flutter управляет обоими; каждый добавленный flavor умножает то, что CI должен собрать, подписать и протестировать.
Android: productFlavors в сочетании с buildTypes дают варианты вроде stagingDebug; applicationIdSuffix позволяет ставить их рядом, а src/<flavor>/ перекрывает ресурсы и google-services.json
iOS: build configurations на xcconfig-файлах, которые выбирают schemes; отдельный bundle id под окружение означает и отдельный provisioning profile
Flutter: --flavor ложится на оба нативных механизма, а --dart-define-from-file передаёт в Dart константы окружения
Цена для CI: PR-контур собирает один вариант (например, devDebug), релизный собирает prodRelease; сборка всех вариантов на каждый PR и удваивает пайплайн, хотя никто этого не решал
Частая ошибка: flavors для того, что должно быть рантайм-флагом; flavor нужен для другой личности приложения, а не для переключателя фичи
Номер сборки представляет собой состояние пайплайна, а не исходник, который правят руками: CI вычисляет значение, которое только растёт.
Откуда его брать: спросить у стора последний и прибавить единицу (latest_testflight_build_number, google_play_track_version_codes) либо взять счётчик, который не умеет идти назад, например номер запуска CI
Номер сгорает навсегда: удаление сборки в TestFlight или черновика в Play не освобождает значение
Частая ошибка: двое поднимают номер руками в двух ветках; конфликт вылезет только на загрузке, после долгой части
Почти любое необъяснимое падение CI приходит из того, что никто не закрепил: образ раннера, тулчейн или зависимость, разрешившаяся в версию, которой на прошлой неделе не было.
Образы раннеров двигаются: метка latest меняет Xcode по умолчанию либо JDK и NDK; закрепите метку и явно выбирайте тулчейн внутри неё
Xcode не сводится к компилятору: он задаёт версию Swift, набор рантаймов симулятора и то, что стор примет при загрузке
Java, Gradle, AGP и Kotlin образуют матрицу совместимости: коммитьте wrapper, объявляйте Java toolchain, чтобы решал не дефолтный JDK раннера
Ruby и fastlane: коммитьте lock-файл и запускайте через bundle exec; голый fastlane разрешится в то, что случайно едет в образе
Плавающие версии: динамическая версия в Gradle или Swift-пакет, следящий за веткой, превращают чужой релиз в вашу аварию
Опережайте обновления: пусть бот поднимает обновления пул-реквестами, и держите ночной canary на latest, который падает не в чей-то дедлайн
Загрузите dSYM, mapping от R8 и нативные debug-символы именно для этой сборки из задачи, которая собрала бинарь, до выхода релиза; пересборка их не воссоздаёт.
Что сохранять: dSYM на iOS, mapping от R8 и нативные debug-символы на Android, вывод split-debug-info для обфусцированной сборки Flutter
Загружайте в той же задаче, что собрала бинарь: вывод компилятора не одинаков между машинами, поэтому mapping для сборки 4213 существует ровно один
iOS: с тех пор как Xcode 14 убрал bitcode, App Store Connect больше не перекомпилирует ваш бинарь, поэтому загружать нужно dSYM из вашего собственного архива; download_dsyms нужен только для старых сборок с bitcode
Android: mapping нужен и в загрузке в Play, и у краш-вендора: этим файлом они не обмениваются
Частая ошибка: версия или номер сборки не совпадают с теми, к которым привязаны символы: файл на месте, стек всё равно шестнадцатеричный
Храните символы старых версий: старые версии тоже падают, и именно эти краши вы чините
Сделайте так, чтобы красная сборка надёжно означала сломанное приложение: убирайте причину каждого флака, перезапускайте один тест, а не задачу, и отправляйте повторных нарушителей в карантин с владельцем.
Где гонять: эмуляторы Android на Linux-раннерах с ускорением дешевле всего, а Gradle Managed Devices делают устройство объявленным входом сборки; симуляторы iOS идут вместе с macOS-раннером
Реальные устройства гоняйте ночью, а не на каждый PR: они окупаются на оболочках вендоров, биометрии и устройствах с малой памятью
Сначала уберите причину флака, потом ретраи: test orchestrator с очисткой данных между тестами, выключенные анимации, замоканная сеть, шардирование по раннерам
Перезапускайте тест, но не всю задачу: ретрай задачи прячет флак, удваивает счёт и приучает всех жать rerun
Карантин с владельцем и задачей: и считайте частоту флаков; набор, чьи красные сборки привычно игнорируют, хуже отсутствия набора
Сохраняйте улики: видео, лог устройства и скриншоты падения как артефакты
Пересборка даёт новый бинарник: другой образ раннера, иначе отрезолвившаяся зависимость, новый номер сборки. В стор уезжает не то, что кто-либо тестировал.
Компилируйте один раз, в одной задаче, и публикуйте .ipa или .aab, файлы символов и список отрезолвленных версий как один неизменяемый артефакт с номером сборки в имени. Дальше стадии двигают этот артефакт, ничего не пересобирая.
iOS: сборка в TestFlight и есть кандидат для App Store; отправляйте на ревью её номер, а не новый бинарник
Android: продвигайте тот же AAB между треками по version code; Play API принимает промоушен трека без артефакта
Конфигурация вкомпилирована: staging-бинарник в прод не продвинуть, поэтому кандидат сразу собирается с продовой конфигурацией
Частая ошибка: деплойная задача, которая снова зовёт build; прячется потому, что обе сборки обычно работают
Храните артефакты дольше релизного цикла: при обычных 90 днях по умолчанию хотфиксу для старого релиза уже нечего продвигать
Trunk-based разработка с недоделанным за флагами, релизная ветка, которую режут по расписанию поезда и в которую едут только cherry-pick фиксов, и сборки для стора из тегов.
Trunk-based: короткие ветки в main, main всегда готов к релизу, недоделанное за флагом; трёхнедельная фиче-ветка это конфликт слияния с прикрученным дедлайном
Релизную ветку режьте по расписанию поезда: дальше в неё едут только cherry-pick фиксов, main продолжает двигаться, а хотфикс вливайте обратно сразу, иначе следующим поездом уедет тот же баг
Артефакт для стора собирайте из тега: именно от него через три недели отпочкуется хотфикс
Настоящий откат делается фиче-флагом: установленную сборку не отозвать
Отменяйте перекрытые прогоны, пропускайте задачи, которых изменение не касается, собирайте debug на PR, кешируйте по lock-файлу, шардируйте тесты и переносите на Linux всё, что не требует Xcode; build scan в Gradle или сводка таймингов Xcode покажут, что окупится раньше.
Перестаньте делать лишнюю работу: группа concurrency по ветке с cancel-in-progress убивает прогон, перекрытый новым пушем, а фильтры по путям не дают правке README собирать два приложения
Debug-сборки на PR, релизные на main: покрытие, скриншоты и полная матрица устройств живут в ночном контуре
Кешируйте осознанно: ключ от хеша lock-файла с запасным префиксом, кеш Gradle home, SwiftPM и CocoaPods, включённые configuration cache и build cache; DerivedData целиком не кешируйте
Делите, а не удлиняйте: десять минут тестов на шести раннерах превращаются в задачу на три-четыре минуты с учётом подготовки
Тратьте деньги там, где это работает: всё, чему не нужен Xcode, уходит на Linux-раннер, а Gradle Managed Devices с ATD-образами делают загрузку эмулятора быстрее и одинаковой на каждом прогоне
Следите за трендом: публикуйте длительность задач: пайплайн, гниющий по десять секунд в неделю, именно так и доезжает до сорока минут
Секреты должны быть недостижимы из любого кода, который может изменить посторонний: PR из форков идут без секретов, а релизные задачи стоят за подтверждением человека.
**Дыра кроется в pull_request_target: он выполняется в контексте базовой ветки с секретами и правами на запись, поэтому чекаут head пул-реквеста и его сборка отдают атакующему всё; чекайте только базу либо разделяйте недоверенную сборку и доверенную публикацию
Скрипт сборки является кодом: PR, правящий gradle-файл, Gemfile, Podfile или fastlane-экшен, выполняется на вашем раннере до того, как кто-то прочитал диф
Сторонние actions закрепляйте на commit SHA: тег могут переставить у вас за спиной, именно так и работали несколько supply-chain-инцидентов
Минимум прав**: явные permissions токену, настоящие секреты в защищённом environment с обязательными ревьюерами, fastlane match в режиме readonly
Только одноразовые раннеры: постоянный self-hosted хранит всё, что оставила прошлая задача, включая отравленный кеш
Маскирование в логах не защита: base64 её обходит, поэтому секрет, попавший в лог, считайте сгоревшим
На iOS можно отозвать и выпустить заново всё, потому что установленное из App Store переподписывает Apple. На Android, если app signing key у Google, утёк лишь upload key, и его сбрасывают; если нет, ключ и есть личность приложения, и полностью заменить его нельзя.
iOS: отозвать и выпустить заново: приложения из App Store переподписаны Apple, поэтому установленные копии не затронуты; ломаются только ваши сборки, пока профили не перевыпущены, а fastlane match nuke distribution отзывает их, а следующий запуск match appstore без readonly выпускает новые
Исключение на iOS: ad hoc и корпоративные in-house сборки проверяются против вашего сертификата, после отзыва они перестают ставиться
Ротируйте и ключ App Store Connect API: .p8 это полноценные учётные данные, за которыми нет пароля
Android с Play App Signing: утёк только upload key: запросите его сброс, и пользователи ничего не заметят, ключ Google никогда не покидал Google
Android без него: утёкший ключ и есть личность приложения: кто угодно подпишет обновление, которое установленные копии примут при установке вручную. Подключение Play App Signing его не выводит из строя, потому что тот же ключ вы отдаёте Google. Лекарством служит обновление ключа подписи через Play (новый ключ для новых установок, подтверждение ротации v3 для уже установленных копий на Android 9+); старый ключ остаётся враждебным для старых устройств
Любая смена ключа на Android: заново регистрировать все интеграции по отпечатку: OAuth-клиенты, ограниченные API-ключи, App Links
Разделите выпуск и запуск: доставьте оба бинарника заранее с выключенной фичей, а включите её с сервера, когда обе версии реально разойдутся.
Даты в сторах не могут быть механизмом: скорость ревью вам не подконтрольна, и даже после одобрения phased release и staged rollout означают, что во вторник новый бинарник есть не у всех
Придержите одобренные сборки: ручной релиз в App Store Connect и managed publishing в Play держат одобренный релиз, пока вы его не опубликуете
Закройте фичу удалённым флагом: выключенным по умолчанию и доставленным в обоих бинарниках на релиз-два раньше; он же и есть откат, ведь сборку не отозвать
Бэкенд должен обслуживать предыдущую версию: поведение решает флаг, а API не может рассчитывать на версию клиента
Частая ошибка: флаг без безопасного значения по умолчанию: кешируйте последнее известное и падайте в «выключено», иначе сбой конфига станет сбоем фичи
Expedited review служит аварийным рычагом: потратив его на маркетинговую дату, вы останетесь без него в инциденте