Быстрый старт
От нуля до первого OTA-обновления примерно за десять минут. Без аккаунта Expo, без EAS, без привязки к облаку — только Otapush и ваше приложение.
Что понадобится перед стартом
- Аккаунт Otapush с приложением, созданным в портале. Новое приложение сразу получает три канала (
dev,staging,prod) и собственную пару ключей для подписи — и то и другое создаётся за вас. - Проект, который будем подключать: проект Expo (managed или prebuild) либо bare-проект React Native с установленными
expoиexpo-updates— для этого пути см. Bare React Native. - Node 18+ с npm для CLI (подойдёт и Bun).
- Release-сборка на реальном устройстве или эмуляторе. OTA-обновления применяются только к release-сборкам — dev-клиенты загружают JavaScript из Metro и никогда не обращаются к серверу обновлений.
Это весь список. Аккаунт Expo/EAS, отправка в стор и серверный SDK не понадобятся.
1. Создайте аккаунт и приложение
Откройте веб-портал, зарегистрируйтесь и нажмите New app. Выберите имя и slug — slug станет частью URL обновлений:
https://your-server.example.com/api/updates/<slug>/manifest
Вместе с приложением создаются три канала и уникальная пара ключей для подписи кода (приватный ключ остаётся на сервере; публичный сертификат вы заберёте на следующем шаге). Не закрывайте диалог инициализации — в нём есть всё нужное для шагов ниже.
2. Установите CLI и войдите
npm install -g otapush
# или: bun add -g otapush
otapush login --server https://your-server.example.com
otapush whoami
login интерактивен: спросит email и пароль портала и сохранит токен в ~/.otapushrc.json. Публикация авторизуется отдельным API-ключом, привязанным к приложению, а не этим логином; это разделение описано в справочнике CLI.
3. Свяжите проект командой otapush init
Из корня проекта:
otapush init
init спросит, какое приложение привязать (покажет список приложений аккаунта и может создать новое), а затем запишет три вещи:
certs/certificate.pem— публичный сертификат подписи приложения, полученный с сервера. Коммитьте его: клиент проверяет подписи манифестов по этому файлу.app.json— блокexpo.updatesсо ссылкой на ваш сервер, плюсruntimeVersionтолько если вapp.jsonего ещё нет (существующее значение никогда не перезаписывается).otapush.config.json— связку проект↔приложение: URL сервера, id и slug приложения, версия рантайма, тип проекта.
Чего init не делает: не устанавливает npm-пакеты, не задаёт checkAutomatically и fallbackToCacheTimeout (действуют значения expo-updates по умолчанию) и не трогает нативные файлы в managed-проекте.
Итоговая секция app.json выглядит так:
{
"expo": {
"updates": {
"url": "http://localhost:3000/api/updates/my-app/manifest",
"enabled": true,
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" }
},
"runtimeVersion": "1.0.0"
}
}
Bare-проект React Native (без
app.json)?initопределит это и вместоapp.jsonпропатчитAndroidManifest.xmlиExpo.plist— следуйте инструкции Bare React Native и вернитесь к шагу 4.
Требуется device id. Сервер отклоняет запросы обновлений без стабильного
deviceId(400 deviceId is required). Зарегистрируйте его при запуске приложения черезUpdates.setExtraParamAsync("deviceid", id)— ключ строчными буквами; сниппет есть в Настройке Expo, а рабочий пример —examples/expo-demo/App.tsx.
URL, сертификат и версия рантайма вшиваются в бинарник на этапе сборки. Поэтому шаг 4 — настоящая сборка, а вот после неё изменения одного JavaScript уходят без неё.
4. Один раз соберите release-бинарник
OTA работает только для release-сборок, поэтому соберите одну без EAS:
npx expo prebuild --clean
cd android && ./gradlew assembleRelease # Android
# iOS: соберите конфигурацию Release из Xcode или через xcodebuild
Установите её на устройство или эмулятор и запустите один раз — во встроенном бандле ещё нет серверных обновлений. Подробности, включая подпись iOS, — в Настройке Expo. Это последняя сборка, которая понадобится чисто JavaScript-изменению.
5. Опубликуйте первое обновление
Внесите заметное изменение в JavaScript, затем:
otapush publish --channel prod --platform android --message "First OTA update"
Почему prod: otapush init записывает «голый» URL манифеста без параметра канала, а сервер при отсутствующем канале обслуживает канал prod — именно его проверяет ваша сборка. (dev и staging — для сборок, чей URL содержит ?channel=<name>; см. вопрос про каналы ниже.)
CLI экспортирует JS-бандл и ассеты, загружает их на сервер, подписывает манифест и переводит канал на новое обновление. Перезапустите приложение дважды: первый запуск скачивает обновление, второй — выполняет. На вкладке Обзор появятся события check, download и install.
6. Продвигайте, а не публикуйте заново
Когда появится тестовый канал, переносите в production ровно тот артефакт, который проверили, — а не пересборку:
otapush publish --channel staging --platform ios --message "Ready for prod"
otapush promote --from staging --to prod
promote копирует текущее обновление канала в целевой канал для платформы этого обновления. Канал хранит по одному обновлению на платформу, поэтому для второй платформы повторите команду — или воспользуйтесь вкладкой Updates портала, где это делается по платформам кнопкой.
7. Откатитесь, если что-то пошло не так
otapush rollback --channel prod
Канал вернётся к предыдущему обновлению для этой платформы, а устройства восстановятся при следующей проверке — без релиза в сторе. Откат первого обновления канала тоже обработан: устройствам приказывают вернуться к бандлу, вшитому в бинарник (rollBackToEmbedded — см. Протокол). В портале есть откат в один клик по каждой платформе.
Вопросы и ответы
Что такое runtime version и когда её менять?
runtimeVersion — пропуск между бинарником и JS-обновлением: сервер отдаёт обновление только клиентам с точно совпадающей версией рантайма и никогда иначе. Меняйте её при любом изменении нативной части — новой нативной зависимости, обновлении Expo SDK, правке нативного кода — и пересобирайте приложение; почему эта граница существует, описано в политиках сторов.
Один подводный камень: otapush init не перезаписывает runtimeVersion, уже присутствующую в app.json, а otapush publish берёт значение из otapush.config.json (в конечном счёте — из записи приложения на сервере). Если вы вручную поменяли версию в app.json, передавайте --runtime-version при публикации (или обновите конфиг) — иначе обновления выйдут под версию, которую не запросит ни один бинарник.
Зачем пересобирать приложение после init?
Потому что init меняет нативную конфигурацию, а она поставляется только с бинарником: URL обновлений, сертификат подписи и версию рантайма читает нативный модуль expo-updates, а не ваш JavaScript. По воздуху после этого летают только JavaScript и ассеты — в этом и есть вся суть протокола.
Устройство не видит обновление — что делать?
Идите по списку; каждый пункт — реальный режим отказа:
- Совпадение runtime version.
runtimeVersionобновления должна в точности равляться версии бинарника — без диапазонов и «ближайших» совпадений. - Канал. Сборка с «голым» URL манифеста проверяет
prod; публикация вdevилиstagingдля неё невидима. Для непродовых сборок добавьте в URL?channel=<name>. - Release-сборка. Dev-клиенты и debug-сборки берут JS из Metro и сервер не проверяют вовсе.
- Два запуска. Первый скачивает, второй применяет — при
fallbackToCacheTimeout: 0первый launch ничего не ждёт. - Вкладка «Обзор». Событий
checkнет совсем: устройство не добралось до сервера (URL, сеть, ATS, блокирующий обычный HTTP на iOS).checkесть, аinstallнет: обновления скачиваются, но не запускаются — чаще всего проблема в подписи. - Device id. Отсутствующий или не строчный extra-параметр
deviceidзаканчивается ошибкой400 deviceId is required; см. Настройку Expo. - Устаревший сертификат. Если вы перезапустили
initи получили свежий сертификат уже после сборки бинарника, пересоберите сnpx expo prebuild --clean— сертификат вшит в сборку.
Чем канал отличается от ветки?
Веток в Otapush нет — только каналы, и они проще веток EAS. Канал — именованный указатель, хранящий по одному текущему обновлению на платформу (dev, staging, prod создаются по умолчанию; новые можно добавить в портале). Публикация сдвигает указатель, promote копирует его в другой канал, rollback отступает назад. Каждое опубликованное обновление остаётся в списке, и его можно продвинуть снова — но нет дерева истории, нет частичных раскаток в процентах, и канал выбирается устройством только через URL, вшитый при сборке.
Подойдёт ли этот быстрый старт для bare-приложения React Native?
Да. otapush init распознает bare-проект (нет app.json, в зависимостях есть react-native) и патчит AndroidManifest.xml и Expo.plist вместо app.json; при публикации выполняется npx react-native bundle вместо expo export. Всё остальное — каналы, deviceId, publish/promote/rollback — идентично; подробности в Bare React Native.
Куда дальше
- Справочник CLI — все команды, флаги и коды выхода, плюс настройка CI.
- Протокол — как сервер общается с клиентами
expo-updates, запрос за запросом. - Политики сторов — что Apple и Google разрешают доставлять «по воздуху», и чек-лист перед публикацией.