Skip to content

InsightsDevOps

Trunk-based development: keeping main releasable

How short-lived branches, feature flags and enforced checks let a team merge to main many times a day without breaking what ships.

Published December 5, 20235 min read

Long-lived branches feel safe. Work is isolated, nothing reaches main until it is finished, and nobody else is disturbed. The cost arrives later, at merge time: two weeks of divergence, a conflict in a file three people changed, and an integration bug that only exists in the combination. The longer a branch lives, the more of its risk is deferred to the moment it lands.

Trunk-based development moves that risk forward and makes it small. Everyone integrates into one branch, main, at least daily. Main is always releasable. The practices around it exist to make both of those things true at once.

What it actually requires

Trunk-based development is not "everyone pushes to main". Most teams that do it well still use pull requests. What changes is their size and lifetime:

  • Branches live for hours, at most a day or two. A branch that is still open on Friday is a warning sign, not a plan.
  • Unfinished work merges anyway, switched off. Feature flags separate deploying code from releasing behavior.
  • Main is protected by automation, not by caution. Required checks decide whether a change can merge. Reviewers decide whether it should.
  • A broken main is the team's top priority. Fix it or revert it within minutes. Revert first if the fix is not obvious.

DORA's research has consistently associated trunk-based development with higher delivery performance, but the mechanism is simple: small changes are easier to review, easier to test and easier to undo.

short branchrequired checksmerge to maindeploy, flag offrelease by flag
fig. 1 — small changes reach main quickly and ship behind a flag

Keeping branches short

The hard part is not tooling. It is learning to split work so that each piece is safe to merge on its own. A few techniques do most of the work:

  • Expand, then contract. Add the new database column, write to both, migrate readers, then remove the old column, each as its own change.
  • Branch by abstraction. Put an interface in front of the code being replaced, merge that, then build the new implementation behind it.
  • Merge the dark parts first. New endpoints, new tables and new classes that nothing calls yet can merge before the code that uses them.
  • Stack small pull requests when one change depends on another, rather than growing one large one.

A useful team agreement is a soft limit on pull request size, such as a few hundred changed lines excluding generated files. Treat it as a prompt to split, not a rule to argue about.

Feature flags

Flags are what make it safe to merge code that is not ready for users. Keep them boring:

src/checkout/summary.ts
import { flags } from '@/lib/flags'
 
export async function renderSummary(order: Order, user: User) {
  if (await flags.isEnabled('checkout-new-tax-engine', { userId: user.id })) {
    return renderWithTaxEngine(order)
  }
  return renderLegacy(order)
}

The flag service can be LaunchDarkly, Unleash, Flagsmith, a cloud provider's feature configuration or a table in your own database. What matters more is discipline:

  • Every flag has an owner and a removal date. Release flags should be gone within weeks of full rollout.
  • Test both paths while the flag exists. CI should run with the flag on and off for anything non-trivial.
  • Do not nest flags. Two flags in one code path means four behaviors, and nobody tests all of them.
  • Separate release flags from operational kill switches. Kill switches are meant to stay. Release flags are debt from the day they are created.

Protecting main

On GitHub, use a ruleset or branch protection rule on main with these settings:

SettingWhy
Require a pull request before mergingEvery change gets a second look and a CI run
Require status checks to passBuild, unit tests and lint must be green
Require branches to be up to dateChecks ran against the code that will actually land
Require linear historySquash or rebase merges keep history readable and reverts clean
Block force pushes and deletionNobody can rewrite main, including administrators
Require one approval, dismiss stale approvalsReview applies to the final diff, not an earlier one

"Require branches to be up to date" becomes a bottleneck when many people merge per hour: each merge forces everyone else to rebase and rerun CI. A merge queue solves this. GitHub's merge queue, GitLab merge trains and tools like Mergify test each change on top of the changes queued ahead of it, and merge only what passes.

.github/workflows/ci.yml
on:
  pull_request:
  merge_group:
    types: [checks_requested]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
      - run: npm test

The merge_group trigger is easy to forget. Without it, required checks never report for queued changes and the queue stalls.

Making checks fast enough

Required checks only work if they finish while the author is still paying attention. Once a pull request takes 40 minutes to go green, people batch work into larger changes to avoid the wait, and the model erodes.

  • Split checks into required and advisory. Unit tests, type checks and lint block the merge. Long end-to-end suites can run after merge on main, with a fast revert if they fail.
  • Run only what changed in a monorepo, using the build tool's affected-target detection (Nx, Turborepo, Bazel, Gradle).
  • Cache dependencies and build outputs between runs.
  • Quarantine flaky tests immediately. A flaky required check teaches everyone to click "re-run" without reading the failure.

Releasing from main

With main always releasable, releases become a decision about timing, not a stabilization effort. Two common shapes:

  • Continuous deployment: every merge to main deploys to production, with progressive rollout and automatic rollback on alerts.
  • Release from a tag: main deploys to staging continuously, and production deploys are cut from a tagged commit on main.

If you need to support older versions, short-lived release branches cut from main are fine. Fixes land on main first and are cherry-picked to the release branch, never the other way around.

Where to start

  1. Measure current branch lifetime and pull request size for a month. The numbers are usually the most persuasive argument.
  2. Turn on protection for main with required checks, linear history and no force pushes.
  3. Get the required checks under ten minutes, moving slow suites to post-merge if needed.
  4. Introduce a flag service and use it for the next feature that would otherwise need a long branch.
  5. Agree on a revert-first rule for a broken main, and practice it once.
  6. Add a merge queue when rebasing to stay up to date becomes the main source of waiting.