How we ship mobile updates in minutes, not weeks
Mobile app releases are slow by default. You write a fix, open a PR, merge it, remember to kick off a build, wait 30 minutes, upload the binary, fill out release notes, submit for review, wait 24 hours, then pray nothing goes wrong. Multiply that by two platforms.
At Somethings, we ship a React Native app used by thousands of teens and mentors. Bugs that took 10 minutes to fix took 3 days to reach users. Native builds required someone to remember a sequence of manual steps -- and that someone was usually me.
So we built a pipeline where the only manual step is merging a pull request. Native builds get triggered, submitted to both app stores, and tagged automatically. OTA (over-the-air) updates — JavaScript-only changes pushed directly to users without an app store review — reach users within minutes. All from the branch name.
The branch name is the deploy intent
Every change falls into one of two categories: native changes (new modules, permissions, SDK upgrades) that require a full build, or JavaScript-only changes (bug fixes, UI tweaks, new screens) that ship via OTA. The branch name encodes which one it is:
| Branch pattern | What happens on merge |
|---|---|
release/* |
Native build + app store submission |
feat/*, bugfix/*, chore/* |
OTA update |
hotfix/* |
OTA update, marked urgent |
Merge to staging → goes to TestFlight / Internal Track. Merge to main → goes to production.
No deployment buttons. No Slack commands. The branch name is the intent.
We considered GitHub labels, manual triggers, and deploy bots before landing here. Branch naming won because it requires zero extra tooling. Every developer already creates branches. The prefix already communicates intent. There's nothing new to learn.
How native builds go straight to the app stores
This is the part we're most proud of. When a release/* branch merges to staging or main, a workflow extracts the version from the branch name (release/3.6.0 → 3.6.0), syncs it with EAS, triggers builds for iOS and Android with --auto-submit, and creates a git tag.
- name: Trigger production EAS builds
if: github.base_ref == 'main'
run: |
eas build --profile production --platform ios \
--non-interactive --no-wait --auto-submit
eas build --profile production --platform android \
--non-interactive --no-wait --auto-submit
--no-wait so the Action doesn't block for 15-30 minutes. --auto-submit so the binary goes straight to the store. The only remaining manual step is pressing "Submit for Review" in App Store Connect.
Before this, the build would finish on EAS and someone had to remember to download and upload it. That gap caused multi-day delays more than once. --auto-submit closed it.
A shell script handles the release branch setup:
./scripts/release-workflow.sh 3.6.0
Checks out main, creates release/3.6.0, bumps the version, commits. Feature branches can merge into the release branch without triggering deployment -- workflows only fire when the release branch merges into staging or main.
Nobody checks the Expo dashboard anymore
Native builds take 15-30 minutes. Without notifications, someone has to keep checking the Expo dashboard or App Store Connect to see if things worked. That got old fast.
We built a small webhook service -- a single TypeScript file on Vercel -- that receives EAS build and submit events and posts Slack notifications with direct links:
app.post("/webhooks/build", async (c) => {
const body = await c.req.text();
const signature = c.req.header("expo-signature");
if (!verifySignature(body, signature, secret))
return c.json({ error: "Invalid signature" }, 401);
const payload: BuildPayload = JSON.parse(body);
await sendToSlack(slackUrl, formatBuildMessage(payload));
return c.json({ ok: true });
});
Setup is two eas webhook:create commands pointed at the Vercel function. When someone merges a release/* branch, the team sees Slack messages as each build finishes and each binary gets submitted. Nobody opens the Expo dashboard. Nobody checks App Store Connect wondering "did the upload go through?"
This was maybe 2 hours of work and it removed an entire class of "did that actually ship?" conversations from our Slack.
OTA updates land in minutes, not days
When a feat/*, bugfix/*, or chore/* branch merges into staging or main, a GitHub Actions workflow kicks in. It reads the current version from EAS, bumps the patch (3.5.4 → 3.5.5), publishes the OTA bundle, and notifies the backend. The app shows a toast with a "Restart" button when the update lands.
One thing we got wrong early: storing the version in package.json. Constant merge conflicts when multiple OTAs were in flight. Two developers merge to staging within an hour and suddenly you're resolving conflicts on a version string. We moved the version to an EAS environment variable (EXPO_PUBLIC_APP_VERSION) and that problem disappeared overnight. CI reads and increments it atomically. Single source of truth.
The core script:
# Get current version from EAS.
CURRENT_APP_VERSION=$(eas env:get \
--variable-name EXPO_PUBLIC_APP_VERSION \
--variable-environment "$ENV" \
--non-interactive 2>/dev/null | cut -d'=' -f2 | tr -d '\n')
# Increment patch.
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_APP_VERSION"
NEW_PATCH=$((PATCH + 1))
NEW_APP_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
# Update version in EAS and publish.
eas env:update --variable-name EXPO_PUBLIC_APP_VERSION \
--variable-environment "$ENV" --value "$NEW_APP_VERSION" --non-interactive
eas update --environment "$ENV" --channel "$CHANNEL" \
--message "$MESSAGE" --clear-cache --non-interactive
The workflow only fires for the right branch prefixes:
if: >
github.event.pull_request.merged == true && (
startsWith(github.head_ref, 'feat/') ||
startsWith(github.head_ref, 'bugfix/') ||
startsWith(github.head_ref, 'chore/') ||
startsWith(github.head_ref, 'hotfix/')
)
This means release/* branches merging to the same target don't accidentally trigger an OTA. If the source branch is hotfix/*, the workflow automatically marks the update as urgent when notifying the backend.
When multiple developers merge to staging within an hour, OTAs publish one after another. Each contains the full bundle, so users only download the latest one.
The version lifecycle ends up looking like this:
Native Build → sets 3.6.0
OTA #1 → auto-increments to 3.6.1
OTA #2 → auto-increments to 3.6.2
Native Build → sets 3.7.0 (patch resets)
OTA #1 → auto-increments to 3.7.1
Major and minor versions are human decisions. Patch versions are fully automated.
The gap that's still open
We also documented what to do if a staging build accidentally ships to production (temporarily swap EAS env vars, push an emergency OTA). Haven't needed the full procedure yet. Parts of it have been useful in less dramatic situations.
But the real gap is testing. CI runs type checking, linting, and format checks on every PR -- but it doesn't verify the app actually works. A screen could crash on mount and still pass all checks.
We're adding Maestro E2E tests that run before anything ships. Login, chat, session booking -- verified on a simulator before any update reaches users. If the tests fail, the deployment doesn't go out. Right now we merge and trust. Soon we'll merge and verify.

Arman Khan
Founding Principal Engineer at Somethings. Building a mentorship platform connecting teens with mentors.
Somethings Engineering