EAS Build: сборка в облаке без Xcode
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья собрала отладочный клиент — локально через expo run или в облаке по профилю development. Теперь сборки не для себя, а для людей: для тестировщиков и для магазинов. На iOS это упирается в Xcode, а значит, в Mac. EAS Build убирает это препятствие. Вы описываете профили в eas.json, а Gradle и xcodebuild запускаются на машинах Expo — и с любого компьютера получаются .aab для Google Play и .ipa для App Store. Разберём, что уезжает в облако, как профиль сворачивается в одну сборку, какой файл придёт по какому профилю, чего сервер не видит и как подписать сборку для iOS, ни разу не открыв Xcode.
Android собирается на любой машине, где стоит Android Studio. iOS — только в Xcode, а Xcode бывает только на Mac. Облачный сервис Expo EAS Build это ограничение снимает. В таблице из статьи «Что Expo делает за вас» это третий этаж: те же prebuild, Gradle и xcodebuild, что и у вас на компьютере, но на машинах Expo, и для iOS это Mac с Xcode. Своя машина нужна только для того, чтобы отправить задание.
Работы на вашей машине у eas build немного. Команда разрешает профиль из eas.json, вычисляет app.config.js с его переменными, договаривается о подписи и номере сборки, упаковывает проект и отправляет архив. Всё остальное делает сервер: разворачивает чистую машину, ставит зависимости, запускает prebuild, если в репозитории нет ios/ и android/, собирает, подписывает и выкладывает файл. Вот что напечатает сборка для Google Play в проекте автора — вывод собран по исходнику eas-cli:
Три первые строки говорят про окружение, в котором будет вычислен конфиг; о них — секция «Чего сервер не видит». Следующие две — номер сборки и ключ подписи; оба хранятся на серверах Expo, об этом последняя секция. Строка «Compressing project files» — архив: какие файлы в него попадают, решает не вы, а правило, о котором тоже будет речь. Дальше CLI только ждёт. Если нажать Ctrl+C, сборка не прервётся: она уже стоит в очереди на сервере, а её журнал по шагам доступен по ссылке «See logs».
| Шаг | Где выполняется | Разбор |
|---|---|---|
Разрешить профиль eas.json | ваша машина | секция «eas.json» |
Вычислить app.config.js с переменными профиля | ваша машина, а потом ещё раз на сервере | секция «Чего сервер не видит» |
| Номер сборки и подпись | ваша машина и серверы Expo; для iOS ещё и Apple | секция «Подпись и версия» |
| Архив проекта | ваша машина | секция «Чего сервер не видит» |
| Установка зависимостей, prebuild | сервер | статья «Что Expo делает за вас» |
| Gradle или xcodebuild, подпись, выкладка файла | сервер: Android — Linux, iOS — macOS | секция «Какой файл придёт» |
В архив попадает весь репозиторий, а не только папка apps/mobile. eas-cli находит корень git (git rev-parse --show-toplevel), упаковывает его, а на сервере работает в подпапке проекта. В монорепо автора из этого вытекает вот что. Пакет @jsp/contracts, из которого клиент берёт типы API, лежит в git исходниками, собранного кода там нет. Собирает его скрипт в package.json приложения. Имя у скрипта особое, eas-build-post-install — один из npm-хуков EAS Build. Сервер запускает его сам: на Android после установки зависимостей и prebuild, на iOS — ещё и после pod install. Так что к началу Gradle и xcodebuild пакет уже собран.
Сборка идёт на машине Expo, а не на вашей. Всё, что есть только у вас — файлы вне git, переменные вашего терминала, — туда не попадёт.
Как собирать, описывает eas.json рядом с app.config.js. У автора он такой, целиком:
Каждый ключ в build — профиль, его имя передают флагом --profile. Секция cli касается самого eas-cli, appVersionSource разберём в конце. Секция submit нужна для отправки в магазины — это статья «Магазины». Что значат поля профилей:
| Поле | Что решает | Где в части |
|---|---|---|
developmentClient | Debug-сборка со своим лаунчером и меню | статья «Где кончается готовый клиент» |
distribution | store — сборка для магазина, internal — раздача по ссылке | эта статья |
android.buildType | apk или app-bundle | секция «Какой файл придёт» |
ios.simulator | сборка для симулятора, без подписи | секция «Какой файл придёт» |
env | переменные окружения, с которыми вычисляется конфиг и собирается бандл | секция «Чего сервер не видит» |
autoIncrement | поднимать номер сборки при каждой сборке | секция «Подпись и версия» |
channel | канал EAS Update, из которого сборка берёт обновления | глава 11 |
extends | взять за основу другой профиль | ниже |
Перед сборкой eas-cli сворачивает профиль в один плоский объект для одной платформы. Этим занимается функция resolveBuildProfile из пакета @expo/eas-json. Правил у неё три. Сначала раскрывается extends. Потом секция нужной платформы накладывается поверх общей части, а секция другой платформы выбрасывается. Всё это ложится на значения по умолчанию из схемы файла. Ниже функция перенесена почти дословно; кусок eas.json тоже взят у автора, только env сокращён до одной переменной.
Пусть тестировщику нужна сборка preview на его iPhone, а preview у автора собирает iOS только для симулятора. Заводим профиль preview-device, который наследует preview и выключает симулятор. Прогноз: что получит iOS-сборка этого профиля? И что соберёт eas build --platform android, если забыть --profile?
preview-device получил от preview всё: канал, internal, выключенный dev client и env. Свою секцию ios он наложил на родительскую, после чего ключи android и ios исчезли: для iOS-сборки осталось одно плоское simulator: false. Эту же функцию мы прогнали на настоящем @expo/eas-json — вывод совпал ключ в ключ, вместе с порядком.
Вторая строка удивляет сильнее. Без --profile команда берёт production, так и написано в справке флага: «Defaults to "production" if defined in eas.json». distribution: "store" и credentialsSource: "remote" в файле автора не написаны нигде, их дописала схема eas.json. Значит, забытый флаг даёт сборку для магазина, и из-за autoIncrement она ещё и поднимает номер сборки.
И последнее о слиянии: оно неглубокое. Вглубь идут только env, android и ios. Любой другой объект наследник заменяет целиком: если переопределить cache.paths, пропадёт родительский cache.key. Цепочка extends ограничена пятью уровнями. Циклы отдельно не ищутся, их ловит тот же предел: профили, наследующие друг друга по кругу, дают «Too long chain of profile extensions, make sure "extends" keys do not make a cycle».
Профиль, где не написан distribution, собирается для магазина. Так решает схема eas.json, а не ваш файл.
На выходе у каждой платформы свой вопрос. На Android это формат пакета. APK ставится на телефон напрямую. AAB (Android App Bundle) — не установочный файл, а заготовка для Google Play: магазин сам нарезает из неё APK под каждое устройство. На iOS это подпись. Сборку для симулятора не подписывают вовсе, ad hoc встаёт только на заранее зарегистрированные iPhone, а подпись App Store принимают только TestFlight и сам магазин. Решает всё это профиль, и в eas-cli правила короткие:
Если buildType не задан и раздача не internal, CLI не шлёт серверу ничего, и тот собирает app-bundle. Это поведение сервера, в исходнике CLI его нет. Разрешение профиля из прошлого примера лежит в подложке. Прогноз: какие файлы придут по четырём профилям для каждой платформы?
Файлы для обоих магазинов даёт только production: .aab для Google Play и .ipa с подписью App Store. preview на Android ставится на любой телефон. На iOS тот же профиль собирает сборку только для симулятора — это ловушка из прошлой статьи, и она же объясняет, зачем нужен preview-device. Его .ipa подписана ad hoc и встанет только на iPhone, чей UDID зарегистрирован командой eas device:create до сборки. Сборка development — это Debug с лаунчером: наш dev client.
Как .apk, .app для симулятора и ad hoc .ipa попадают на устройство, разобрано в прошлой статье. Новые здесь две строки — файлы для магазинов, и вывод eas-cli для них устроен иначе. У сборки с distribution: "internal" printBuildInfo.js рисует QR-код и пишет «Open this link on your … devices (or scan the QR code) to install the app». У сборки для магазина QR-кода нет — только строка «🤖 Android app:» или «🍏 iOS app:» и ссылка на файл: ставить его на телефон не предполагается.
| Файл | Куда ставится | Как попадает к пользователю |
|---|---|---|
.aab | никуда напрямую | только через Google Play: загрузка в консоль или eas submit; APK под каждое устройство магазин нарежет сам |
.ipa, подпись App Store | TestFlight и App Store | только через App Store Connect: eas submit. Установить по ссылке, как ad hoc, нельзя — в профиле подписи нет списка устройств |
Сервер получает от вас две вещи: архив и окружение. Архив eas-cli собирает в два хода. Сначала неглубокий git clone без извлечения файлов. Потом поверх него копируется ваша рабочая папка — чтобы уехали и незакоммиченные правки, — но через фильтр: .git и node_modules не едут никогда, остальное отсекает .gitignore. Окружение сервера — это env профиля поверх переменных окружения EAS, которые хранятся у Expo и заводятся командой eas env:set. Если имя совпало, побеждает профиль: «The values from the build profile configuration will be used». Стандартных окружений у EAS три — development, preview и production (свои можно завести только на плане Enterprise), — и если в профиле нет поля environment, eas-cli выбирает его сам: distribution: "store" даёт production, dev client — development, всё остальное — preview. Это и есть первая строка вывода в начале статьи, «Resolved "production" environment». Окружение вашего терминала в задание сборки не кладётся вовсе: в prepareJob.js туда уходит только buildProfile.env.
Теперь вспомним app.config.js автора из статьи «Что Expo делает за вас». Firebase подключается только тогда, когда в окружении есть путь к файлу с ключами и сам файл на месте. Ключи в репозиторий не кладут, поэтому папка с ними стоит в .gitignore. На вашем Mac путь задан в терминале, и всё работает. В подложке — реестр и цепочка плагинов из прошлых статей, и плагин Firebase встаёт в тот же реестр со своей настоящей проверкой. Прогноз: что уедет в архиве и какие плагины применит prebuild на вашем Mac, на EAS и на EAS с конфигом, где проверки нет?
На EAS сборка прошла и Firebase не подключила: в окружении сервера нет пути, в архиве нет файла, и осторожный конфиг честно выбросил плагин. Ошибок нет, журнал зелёный, аналитики в приложении нет. Конфиг без проверки падает громко, уже на prebuild на сервере, и текст ошибки — дословно тот, что бросает @react-native-firebase/app. Осторожность автора спасает сборку, но расплачивается тишиной. Тот же вопрос стоит к каждому process.env в конфиге: у автора так же читаются EAS_PROJECT_ID и EAS_OWNER, и если они есть только в вашем терминале, в облаке конфиг вычислится без них.
Файлы с ключами передают переменной окружения EAS типа file. Файл хранится у Expo, а переменная содержит путь к нему — ровно то, чего ждёт конфиг автора. Так же этот тип понимает eas env:pull: такие файлы он кладёт локально в .eas/.env/.
Переменные с префиксом EXPO_PUBLIC_ Metro вшивает в бандл при сборке, как NEXT_PUBLIC_ в статье «Стили, сборка и выкладка» части IV. Поэтому EXPO_PUBLIC_API_URL: "https://example.com" в профилях автора — это адрес, который окажется в приложении у пользователей, пока его не заменят. А секретам в таких переменных не место: бандл лежит внутри .ipa и .apk, и достать его может любой.
Последняя ловушка архива — файл .easignore. Его заводят, чтобы архив похудел: CLI сам советует это сделать, когда архив больше 150 МБ, а больше 2 ГБ не примет вовсе. Но в vcs/local.js eas-cli правило записано прямым текстом: «if .easignore exists, .gitignore files are not used». Появился .easignore — и ни один .gitignore, ни корневой, ни во вложенных папках, больше не читается. На этом стоит первая задача к статье.
Сборку, которую ставят на телефон, нужно подписать. Раньше это требовало Keychain и Xcode, в EAS хватает ответов на вопросы в терминале. По умолчанию credentialsSource: "remote": ключи живут на серверах Expo, и CLI сообщает об этом строкой «Using remote Android credentials (Expo server)». При первой сборке для Android ключа ещё нет, и eas-cli спрашивает «Generate a new Android Keystore?». Согласие — и ключ создан и сохранён у Expo: «Created keystore». Для Google Play этот keystore — ключ загрузки: им подписан каждый .aab, и консоль принимает сборки только с ним. На телефоны Google Play отдаёт APK со своей подписью (Play App Signing), поэтому потерянный ключ загрузки заменить можно, но только запросом на сброс в Play Console. А там, где файл отдают как есть, — в RuStore и при установке .apk напрямую, — keystore и есть подпись приложения, и обновление с другой подписью поверх не встанет; к этому вернётся статья «Магазины». Поэтому копию стоит скачать командой eas credentials и хранить отдельно от репозитория.
С iOS шагов больше, но Mac не нужен и здесь. Нужен платный аккаунт в Apple Developer Program: без него не выпустить ни ad hoc, ни App Store. eas-cli сам входит в аккаунт Apple и через API Apple создаёт сертификат распространения и профиль подписи. Фрагмент первой сборки:
Следующие сборки берут готовое. Сборкам для симулятора ничего из этого не нужно: проверка из секции «Какой файл придёт» пропускает подпись, если стоит simulator. Поэтому профили development и preview автора собираются для iOS и без аккаунта Apple.
Вторая забота — номер. У приложения их два. version из конфига (1.0.0) видит пользователь; на iOS это CFBundleShortVersionString, на Android — versionName. Номер сборки — versionCode на Android и CFBundleVersion на iOS — видят магазины, и каждая загрузка обязана нести новый. Где хранится номер сборки, решает cli.appVersionSource. У автора стоит "remote": номер лежит на серверах EAS и подставляется в проект только во время сборки. Так в объяснении и сказано: «remotely on EAS servers and only applied to the project at build time». Вместе с autoIncrement в профиле production каждая сборка поднимает его на единицу — строка «Incremented versionCode from 1 to 2» в самом первом выводе статьи. Без appVersionSource CLI предупреждает, что поле скоро станет обязательным, а начальный номер задаёт eas build:version:set.
Итог: eas build --platform all --profile production с любой машины — хоть с Windows, хоть с Linux — ставит в очередь две сборки. На выходе .aab для Google Play и .ipa для App Store, подписанные ключами, которые хранит EAS, и с номерами, которые ведёт EAS. Прежде чем отправлять их в магазины, нужно довести до ума то, что пользователь увидит первым: иконку, сплэш и имя. Этому посвящена следующая статья.
Скачайте копию keystore командой eas credentials и храните её вне репозитория: для Google Play это ключ загрузки, а для RuStore и .apk напрямую — подпись самого приложения.
- EAS Build — облачный этаж Expo. На вашей машине
eas buildразрешает профиль, вычисляет конфиг, договаривается о подписи и номере сборки и отправляет архив. Установка зависимостей, prebuild, Gradle и xcodebuild идут на машинах Expo, для iOS — на Mac, поэтому свой не нужен. - Профиль — это секция
eas.jsonс раскрытымextendsи наложенной секцией платформы, поверх значений по умолчаниюdistribution: "store"иcredentialsSource: "remote". Вглубь сливаются толькоenv,androidиios. Без--profileсобираетсяproduction. - Какой файл придёт, решает профиль.
internalна Android даёт.apk, иначе.aab, который принимает только Google Play.ios.simulatorдаёт.appбез подписи,internalна iOS — ad hoc.ipaдля зарегистрированных iPhone,store—.ipaдля TestFlight и App Store. - Сервер видит архив корня git без того, что отсекает
.gitignore(а если есть.easignore— только по его правилам), иenvпрофиля поверх переменных окружения EAS. Переменные вашего терминала туда не едут, файлы с ключами передают переменной типаfile, аEXPO_PUBLIC_*вшиваются в бандл. - Keystore Android и сертификаты iOS EAS создаёт и хранит сам — для iOS нужен лишь аккаунт Apple Developer.
appVersionSource: "remote"иautoIncrementведут номер сборки на серверах EAS. Итог —.aabи.ipaдля обоих магазинов с любой машины.
1/4В проекте автора запустили eas build --platform android, забыв --profile. Что соберётся?
2/4Профиль b наследует a и задаёт ios: { simulator: false } и cache: { paths: ["y"] }. У a — ios: { simulator: true, buildConfiguration: "Release" } и cache: { key: "a", paths: ["x"] }. Что получит iOS-сборка b?
3/4Чтобы архив для EAS стал меньше, в корень репозитория добавили .easignore с одной строкой assets-raw/. Что изменилось?
4/4Можно ли без Mac получить .ipa для App Store?
.easignore, который выложил ключи
Архив проекта разросся из-за видео в assets-raw/: сборке оно не нужно, оно для промостраницы. Коллега завёл .easignore с одной строкой — и архив похудел на видео, но вырос на всём остальном. Функция upload в подложке повторяет правило eas-cli: если .easignore есть, .gitignore не читается. Поправьте список правил так, чтобы в архиве остались только исходники.
.easignore не дополняет .gitignore, а заменяет его, поэтому обязан повторить всё, что прятал тот, и добавить своё:
Теперь печатается ["app.config.js", "src/app.tsx"]. В настоящем проекте это значит: скопировать .gitignore в .easignore и дописать строки. Если в монорепо .gitignore есть и во вложенных папках, apps/mobile/.gitignore в том числе, их правила тоже переносят в корневой .easignore, путями от корня — вложенные файлы при нём не читаются. Проверить, что уезжает, можно, не отправляя сборку: eas build:inspect --platform android --stage archive --output ./archive-check кладёт в папку ровно то, что ушло бы на сервер.
Пять просьб — пять сборок
К вам пришли с пятью просьбами. Для каждой назовите профиль eas.json автора (или preview-device из статьи), файл, который придёт, и то, как он попадёт к адресату.
| № | Просьба |
|---|---|
| 1 | Тестировщик хочет поставить свежую сборку на свой телефон на Android |
| 2 | Выложить первую версию в Google Play |
| 3 | Дизайнер хочет посмотреть приложение на своём iPhone до релиза |
| 4 | Проверить релизную сборку на симуляторе iOS перед отправкой |
| 5 | Отправить сборку внешним тестировщикам через TestFlight |
1 — preview: internal и buildType: "apk" дают .apk, тестировщик ставит его по ссылке или QR-коду из вывода eas-cli.
2 — production: .aab. На телефон его не поставить, только загрузить в Google Play — вручную или через eas submit, об этом статья «Магазины».
3 — preview-device, но сначала eas device:create на телефоне дизайнера: ad hoc .ipa встаёт только на iPhone, чей UDID был в профиле подписи на момент сборки. preview автора здесь не подойдёт — он собирает iOS для симулятора. Другой путь — сборка production через TestFlight, как в пятом пункте.
4 — preview: для iOS это .app для симулятора, без подписи и без аккаунта Apple; eas build:run скачает архив и поставит. Сборка development для этого не годится: она Debug, а проверяют релизную.
5 — production: .ipa с подписью App Store. TestFlight — часть App Store Connect, туда отправляют ту же сборку, что пойдёт в магазин.
Одно наблюдение по итогам: четыре профиля покрывают пять просьб, и preview-device появился ради одной — iPhone без TestFlight. Если такая просьба приходит редко, дешевле обходиться TestFlight и не регистрировать устройства.