This is a guest post from David Davidov - a Founding Engineer at Posh - the social marketplace for real-world experiences that helps organizers create, scale, and monetize communities through events. 
…
After wrestling with lengthy app review cycles and time-sensitive manual releases, our team at Posh invested in rebuilding our mobile CI/CD pipelines to eliminate as much manual work as possible. For context, our app serves millions of users across iOS and Android, so delivering releases to the app stores safely, reliably, and on schedule has always been a top priority for us. That investment paid off, and the improved release velocity helped support our growth to charting the #1 app in the Entertainment category on the App Store.
Discovering our pain-points
Since I kicked off development for our mobile app over 2 years ago, I've always disliked the manual process of releasing via the stores. Our team knew about Expo Updates but before fingerprinting and EAS Workflows' pre-packaged jobs the process of automating OTA updates always felt a little janky with too much room for error. Some of the pain-points we faced in the last two years were:
- No insight as to when a task on Linear was released to a certain app version.
- Having to create a weekly rotating Release Train manager who was responsible for releasing that week's set of changes ← Our biggest bottleneck
- Automatically integrating E2E tests into our release process
- Having to kick off builds and submissions using unstable GitHub Actions that required lots of maintenance
- Understanding whether an engineer's PR introduced new native changes
All of these pain-points made developing on mobile feel like a chore rather than a creative, fast-moving experience.
After revisiting Expo's advancements around fingerprinting, workflows, and OTA updates, I decided to redesign our release system from the ground up, automating as much of the process as possible while still preserving manual approval points where they mattered most. This automation helped us go from weekly manual releases to nearly monthly store releases and multiple OTA updates a week!
What we wanted to solve for
- An easier way of installing and testing new changes introduced in a PR while understanding if a PR introduced new native changes
- A system that doesn't over-trigger EAS builds, only fire them when we're ready to start testing
- Gracefully introduce OTA updates with an opportunity for them to be testable in a staging environment, changes merged to master should not be automatically deployed to production!
- Reduce the need to create new version branches, introduce them when native changes get introduced to master
- Automate away the hassle of notifying our team when new versions have been released to production & what changes those versions include
I'll dive into how we managed to set ourselves up to spend less time releasing to production so you don't have to.
Pre-requisites
- I would recommend watching this video from Beto on splitting up your app environments, this will be needed for configuring specific update channels/app environments. You should be able to install development, preview/staging, and production builds on one device by the end of that tutorial. This also makes testing your app much easier since you can hop between the builds easier.
- Configure Expo Updates with Expo Fingerprints on your project.
Automation 1: Setting up PR previews
This is not mandatory for the CI/CD workflow but it will help your team review changes faster, especially if you're into agentic workflows.
After reading this article I was inspired to create an EAS workflow that comments on PRs with a QR code that installs the newly introduced changes on the development app. I won't go too deep on the code for this but I changed a few things on this workflow:
- We don't build the app if a fingerprint was not found (cannot OTA to development). I felt like this would blow our credit usage a bit. I might change this in the future if I want to automate package updates via agents.
- We add a
needs new buildlabel on PRs that introduce new native code. This is just for filtering GitHub PRs; if you continue to read you'll see I like labels quite a lot. - We edited the comment Expo's example had to be a bit more custom. It adds some instructions, links to recent builds under this fingerprint, troubleshooting steps & larger QR codes so it's in your face more 😆
Heres what the PR preview comment ends up looking like.
Heres how the PR preview workflow should look like
Now when I request Devin or Claude to make changes to a certain area in the app, I can flash it instantly and get a feel for what exactly changed. I can now review & ship on the go 🏃♂️✨
Automation 2: OTA branches
Now let's say I merge a few PRs into master. I would not want those PRs to be automatically deployed to production without some sort of opportunity to test them. Creating OTA branches also gives us an opportunity to batch our OTAs with a few changes rather than each PR triggering an OTA update.
OTA branches require two workflows:
- A master/main workflow. This will be used for:
- Spinning up an OTA branch under the current app version with an optional
OTA branchlabel, eg:8.0.0 OTA #1- Labels make filtering for PRs easier ✨
- If an OTA branch is open already, rebasing it on top of master
- Automatically adding the newly introduced PR title & issue number into the changelog inside that PR
- When an OTA branch gets merged into master, a step for triggering an EAS Update to production
- We will continue to expand the master/main workflow; the last section gives an overview of what the master workflow will look like
- Spinning up an OTA branch under the current app version with an optional
- A workflow that runs on every push to OTA branches. This will contain:
- OTA publishes to your preview environments
It should look something like this:
name: OTA Previewon:push:branches: ['ota_update_*']jobs:fingerprint:name: Fingerprinttype: fingerprintenvironment: previewpublish_preview_android:name: Publish Android preview OTAneeds: [fingerprint]type: updateenvironment: previewparams:platform: androidbranch: previewpublish_preview_ios:name: Publish iOS preview OTAneeds: [fingerprint]type: updateenvironment: previewparams:platform: iosbranch: preview
Now when JS only changes get merged in, you should have a branch that looks a bit like this
This is what that PR preview workflow looks like
The diff in the OTA branches should just be changelog updates like so:
## v8.15.1 OTA 1 (2026-04-13)- Sample JS Changes white again #166- Sample JS Changes green again #165- Sample JS Changes #163- Sample JS Changes #161- Sample JS Changes #160- Sample JS Changes #158## v8.15.1 (older version)
We will revisit how we utilize the changelog in the last automation.
Automation 3: Version branches
Let's say I merge in a PR that introduces new native changes. I'm expecting my workflow to close out/void any in-progress OTA branches and spin up a new version branch in replacement of that. We will use that branch until we deploy it to production. This will be the source of truth for our engineering team as to whether they need to create a new release via the stores or not.
Similar to the OTA branch, the version branch includes two workflows:
- Expanding our master workflow to handle new native changes.
- If an OTA branch exists, close it out and create a new version branch with the
Version Branchlabel on it- Increase the mobile app's
package.jsonversion automatically
- Increase the mobile app's
- If a version branch exists already, rebase it onto master and update the changelog as we do for OTA branches
- If an OTA branch exists, close it out and create a new version branch with the
- A workflow that runs when we manually add the
build & submit bundleslabel to it- This ensures we're only submitting to the stores when our team is ready to cut a build
- This builds and submits to our staging track using Expo's prepackaged workflow commands
- When a new bundle is detected on both stores, we auto-invite our end-to-end testers to the builds
- After submitted, it notifies our end-to-end testers to start testing via Slack
Here's what it would look like:
name: Version Branch Buildson:pull_request:types: [labeled]concurrency:cancel_in_progress: truegroup: ${{ workflow.filename }}-${{ github.ref }}jobs:build_production_android:name: Build Production Androidif: ${{ github.event.label.name == 'build and submit' }}type: buildenvironment: productionparams:platform: androidprofile: productionbuild_production_ios:name: Build Production iOSif: ${{ github.event.label.name == 'build and submit' }}type: buildenvironment: productionparams:platform: iosprofile: productionbuild_preview_android:name: Build Preview Androidif: ${{ github.event.label.name == 'build and submit' }}type: buildenvironment: previewparams:platform: androidprofile: previewbuild_preview_ios:name: Build Preview iOSif: ${{ github.event.label.name == 'build and submit' }}type: buildenvironment: previewparams:platform: iosprofile: previewbuild_development_android:name: Build Development Androidif: ${{ github.event.label.name == 'build and submit' }}type: buildenvironment: developmentparams:platform: androidprofile: developmentbuild_development_ios:name: Build Development iOSif: ${{ github.event.label.name == 'build and submit' }}type: buildenvironment: developmentparams:platform: iosprofile: developmentsubmit_production_android:name: Submit Production Androidneeds: [build_production_android]type: submitenvironment: productionparams:build_id: ${{ needs.build_production_android.outputs.build_id }}profile: productionsubmit_production_ios:name: Submit Production iOSneeds: [build_production_ios]type: submitenvironment: productionparams:build_id: ${{ needs.build_production_ios.outputs.build_id }}profile: productionsubmit_preview_android:name: Submit Preview Androidneeds: [build_preview_android]type: submitenvironment: previewparams:build_id: ${{ needs.build_preview_android.outputs.build_id }}profile: previewsubmit_preview_ios:name: Submit Preview iOSneeds: [build_preview_ios]type: submitenvironment: previewparams:build_id: ${{ needs.build_preview_ios.outputs.build_id }}profile: previewnotify_testers:name: Notify QA Teamneeds: [build_preview_android, build_preview_ios, submit_preview_android, submit_preview_ios]environment: previewsteps:- name: Send Slack messagerun: |# Notify your QA team that new preview builds are available.# Include whatever your team needs, such as build numbers,# install links, release notes, or testing instructions.comment_builds:name: Post Build Linksneeds:[build_production_android,build_production_ios,build_preview_android,build_preview_ios,build_development_android,build_development_ios,]environment: productionsteps:- name: Post comment on PRrun: |# Post a PR comment with links to the generated EAS builds.# The implementation depends on how your CI environment exposes# repository, pull request, and authentication context.
That workflow should look like this.
The PR should now contain changes to the changelog and the mobile app's package.json for the new version number.
The only manual part of this would now just be submitting the apps for review and clicking release. We feel like that part should not be automated because it may include more changes than an updated bundle.
Automation 4: The master workflow
At this stage our master workflow should be responsible for:
- Creating and rebasing our OTA branch when PRs are merged in
- Creating and rebasing our version branch / closing any overlapping OTA branches when PRs are merged in
- Creating new releases on GitHub when an OTA or version branch is merged in
name: Production Deployon:push:branches: ['main']concurrency:cancel_in_progress: truegroup: ${{ workflow.filename }}-${{ github.ref }}jobs:fingerprint:name: Fingerprinttype: fingerprintenvironment: productionget_android_build:name: Check Android buildneeds: [fingerprint]type: get-buildparams:fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}profile: productionget_ios_build:name: Check iOS buildneeds: [fingerprint]type: get-buildparams:fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}profile: productioncreate_release:name: Create GitHub Releaseneeds: [fingerprint]if: ${{ contains(github.event.head_commit.message, 'app_version_') }}environment: productionsteps:- uses: eas/checkout- name: Create release# run a scripts to create a GH release using their APIpublish_production_android:name: Publish Android production OTAneeds: [get_android_build, get_ios_build]if: >-${{ contains(github.event.head_commit.message, 'ota_update_')&& needs.get_android_build.outputs.build_id&& needs.get_ios_build.outputs.build_id }}type: updateenvironment: productionparams:platform: androidbranch: productionpublish_production_ios:name: Publish iOS production OTAneeds: [get_android_build, get_ios_build]if: >-${{ contains(github.event.head_commit.message, 'ota_update_')&& needs.get_android_build.outputs.build_id&& needs.get_ios_build.outputs.build_id }}type: updateenvironment: productionparams:platform: iosbranch: productionupdate_release:name: Update GitHub Releaseneeds: [get_android_build, get_ios_build]if: >-${{ contains(github.event.head_commit.message, 'ota_update_')&& needs.get_android_build.outputs.build_id&& needs.get_ios_build.outputs.build_id }}environment: productionsteps:- uses: eas/checkout- name: Update release with OTA changelogrun: bash scripts/workflows/update-release.shcreate_ota_pr:name: Manage OTA PRneeds: [fingerprint, get_android_build, get_ios_build]if: >-${{ needs.get_android_build.outputs.build_id&& needs.get_ios_build.outputs.build_id&& !contains(github.event.head_commit.message, 'ota_update_')&& !contains(github.event.head_commit.message, 'app_version_') }}environment: productionsteps:- uses: eas/checkout- name: Create OTA branch and PRrun: |# run a script to create an OTA branch and PR incremented from the last one created from the current versionmanage_version_pr:name: Manage Version Branch PRneeds: [get_android_build, get_ios_build]if: >-${{ !contains(github.event.head_commit.message, 'ota_update_')&& !contains(github.event.head_commit.message, 'app_version_') }}environment: productionsteps:- uses: eas/checkout- name: Manage version branch and PRrun: |# run a script to create an Version branch and PR incremented from the last one created from the current version
This is what the master workflow should look like, every PR that merges in will pass through this workflow.
Automation 5: Notifying our team
After an OTA or version branch has been merged in we create a GitHub release on our repo using the auto-generated changelog from each branch. Using that we create a GitHub Action that fires on release publishes that:
- Sends a message on our team's Slack channel indicating new changes have been deployed!
- Finds every Linear ticket associated with the merged-in changes for that version and labels it with the version number
name: Release Slack Notificationon:release:types: [published]jobs:notify:name: Post Release to Slackruns-on: ubuntu-lateststeps:- name: Send release to Slackrun: |# Build and send a Slack message for the published release.# Find all the Linear issues completed in this release and add a version label to them using Linear's API# Include whatever your team needs, such as the release tag,# changelog, links to pull requests, or the release URL.
It could do anything you want with the tasks changed in this batch of OTA or native changes. For us it's just sending a message to Slack to let our team know when changes started rolling out and updating the recently released issues on Linear to contain the new version label.
An example of a slack notification that would fire when your changelog updates
Now anyone on our org can seamlessly filter issues by versions released. This is especially helpful for PMs and our support team. This ties the knot on our release cycle for both OTA updates and native updates. Once again, MERGED ≠ DEPLOYED for mobile apps.
Closing thoughts
After adopting these workflows, we've fully automated the creation and management of OTA and version branches with the safety net of having a staging env first and without over-triggering builds. As mobile engineers we don't have to worry about incrementing app versions, handling cross-department comms on when new changes are released to our app, or worse, accidentally deploying the wrong OTA to millions of users 😰.
I just want to start by saying this would've been a pain to set up without Expo Workflows and fingerprinting.
Expo and their constant developer improvements have been a key player in getting us to #1 for entertainment on the App Store.
We're going to continue advancing this pipeline in the near future, so feel free to reach out to me if you think there are ways we can iterate on this or if this guide helped you out in any way.


