EAS Update: каналы, ветки и вес обновления
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Три статьи главы разобрали ответ сервера, замок версий и отпечаток. Осталось само действие — выкатить. У EAS Update для этого два имени, и путают их чаще всего: ветка — очередь публикаций, канал вшит в сборку и всего лишь указывает на ветку. Добавим выбор по каналу к серверу-миниатюре, посчитаем, сколько байт на самом деле уедет по сети (числа — из настоящего экспорта, снятого в приложении автора), раскатаем обновление на часть устройств и попробуем откатиться. Последнее удивит: одну и ту же старую группу один телефон скачает, а другой пропустит. Сверено с eas-cli 22.2.0 и исходниками expo-updates 57.
Ветка — это очередь публикаций: eas update --branch release-1.0 кладёт новую группу обновлений в её конец, и в ветке их накапливаются десятки. Канал — имя, под которым сборка приходит за обновлениями: production, preview, development. Связывает их правило канала, и это просто указатель: канал смотрит в одну ветку, а какую — решается на сервере.
Канал попадает в приложение на сборке. В профиле eas.json у автора написано "channel": "production" — мы разбирали этот файл в главе про EAS Build. Дальше канал ложится в нативный конфиг, в набор requestHeaders под ключом expo-channel-name: на iOS это Expo.plist, на Android — meta-data в AndroidManifest.xml. Оттуда expo-updates берёт его в заголовок запроса, а из JS он виден как Updates.channel.
Разница между строками важнее, чем кажется. Первая публикует и требует собранного бандла. Вторая меняет одно поле на сервере: все установленные приложения с каналом production при следующей проверке спросят ту же ссылку, а получат обновление уже из другой ветки. Ни магазина, ни пересборки, ни даже новой публикации.
Обратное неверно: канал сборке не сменить. Он часть бинарника — и часть «данных сборки» для expo-updates: если у новой сборки другой expo-channel-name, BuildData стирает из базы все скачанные обновления, и телефон оказывается как после свежей установки.
В подложке — ветки «Подхода» с публикациями и каналы с их правилами; форма правила настоящая, из eas-cli: { version: 0, data: [{ branchId, branchMappingLogic }] }, где логика "true" значит «весь канал смотрит в эту ветку». Сервер из прошлой статьи умеет сверять runtimeVersion и платформу — добавим к нему выбор ветки по каналу. Прогноз: что получат три телефона, у которых вшиты каналы production, preview и development, и что изменится после переключения канала?
Телефон с каналом production получил upd-0911, а не более свежее upd-0915: последнее опубликовано под runtimeVersion 1.1.0, и замок из прошлой статьи его не пропустил. Канал preview смотрит в свою ветку и получил своё. Канал development в eas.json есть, а в EAS его не создавали — обновлений нет, и это не ошибка, а обычное состояние: канал появляется, когда в него впервые публикуют. А последняя строка — та самая одна команда: правило канала теперь показывает на hotfix-1.0, и тот же телефон с тем же запросом получил другое обновление.
У eas update есть оба флага, и они не синонимы. С --branch публикация идёт в названную ветку. С --channel команда сперва смотрит канал и берёт его ветку — в eas-cli это getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, и имя говорит всё: канала нет — создаст ветку и канал с тем же именем и свяжет их. А если у канала веток несколько (идёт раскатка), команда откажется: «Channel has multiple branches associated with it. Instead, use '--branch' instead of '--channel'». Отсюда простое правило: публикуют в ветку, а каналом пользуются, когда ветка ровно одна и называется так же.
Команда делает две вещи подряд. Сначала собирает бандл — тем же npx expo export, которым его можно собрать руками: в eas-cli это buildBundlesAsync, и в тексте ошибки при --skip-bundler он прямо советует npx expo export && eas update --skip-bundler. Потом читает metadata.json из папки dist и превращает его в группу обновлений. Вот этот экспорт в приложении автора; пути сокращены, служебные строки в начале и конце убраны, остальное как есть:
Всё, что нужно публикации, лежит в metadata.json: имя файла бандла и список ассетов с расширениями.
Дальше начинается знакомое по первой статье главы. Из bundle рождается launchAsset с contentType application/javascript, из assets — список assets, где тип угадывается по расширению. У каждого файла считаются два отпечатка: bundleKey — MD5 содержимого, тот самый key из манифеста, и storageKey — SHA-256 от типа содержимого и SHA-256 самого файла. Первый нужен телефону, второй — серверу.
Там же, при публикации, вычисляется runtimeVersion — из конфига или отпечатка, ровно как в двух прошлых статьях. Отсюда самая тихая беда выката: правка тянула новый нативный пакет, отпечаток разошёлся, обновление опубликовано под новой меткой — и ни одна установленная сборка его не получит. Команда при этом отработает без единого предупреждения, а метка будет напечатана в её выводе:
Поле вывода eas update | Что означает |
|---|---|
Branch | ветка, в конец которой легла группа |
Runtime version | метка, посчитанная при публикации. Её и надо сверить с меткой своих сборок |
Platform | платформы, попавшие в группу: android, ios или обе |
Update group ID, Android update ID, iOS update ID | идентификаторы группы и обновлений в ней; Update group ID нужен для update:republish |
Android Rollout, iOS Rollout | печатается, только если публиковали с --rollout-percentage |
Message, Commit | ваше сообщение и хеш коммита, если репозиторий чист |
Одна публикация даёт по обновлению на платформу — бандлы у Android и iOS разные, — и они связаны в группу. Если платформы дают разные runtimeVersion (например, при политике nativeVersion, где в метку входит номер сборки), групп будет несколько, и eas-cli так и скажет: «Since multiple runtime versions are defined, multiple update groups have been published». Переиздание и удаление работают с группой целиком.
Теперь про размер, и цифры здесь настоящие. Экспорт выше дал бандл entry-358b4e73…hbc на 3 947 353 байта и 27 ассетов на 985 718 байт, из которых 962 968 — один шрифт. Тот же экспорт с флагом --no-bytecode даёт обычный JS на 2 576 417 байт: байткод Hermes крупнее исходника примерно в полтора раза, зато телефону не нужно разбирать его при запуске.
Главное в этих числах — что бандл один. В части IV выкладка сайта обходилась малой кровью: Next.js режет приложение на куски по маршрутам, и браузер докачивает только те, что нужны странице. Здесь кусков нет. Metro собирает один файл, и React, и ваши экраны, и журнал операций из главы 6 лежат в нём вместе, поэтому новая длина таймера отдыха — это новые четыре мегабайта, а не новая строка.
С ассетами иначе, и считают их дважды по разным правилам. Сервер смотрит на storageKey: если такой файл уже загружали — хоть в другой ветке, хоть полгода назад, — второй раз он не поедет, и eas-cli напишет «Uploading assets skipped - no new assets found». Телефон смотрит на key из манифеста и сравнивает со своей базой — это мы собирали в первой статье главы. Прогноз: обновление upd-0917 правит JS и несёт новую картинку пустого дневника, которую до этого выкатывали в preview. Сколько файлов и байт уедет на сервер при публикации и сколько скачает телефон?
Числа разошлись, и это не ошибка счёта. Сервер уже видел все шесть ассетов, включая новую картинку, — он принял только бандл. Телефон картинку не видел никогда: её выкатывали в другую ветку, до него она не доезжала, — поэтому он качает на один файл больше. Общее у обоих одно: бандл в списке всегда. Его key — MD5 содержимого, а содержимое меняется от любой правки.
Единственная поблажка — bsdiff: телефон просит бандл с заголовком A-IM: bsdiff и готов принять разницу с тем, на котором запущен. Отдавать разницу или нет, решает сервер; чтобы EAS умел считать её ещё и против вшитого в сборку бандла, есть отдельная команда eas update:embedded:upload.
Обновление, которое доехало до всех за час, — это и достоинство, и способ уронить приложение у всех сразу. Поэтому у выката есть доля. Способа два, и они разные по устройству. Первый — процент на самом обновлении внутри ветки: --rollout-percentage; тем, кто в долю не попал, отдаётся предыдущее последнее обновление ветки. Второй — раскатка ветки на канале: правило канала на время становится составным и делит установки между двумя ветками.
Вот тут и видно, что правило канала — данные, а не код. Составное правило из eas-cli устроено так: первая запись ведёт на новую ветку и требует выполнения двух условий — runtimeVersion равна названной и rolloutToken меньше доли; вторая запись ведёт на прежнюю ветку с логикой "true", то есть ловит всех остальных. Операторы всего два: == и hash_lt. Соберём разбор этого правила и прогоним его на тысяче установок. Прогноз: сколько из тысячи попадёт на hotfix-1.0 при доле 10%, что будет с ними при переходе к 50% и что получит установка со сборкой 1.1.0?
Доля считается не от тысячи. Условие runtimeVersion == "1.0.0" отсекает каждую пятую установку ещё до жребия, остаётся восемьсот — и в раскатку из них попали 67, а не 80: жребий здесь наш собственный, обычный хеш от короткой строки, и на таком числе установок его неровность видна глазом. Две последние строки — про вторую половину правила: установка inst-7 на сборке 1.0.0 уходит на hotfix-1.0, а она же на сборке 1.1.0 остаётся на прежней ветке, потому что первое условие не выполнено. Но главная строка — третья: при переходе с 10% на 50% из первой группы не выпал никто. Так работает hash_lt: у установки есть своё число, и порог, сдвигаясь вправо, только добавляет новых. Будь на его месте случайный жребий, доля вела бы себя совсем иначе — это задача в конце статьи.
Составное правило — временное состояние канала, у него два выхода: --action=end --outcome=republish-and-revert (ветка-победитель остаётся, канал снова смотрит в одну ветку) или откат к прежней. Пока раскатка идёт, у канала две ветки — и eas update --channel откажется публиковать, потому что не сможет выбрать между ними. Это второй повод писать в командах ветку, а не канал.
Выкатили, и через час стало ясно, что таймер отдыха считает неправильно. Первое движение — вернуть канал на прежнюю ветку. Поможет ли оно, зависит от того, где лежит плохое обновление, и решает это LoaderSelectionPolicyFilterAware. Время публикации в ней — не первая проверка. Ответ EAS несёт заголовок expo-manifest-filters с именем ветки: branchname="release-1.0". Телефон сверяет фильтр с metadata манифеста — и нового обновления, и запущенного. Там лежит branchName, а ключи сравниваются без учёта регистра. Если запущенное фильтр не проходит, новое берётся без сравнения времени. До строки newUpdate.commitTime.after(launchedUpdate.commitTime) дело доходит, только когда запущенное фильтр проходит: оно с той же ветки или вшито в сборку, где metadata нет.
Соберём решение телефона вместе с фильтром и прогоним по нему пять ответов ветки release-1.0. Телефонов два. На A запущено upd-0917 от 17 сентября, пришедшее с hotfix-1.0, и канал вернули на release-1.0. B — другая история: плохую группу upd-0919 опубликовали прямо в release-1.0, куда канал и смотрит. Прогноз: что сделают A и B с одной и той же старой upd-0911, а B ещё — с группой, переизданной republish, и с директивой отката, свежей и с прежним временем?
Первые две строки — один и тот же ответ, а решения противоположные. A скачал upd-0911, хотя она на шесть дней старше запущенной: его upd-0917 с hotfix-1.0 не проходит фильтр release-1.0, и до времени дело не дошло. Значит, если плохое обновление пришло с другой ветки, возврат канала откатывает по-настоящему. B ту же группу пропустил: он и так на release-1.0, фильтр пройден, и решает время. Здесь старое само не вернётся, нужна новая публикация. eas update:republish не «возвращает» группу, а создаёт новую с тем же содержимым и новым временем, и она уже новее. Директива rollBackToEmbedded файлов не несёт вовсе, а велит вернуться к бандлу, вшитому в сборку. Последняя строка показывает, что и директива живёт по тому же правилу: у неё есть commitTime, и старая директива на той же ветке не сделает ничего.
Чего откат не делает — стоит знать заранее. Он не возвращает нативную часть: бинарник на телефоне тот же, и если беда в нём, помогает только магазин. Он не откатывает миграции схемы на устройстве: вернувшийся бандл увидит базу из главы 6 уже перестроенной, поэтому миграции на телефоне делают только расширяющими. И он ничего не чинит на тех, кто в это время не открывал приложение, — они просто получат сразу исправленную версию.
Если чинить некогда, у канала есть стоп-кран: eas channel:pause production. Правило канала становится пустым, и, как пишет сама команда, «Users with builds on channel production will no longer receive updates» — новые обновления не поедут никому, а уже скачанные останутся.
- Ветка — очередь публикаций, канал — имя, вшитое в сборку, а между ними правило канала:
{ version: 0, data: [{ branchId, branchMappingLogic }] }. Ветку канала меняют одной командой, сам канал — только новой сборкой. eas updateсобирает бандл тем жеexpo export, читаетmetadata.jsonи делает из него группу обновлений:launchAssetиз бандла,assetsиз ассетов,runtimeVersion— из конфига или отпечатка при публикации.- Бандл летит целиком при любой правке: в настоящем экспорте это 3 947 353 байта байткода Hermes. Кусков по маршрутам, как у сайта из части IV, здесь нет — приложение собрано в один файл.
- Ассеты считают дважды: сервер пропускает знакомые по
storageKey, телефон — поkeyиз манифеста. Поэтому «что уедет при публикации» и «что скачает телефон» — разные числа. - Раскатка — временное составное правило канала:
runtimeVersion ==иrolloutToken hash_lt. Число у установки устойчивое, поэтому рост доли только добавляет участников. - Откат зависит от того, где лежит плохое обновление. Пришло с другой ветки — канал возвращают на прежнюю, и телефон берёт её обновление, даже более старое: запущенное не проходит фильтр ветки из
expo-manifest-filters, и время не сравнивается. Лежит в той же ветке — старое само не вернётся, телефон возьмёт только то, что новее запущенного. Тогда откат — это публикация:update:republishсоздаёт новую группу, аroll-back-to-embeddedшлёт директиву со своимcommitTime. Натив и миграции схемы на устройстве откат не отменяет.
1/4Сборка в магазине собрана с каналом production. Что нужно, чтобы её обновления пошли из другой ветки?
2/4В обновлении изменилась одна строка JS, ассеты прежние. Что уедет на сервер при публикации?
3/4Идёт раскатка 10%, и вы поднимаете её до 50%. Что будет с теми, кто уже получил новую ветку?
4/4Телефон запущен на вчерашнем обновлении с ветки hotfix-1.0. Канал вернули на прежнюю ветку, где последняя публикация недельной давности. Что сделает телефон?
Раскатка, которая скачет
Коллега собрал раскатку на 10% сам и взял число не от установки, а от номера проверки. На бумаге доля верная: в среднем каждая десятая проверка попадает в раскатку. Посмотрите, что при этом видит один телефон, и почините inRollout так, как это делает правило hash_lt.
Раскатка обязана быть свойством установки, а не события. В правиле EAS это и записано ключом rolloutToken: число берётся от того, кто спрашивает.
Теперь двадцать проверок дают двадцать одинаковых ответов: inst-7 ++++++++++++++++++++ и «попаданий: 20 из 20». У сломанного варианта доля в среднем тоже десять процентов, и в этом вся хитрость: на бумаге всё верно, а на деле жребий бросается заново при каждой проверке. И назад телефон вернётся так же легко: выпал из доли — и снова запустит прежнее обновление, хоть оно и старше, потому что запущенное с другой ветки не проходит фильтр, об этом раздел про откат. Получаются качели: в каждый момент на новой ветке около десятой части телефонов, но за несколько дней через неё пройдут все, и не по разу.
Откат, который не долетел
У своего сервера обновлений одна очередь публикаций и нет фильтра ветки: плохое обновление лежит там же, где и прежнее, и телефон, как B из статьи, решает по времени. На просьбу откатиться сервер отдаёт старую группу как есть — тот же id, то же время публикации. Телефон запущен на upd-0917, и такой ответ он пропускает. Допишите republish так, как это делает eas update:republish.
Внутри одной ветки переиздание — это публикация, а не возврат: то же содержимое, но своя запись в ветке и своё время.
Вывод меняется на «сервер отдаст: upd-0911-r2 2026-09-20T09:00:00.000Z» и «телефон: скачает upd-0911-r2». У настоящей команды так же: у переизданной группы свой Update group ID, и сообщение к ней команда просит написать заново. Если бы сервер отдавал старую группу как есть, телефоны, успевшие обновиться, остались бы со сломанной версией навсегда — до следующей нормальной публикации.
Канал на паузе — это не «канала нет»
eas channel:pause оставляет каналу пустое правило: data: []. Для сервера это «обновлений не отдавать», и от несуществующего канала такой случай стоит отличать — хотя бы ради того, кто будет читать журнал. Допишите resolveChannel так, чтобы три случая дали три разных ответа.
Случаи различаются двумя проверками подряд: сначала есть ли канал, потом есть ли в его правиле хоть одна запись.
Вывод: production { branch: "release-1.0" }, preview { reason: "канал на паузе" }, development { reason: "канала нет" }. Телефону во всех трёх случаях уедет одно и то же — 204 и «обновлений нет», — но в журнале сервера это три разные строки, и только вторая значит, что кто-то нажал стоп-кран.