Быстрый старт

От нуля до первого 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 и ассеты — в этом и есть вся суть протокола.

Устройство не видит обновление — что делать?

Идите по списку; каждый пункт — реальный режим отказа:

  1. Совпадение runtime version. runtimeVersion обновления должна в точности равляться версии бинарника — без диапазонов и «ближайших» совпадений.
  2. Канал. Сборка с «голым» URL манифеста проверяет prod; публикация в dev или staging для неё невидима. Для непродовых сборок добавьте в URL ?channel=<name>.
  3. Release-сборка. Dev-клиенты и debug-сборки берут JS из Metro и сервер не проверяют вовсе.
  4. Два запуска. Первый скачивает, второй применяет — при fallbackToCacheTimeout: 0 первый launch ничего не ждёт.
  5. Вкладка «Обзор». Событий check нет совсем: устройство не добралось до сервера (URL, сеть, ATS, блокирующий обычный HTTP на iOS). check есть, а install нет: обновления скачиваются, но не запускаются — чаще всего проблема в подписи.
  6. Device id. Отсутствующий или не строчный extra-параметр deviceid заканчивается ошибкой 400 deviceId is required; см. Настройку Expo.
  7. Устаревший сертификат. Если вы перезапустили 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 разрешают доставлять «по воздуху», и чек-лист перед публикацией.