Why We Ditched Git-Flow for Trunk-Based Mobile Releases

작성자

카테고리:

← 피드로
DEV Community · Soham Mondal · 2026-08-26 개발(SW)

If you’ve shipped a React Native app on git-flow, you know the tax. develop, release/x.y.z, a hotfix branch nobody merges back cleanly, a changelog somebody writes half-awake before store submission, and a release manager doing branch archaeology to answer a simple question: “what is actually in production right now?”

Git-flow did not break for us because branching theory is flawed. It broke because mobile release safety was being enforced by memory, rituals, and good intentions. That is manageable right up until you’re moving fast, juggling OTA updates, store binaries, native dependencies, and the very real fact that Apple does not care that your branches looked tidy.

We moved our React Native app to trunk-based development. Everything lands on main. No long-lived develop. No babysitting release/* branches. No merge-back ceremony after a hotfix. But the important part is not “fewer branches.” The important part is that the decision of whether a commit becomes an OTA update or a full store release is not made by a human anymore. Our release orchestrator makes that call after checking the app’s native fingerprint.

That, to me, is what a mature Expo/React Native release setup looks like: one trunk, boring release mechanics, and explicit guardrails around the native/runtime boundary.

Git-flow vs trunk-based, in one line
Git-flow tries to create safety with branch structure. Trunk-based development tries to create safety with fast integration and stronger automation.

OTA update
In Expo/EAS Update terms, OTA means shipping JavaScript and assets to already-installed binaries without waiting for App Store review. It cannot change native code, native dependencies, permissions, or anything else baked into the binary. Expo’s docs are clear on that boundary.

Someone juggling too many plates, one about to fall

This shift was partly opinionated and partly practical. I had asked peers in the Expo community what they were doing for branching strategy, and the answers were all over the place: some still used release branches, some shipped almost everything from main, some had custom hybrids built around EAS Update and hotfix pressure. That thread reinforced something I already suspected: there isn’t one blessed branch model for React Native. But there is a pattern. Teams that get good at shipping keep removing manual release decisions.

For context, that discussion is here: What’s your team’s branching strategy for React Native / Expo?

The one idea that makes this work

Mobile has a problem web does not: most of your code can update instantly over the air, but some of it is physically baked into a binary already sitting in the App Store or Play Store. Once that binary is installed, there is no clever branching strategy that lets you OTA your way around a native mismatch.

So the whole system rests on one question, asked by a machine, not by a human in a release Slack channel:

Does this code produce the same native fingerprint as what’s already in the store?

Every native-relevant input gets hashed into a fingerprint: native dependencies, config plugins, platform config, and other inputs that affect the runtime baked into the app. Expo has been pushing this model pretty hard, and rightly so. If you use runtimeVersion with the fingerprint policy, that compatibility boundary is no longer a hand-maintained version string. It is derived from the actual native shape of the app.

That means the release system does not need somebody to eyeball a PR and say, “eh, this seems JS-only.” It can compute the answer.

Native fingerprint
Expo Fingerprint is effectively a hash of the app’s native-relevant surface area. If that hash changes, the binary and update may no longer be compatible. See the Expo Fingerprint docs and runtime version docs.

$ eas fingerprint:compare --build <latest-production-build-id>
✔ Fingerprints match
No native changes detected for this runtime.

$ eas fingerprint:compare --build <latest-production-build-id>
✖ Fingerprints do not match
Changed sources:
  + app.config.ts
  + ios/Podfile.lock
  + android/gradle/libs.versions.toml

This update needs a new binary before it can ship safely.

Enter fullscreen mode Exit fullscreen mode

That diff is the useful bit. Not just “hash mismatch, good luck,” but the actual sources that changed. That is the difference between a five-minute investigation and an hour of people arguing whether a dependency bump really changed the runtime.

A few examples of changes that look innocent until they aren’t:

  • Adding a package that includes a native module.
  • Changing an Expo config plugin.
  • Bumping an SDK that rewrites native build files.
  • Touching permissions, entitlements, or platform-specific build config.

In git-flow, somebody has to remember those rules. In a mature Expo pipeline, the orchestrator enforces them.

A friendly computer politely explaining why the fingerprint check failed

OTA vs. full release, decided by the orchestrator

A release request should not ask a human to choose between ota and full-release. That’s exactly the decision humans get wrong under pressure.

The better model is simple: trigger one release workflow, let it inspect the current state, then route to the correct path.

on:
  workflow_dispatch:
jobs:
  decide-release-mode:
    runs-on: ubuntu-latest
    outputs:
      mode: ${{ steps.decide.outputs.mode }}
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci

      - id: decide
        run: |
          if eas fingerprint:compare --build "$LATEST_PROD_BUILD_ID" --non-interactive; then
            echo "mode=ota" >> "$GITHUB_OUTPUT"
          else
            echo "mode=full-release" >> "$GITHUB_OUTPUT"
          fi

  ota:
    needs: decide-release-mode
    if: ${{ needs.decide-release-mode.outputs.mode == 'ota' }}
    runs-on: ubuntu-latest
    steps:
      - run: eas update --branch production --message "$(git log -1 --pretty=%s)"

  full-release:
    needs: decide-release-mode
    if: ${{ needs.decide-release-mode.outputs.mode == 'full-release' }}
    runs-on: ubuntu-latest
    steps:
      - run: eas build --platform all --profile production --non-interactive
      - run: eas submit --platform all --latest --non-interactive

Enter fullscreen mode Exit fullscreen mode

main splitting into an OTA path and a native release path

That is not meant to be copy-paste production YAML. It is the architectural point: one release intent goes in, the workflow decides the path.

If the fingerprint matches the latest production binary, ship OTA in minutes. If it doesn’t, cut a real binary and submit it. Same trigger, different outcome, machine-decided.

This is also easier to explain to the team. Engineers do not need a release-mode flowchart in their heads. They need one rule: merge to main, and let the orchestrator classify the change.

That approach lines up with where Expo/EAS has been heading. EAS Update, EAS Build, runtime versioning, and fingerprint-aware compatibility are all steering toward the same idea: stop treating release safety as tribal knowledge. Encode it.

What actually happens on a release

The best release systems are boring in the right places. Nobody should be hand-editing version numbers or typing release notes into a markdown file five minutes before submit.

When a release is triggered, the pipeline should do roughly this:

  1. Cut a release PR that bumps the app version and generates a changelog from commit history.
  2. Merge that PR, tag the commit, and build/submit from that exact revision.
  3. Pin a snapshot branch at the shipped commit so “what is in production” is a Git ref, not a memory exercise.

That store/1.24.0+512 branch is not a long-lived release branch. It is a frozen pointer to the exact commit that became the store binary.

That distinction matters.

In git-flow, release/* branches tend to become semi-alive places where extra fixes, cherry-picks, and “just this one last thing” changes accumulate. In a trunk-based mobile setup, the snapshot branch should stay dumb. It exists so that when production breaks, you know exactly where to branch the hotfix from.

Snapshot branch
A snapshot branch is just a named Git ref pinned to the exact shipped commit. It is not a second lane of active development. Its job is traceability and safe hotfix routing.

That means a hotfix flow becomes much less ambiguous:

  • If the bug affects the live store binary, branch from the latest store snapshot.
  • If it’s OTA-compatible, the orchestrator can publish it quickly once merged.
  • If it changes the native fingerprint, the orchestrator forces the full-release path.
  • Either way, main remains the source of truth, not a maze of partially-synced branches.
flowchart LR
    A1(( )) --> A2(( )) --> A3([Release commit]) --> A4(( )) --> A5(( ))
    A3 --> S[store/1.24.0+512 snapshot]
    S --> H{Hotfix needed?}
    H -->|Yes| P[Patch from snapshot]
    P --> M[Merge forward into main]

    classDef commit fill:#fffaf2,stroke:#171717,color:#171717,stroke-width:2px;
    classDef release fill:#f4e1bd,stroke:#171717,color:#171717,stroke-width:2px;
    classDef snapshot fill:#efe6d8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef action fill:#f7f1e8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef accent fill:#f0b35f,stroke:#171717,color:#171717,stroke-width:2px;

    class A1,A2,A4,A5 commit;
    class A3 release;
    class S snapshot;
    class H action;
    class P,M accent;

This is the part people often miss when they hear “trunk-based for mobile.” They imagine recklessness. In practice, a mature setup is more constrained than git-flow. Fewer branches, yes. More automation, definitely. But also stricter compatibility checks, clearer release provenance, and much less room for subjective judgment calls.

If you’re building on Expo today, it’s worth reading the official EAS Update docs and watching the work the Expo team is doing around runtime versions, updates, and workflows. The release maturity story is getting better because the underlying primitives are getting better.

The Expo blog has had a few solid posts in this area too. David Davidov’s write-up on Posh’s move toward continuous delivery is worth a read, and Jacob Clausen’s post on app variants is adjacent in a useful way once you start thinking seriously about release lanes, environments, and binary identity.

Also, credit where it’s due: people building in the Expo/EAS space have made this conversation more concrete over the last couple of years. The tooling is better, the operational model is clearer, and mobile teams finally have a credible path away from branch-heavy release rituals.

Part 2 is where the rest of the safety story picks up: test gates, feature-flag discipline, and hotfix routing without turning main into a panic lane.

Further reading:

Next: how we keep a pipeline this fast from being reckless — the test gate, feature-flag discipline, and the hotfix routing logic that decides exactly which branch a fix belongs on.

원문에서 계속 ↗