EAS Build: сборка в облаке без Xcode

сборка≈ 14 минут

Здесь будет встроен видео-разбор темы (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:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника eas-cli 24.8.0 в порядке вызова, а не запись сеанса; проект без переменных в окружении EAS, адреса, номера и время — для примера
Resolved "production" environment for the build. Learn more: https://docs.expo.dev/eas/environment-variables/#setting-the-environment-for-your-builds
No environment variables with visibility "Plain text" and "Sensitive" found for the "production" environment on EAS.
Environment variables loaded from the "production" build profile "env" configuration: EXPO_PUBLIC_API_URL, EXPO_PUBLIC_YANDEX_METRIKA_ID, EXPO_PUBLIC_APPMETRICA_KEY.
✔ Incremented versionCode from 1 to 2.
✔ Using remote Android credentials (Expo server)
Compressing project files and uploading to EAS Build. Learn more: https://expo.fyi/eas-build-archive
✔ Uploaded to EAS 4s
See logs: https://expo.dev/accounts/…/projects/stack-starter/builds/…
Waiting for build to complete. You can press Ctrl+C to exit.
✔ Build finished
🤖 Android app:
https://expo.dev/artifacts/eas/….aab

Три первые строки говорят про окружение, в котором будет вычислен конфиг; о них — секция «Чего сервер не видит». Следующие две — номер сборки и ключ подписи; оба хранятся на серверах 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 пакет уже собран.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Сборка идёт на машине Expo, а не на вашей. Всё, что есть только у вас — файлы вне git, переменные вашего терминала, — туда не попадёт.

Как собирать, описывает eas.json рядом с app.config.js. У автора он такой, целиком:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Каждый ключ в build — профиль, его имя передают флагом --profile. Секция cli касается самого eas-cli, appVersionSource разберём в конце. Секция submit нужна для отправки в магазины — это статья «Магазины». Что значат поля профилей:

ПолеЧто решаетГде в части
developmentClientDebug-сборка со своим лаунчером и менюстатья «Где кончается готовый клиент»
distributionstore — сборка для магазина, internal — раздача по ссылкеэта статья
android.buildTypeapk или 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?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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 правила короткие:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если buildType не задан и раздача не internal, CLI не шлёт серверу ничего, и тот собирает app-bundle. Это поведение сервера, в исходнике CLI его нет. Разрешение профиля из прошлого примера лежит в подложке. Прогноз: какие файлы придут по четырём профилям для каждой платформы?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Файлы для обоих магазинов даёт только 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 StoreTestFlight и 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 с конфигом, где проверки нет?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На EAS сборка прошла и Firebase не подключила: в окружении сервера нет пути, в архиве нет файла, и осторожный конфиг честно выбросил плагин. Ошибок нет, журнал зелёный, аналитики в приложении нет. Конфиг без проверки падает громко, уже на prebuild на сервере, и текст ошибки — дословно тот, что бросает @react-native-firebase/app. Осторожность автора спасает сборку, но расплачивается тишиной. Тот же вопрос стоит к каждому process.env в конфиге: у автора так же читаются EAS_PROJECT_ID и EAS_OWNER, и если они есть только в вашем терминале, в облаке конфиг вычислится без них.

Файлы с ключами передают переменной окружения EAS типа file. Файл хранится у Expo, а переменная содержит путь к нему — ровно то, чего ждёт конфиг автора. Так же этот тип понимает eas env:pull: такие файлы он кладёт локально в .eas/.env/.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Переменные с префиксом 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 создаёт сертификат распространения и профиль подписи. Фрагмент первой сборки:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника eas-cli 24.8.0 (build/build/utils/credentials.js, credentials/context.js, resolveCredentials.js, CreateDistributionCertificate.js, CreateProvisioningProfile.js), а не запись сеанса; ввод Apple ID, двухфакторный код и выбор команды пропущены
✔ Using remote iOS credentials (Expo server)
? Do you want to log in to your Apple account? › yes
› Log in to your Apple Developer account to continue
? Apple ID: › …
…
✔ Created distribution certificate
…
✔ Created provisioning profile

Следующие сборки берут готовое. Сборкам для симулятора ничего из этого не нужно: проверка из секции «Какой файл придёт» пропускает подпись, если стоит 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 не читается. Поправьте список правил так, чтобы в архиве остались только исходники.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

.easignore не дополняет .gitignore, а заменяет его, поэтому обязан повторить всё, что прятал тот, и добавить своё:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь печатается ["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 и не регистрировать устройства.