How We Keep a Trunk-Based Pipeline From Being Reckless

작성자

카테고리:

← 피드로
DEV Community · Soham Mondal · 2026-08-26 개발(SW)
Cover image for How We Keep a Trunk-Based Pipeline From Being Reckless

Soham Mondal

Part 1 covered the routing decision: the orchestrator checks the native fingerprint and decides whether a release can go out as an OTA update or needs a new binary. Useful, but incomplete. A gate that answers “is this runtime-compatible” says nothing about whether the change is actually good.

That is the part people get wrong when they hear “we ship from main.” They picture chaos. What they should picture is a stricter PR gate, heavier feature-flag discipline, and a hotfix path that is more explicit than the git-flow version ever was.

If every merge to main can theoretically reach production in minutes, then the safety net cannot be “we’ll probably notice something during the release train.” The safety has to live inside the pipeline.

Trunk-based is not lower process
It is lower branch ceremony and higher automation discipline. If your test gate, flags, and hotfix routing are weak, trunk-based will expose that fast.

Someone walking a tightrope where the safety net is woven into the rope itself

The PR gate

Every pull request into main runs through the same automated gate before it is mergeable: typecheck, lint, test suite, and end-to-end checks. None of that is negotiable. That is the floor.

The exact tools matter less than the contract. A PR should not become releasable because a reviewer felt good about it. It should become releasable because the same mechanical checks passed every other healthy change before it.

That gate usually looks boring, which is good:

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run typecheck

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test -- --runInBand

  e2e:
    runs-on: ubuntu-latest
    needs: [typecheck, lint, test]
    steps:
      - uses: actions/checkout@v4
      - run: npm run e2e

Enter fullscreen mode Exit fullscreen mode

A checkpoint gate with three checked posts and one still under construction

Nothing exotic under the hood: ESLint, Jest, React Native Testing Library, whatever your E2E layer is, plus local hooks so the obvious failures get caught before CI has to. Popular, boring, well-documented tooling on purpose. The value is not in a fancy test stack. The value is that main has a contract.

The contract is simple: if a change lands, it is expected to be shippable.

That does not mean every merge must go live instantly. It means the system should not depend on a separate stabilization branch to discover obvious breakage later.

Feature flags are the real safety valve

Here is the rule that makes OTA-from-main survivable at all: shipping code and releasing a feature are two different actions.

A merge can put code on devices within minutes. Whether users can actually hit that code path is a separate decision, controlled remotely.

That decoupling is what makes trunk-based development workable for product teams. Nobody has to merge at the exact perfect moment. “Merged” does not have to mean “visible.”

Deploy vs release
Deploy means the code is present on user devices. Release means users can actually experience it. In a healthy mobile pipeline, feature flags let you separate those two decisions.

const enabled = await featureFlags.isEnabled('new_checkout_flow', {
  userId,
  platform,
  appVersion,
});

if (enabled) {
  return <NewCheckoutFlow />;
}

return <LegacyCheckoutFlow />;

Enter fullscreen mode Exit fullscreen mode

Anything not ready to be user-visible goes behind a flag before it goes near main: half-finished work, risky rewrites, migrations, pricing experiments, staged onboarding changes, whatever.

That is not a code review nit. That is release architecture.

I’ve used both Firebase Remote Config and Flagsmith for this in production. Different tradeoffs, same core job. Firebase is convenient if you’re already deep in that ecosystem and need straightforward targeting. Flagsmith gives you a bit more explicit control once flags start becoming part of how the team routinely operates. Either is fine. What matters is not the vendor. What matters is whether the team actually treats flags as the release valve instead of an occasional convenience.

One simple pattern that ages well is keeping the risky decision point in one place:

export async function shouldRenderNewCheckout(ctx: {
  userId: string;
  platform: "ios" | "android";
  appVersion: string;
}) {
  return featureFlags.isEnabled("new_checkout_flow", {
    userId: ctx.userId,
    platform: ctx.platform,
    appVersion: ctx.appVersion,
  });
}

Enter fullscreen mode Exit fullscreen mode

That looks small, but centralizing flag evaluation pays off later when you need percentage rollouts, kill switches, environment-aware defaults, or analytics around who saw what.

A wall light switch with a blank tag hanging off it

The same fingerprint gate, wearing a different hat

The fingerprint check from Part 1 is not just an OTA router. It is also what makes hotfix routing sane.

Because the gate compares against the exact binary currently in the store, the system always knows one thing with certainty: whether main could safely reach the devices that are live right now, or whether it has drifted natively since the last binary release.

That single fact matters a lot during an incident. It answers the first question fast: can we fix this on top of the current trunk, or do we need to cut from the live snapshot?

Hotfixing without touching main

A production fire needs a fix routed to wherever users actually are. After a few routine OTAs, that is often not the same commit as the last full store release. So the pipeline keeps immutable reference points for what is live.

Two protected snapshots exist for exactly this:

  • One pinned at the last full store release.
  • One pinned at the last successful OTA, if a newer one shipped since.

A hotfix branches off whichever of those two is actually live, ships from there, and only afterward gets merged or cherry-picked forward into main.

Neither snapshot branch is a development branch. They are frozen markers. That is what keeps a hotfix from accidentally dragging in unrelated work that landed on main after the last production-safe point.

In practice, the routing logic is this plain:

flowchart TD
    A([Production issue found]) --> B{Does main match the live runtime?}
    B -->|Yes| C[Fix on main]
    C --> D{Fingerprint still matches?}
    D -->|Yes| E[Publish OTA]
    D -->|No| F[Cut full release]
    B -->|No| G[Branch from live snapshot]
    G --> H[Patch only what is needed]
    H --> I{Fingerprint matches snapshot runtime?}
    I -->|Yes| J[Ship hotfix OTA]
    I -->|No| K[Build and submit hotfix binary]
    J --> L[Merge forward into main]
    K --> L
    E --> M([Users receive fix])
    F --> M
    L --> M

    classDef start fill:#f4e1bd,stroke:#171717,color:#171717,stroke-width:2px;
    classDef normal fill:#fffaf2,stroke:#171717,color:#171717,stroke-width:2px;
    classDef decision fill:#efe6d8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef action fill:#f7f1e8,stroke:#171717,color:#171717,stroke-width:2px;
    classDef release fill:#f0b35f,stroke:#171717,color:#171717,stroke-width:2px;

    class A,M start;
    class B,D,I decision;
    class C,G,H,L normal;
    class E,F,J,K release;

There is no heroism in this. That is why it works.

The whole shape of it

None of these pieces does much on its own.

The PR gate keeps obvious breakage out.
Feature flags decouple deploy from release.
The fingerprint check protects the native/runtime boundary.
Snapshot branches make hotfix routing explicit instead of improvisational.

Together, they replace what release managers and release trains used to do manually: catch bad code before it ships, control exposure separately from deployment, know whether trunk can safely reach production, and know exactly where a hotfix belongs.

That is the trade this series is really about. Less branch ceremony. More operational honesty. Faster feedback. Fewer release superstitions.

There’s a fifth piece coming eventually — testing an OTA update against the real production binary before any real user sees it, without a second build. That’s a post of its own once it’s actually shipped.

원문에서 계속 ↗