EAS Update: каналы, ветки и вес обновления

обновления≈ 16 минут

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

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

Разница между строками важнее, чем кажется. Первая публикует и требует собранного бандла. Вторая меняет одно поле на сервере: все установленные приложения с каналом production при следующей проверке спросят ту же ссылку, а получат обновление уже из другой ветки. Ни магазина, ни пересборки, ни даже новой публикации.

Обратное неверно: канал сборке не сменить. Он часть бинарника — и часть «данных сборки» для expo-updates: если у новой сборки другой expo-channel-name, BuildData стирает из базы все скачанные обновления, и телефон оказывается как после свежей установки.

В подложке — ветки «Подхода» с публикациями и каналы с их правилами; форма правила настоящая, из eas-cli: { version: 0, data: [{ branchId, branchMappingLogic }] }, где логика "true" значит «весь канал смотрит в эту ветку». Сервер из прошлой статьи умеет сверять runtimeVersion и платформу — добавим к нему выбор ветки по каналу. Прогноз: что получат три телефона, у которых вшиты каналы production, preview и development, и что изменится после переключения канала?

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

Телефон с каналом 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 и превращает его в группу обновлений. Вот этот экспорт в приложении автора; пути сокращены, служебные строки в начале и конце убраны, остальное как есть:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, приложение автора (Expo SDK 57, React Native 0.86)
Starting Metro Bundler
Android Bundled 9733ms …/expo-router/entry.js (1574 modules)
› Assets (27):
…/@expo-google-fonts/material-symbols/400Regular/MaterialSymbols_400Regular.ttf (963KB)
…/expo-router/assets/arrow_down.png (9.5KB)
…/expo-router/assets/unmatched.png (4.8KB)
…
› android bundles (1):
_expo/static/js/android/entry-358b4e73d2e36ba9d4165f913b957ce3.hbc (3.9MB)
› Files (1):
metadata.json (1.9KB)

Всё, что нужно публикации, лежит в metadata.json: имя файла бандла и список ассетов с расширениями.

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

Дальше начинается знакомое по первой статье главы. Из 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. Сколько файлов и байт уедет на сервер при публикации и сколько скачает телефон?

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

Числа разошлись, и это не ошибка счёта. Сервер уже видел все шесть ассетов, включая новую картинку, — он принял только бандл. Телефон картинку не видел никогда: её выкатывали в другую ветку, до него она не доезжала, — поэтому он качает на один файл больше. Общее у обоих одно: бандл в списке всегда. Его key — MD5 содержимого, а содержимое меняется от любой правки.

Единственная поблажка — bsdiff: телефон просит бандл с заголовком A-IM: bsdiff и готов принять разницу с тем, на котором запущен. Отдавать разницу или нет, решает сервер; чтобы EAS умел считать её ещё и против вшитого в сборку бандла, есть отдельная команда eas update:embedded:upload.

Обновление, которое доехало до всех за час, — это и достоинство, и способ уронить приложение у всех сразу. Поэтому у выката есть доля. Способа два, и они разные по устройству. Первый — процент на самом обновлении внутри ветки: --rollout-percentage; тем, кто в долю не попал, отдаётся предыдущее последнее обновление ветки. Второй — раскатка ветки на канале: правило канала на время становится составным и делит установки между двумя ветками.

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

Вот тут и видно, что правило канала — данные, а не код. Составное правило из eas-cli устроено так: первая запись ведёт на новую ветку и требует выполнения двух условий — runtimeVersion равна названной и rolloutToken меньше доли; вторая запись ведёт на прежнюю ветку с логикой "true", то есть ловит всех остальных. Операторы всего два: == и hash_lt. Соберём разбор этого правила и прогоним его на тысяче установок. Прогноз: сколько из тысячи попадёт на hotfix-1.0 при доле 10%, что будет с ними при переходе к 50% и что получит установка со сборкой 1.1.0?

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

Доля считается не от тысячи. Условие 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 нет.

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

Соберём решение телефона вместе с фильтром и прогоним по нему пять ответов ветки 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, и с директивой отката, свежей и с прежним временем?

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

Первые две строки — один и тот же ответ, а решения противоположные. 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.

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

Раскатка обязана быть свойством установки, а не события. В правиле EAS это и записано ключом rolloutToken: число берётся от того, кто спрашивает.

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

Теперь двадцать проверок дают двадцать одинаковых ответов: inst-7 ++++++++++++++++++++ и «попаданий: 20 из 20». У сломанного варианта доля в среднем тоже десять процентов, и в этом вся хитрость: на бумаге всё верно, а на деле жребий бросается заново при каждой проверке. И назад телефон вернётся так же легко: выпал из доли — и снова запустит прежнее обновление, хоть оно и старше, потому что запущенное с другой ветки не проходит фильтр, об этом раздел про откат. Получаются качели: в каждый момент на новой ветке около десятой части телефонов, но за несколько дней через неё пройдут все, и не по разу.

Откат, который не долетел

У своего сервера обновлений одна очередь публикаций и нет фильтра ветки: плохое обновление лежит там же, где и прежнее, и телефон, как B из статьи, решает по времени. На просьбу откатиться сервер отдаёт старую группу как есть — тот же id, то же время публикации. Телефон запущен на upd-0917, и такой ответ он пропускает. Допишите republish так, как это делает eas update:republish.

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

Внутри одной ветки переиздание — это публикация, а не возврат: то же содержимое, но своя запись в ветке и своё время.

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

Вывод меняется на «сервер отдаст: upd-0911-r2 2026-09-20T09:00:00.000Z» и «телефон: скачает upd-0911-r2». У настоящей команды так же: у переизданной группы свой Update group ID, и сообщение к ней команда просит написать заново. Если бы сервер отдавал старую группу как есть, телефоны, успевшие обновиться, остались бы со сломанной версией навсегда — до следующей нормальной публикации.

Канал на паузе — это не «канала нет»

eas channel:pause оставляет каналу пустое правило: data: []. Для сервера это «обновлений не отдавать», и от несуществующего канала такой случай стоит отличать — хотя бы ради того, кто будет читать журнал. Допишите resolveChannel так, чтобы три случая дали три разных ответа.

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

Случаи различаются двумя проверками подряд: сначала есть ли канал, потом есть ли в его правиле хоть одна запись.

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

Вывод: production { branch: "release-1.0" }, preview { reason: "канал на паузе" }, development { reason: "канала нет" }. Телефону во всех трёх случаях уедет одно и то же — 204 и «обновлений нет», — но в журнале сервера это три разные строки, и только вторая значит, что кто-то нажал стоп-кран.