In a nutshell
Trunk-based development (TBD) is a simple rule with big consequences: everyone works on one shared branch — the “trunk”, usually main — and merges small changes into it every single day. Work that is not finished yet is hidden behind an on/off switch (a feature flag), so the trunk is always in a shippable state even while half-built features live inside it. That is the whole idea. The rest is discipline and tooling to make it safe.
The mental model: think of a team writing one document together in Google Docs versus everyone emailing around their own copy of a Word file and trying to reconcile all the copies at the end of the month. The Word-file approach is GitFlow — long-lived develop, feature/*, and release/* branches that quietly drift apart for weeks and then collide painfully when you finally merge. The single live document is trunk-based development: you see everyone’s small edits continuously, conflicts are tiny because they are caught the same day, and a paragraph that is not ready yet is left as a hidden draft (the feature flag) rather than kept in a separate file nobody else can see.
Why should a beginner care? Because the branching model you pick decides whether continuous delivery is even possible. You cannot ship every commit to production automatically if your commits are stranded on branches that take two weeks to merge. TBD is the boring, load-bearing precondition that makes fast, safe, frequent releases achievable — and this lesson is the migration path from GitFlow to TBD, not a manifesto.
Level: Intermediate · Time: ~31 min
Prerequisites — know this first:
- Comfortable with Git branches, merge, and rebase — how a branch forks and how conflicts happen (Git deep dive).
- Basic CI/CD literacy: what a pipeline, a build, and a deploy are.
- A first exposure to feature flags helps but is not required (OpenFeature flag platform).
After this lesson you will be able to:
- Explain the difference between GitFlow’s long-lived branches and TBD’s short-lived branches (or commit-straight-to-trunk), and why branch age is the enemy.
- Write a branch-protection and merge-queue policy that keeps
mainalways releasable. - Hide unfinished work with feature flags and branch-by-abstraction so large changes ship in small, green increments.
- Decouple deploy from release and roll out (or roll back) behaviour by flipping a flag, not shipping a build.
- Evolve databases and APIs under continuous integration using expand/contract.
- Run a staged GitFlow-to-TBD migration and prove it worked with DORA metrics.
The diagram traces one change through a trunk-based pipeline left to right: a short-lived branch (or a branch-by-abstraction seam) opens one small PR, a merge queue re-tests it against the current tip of main before it lands, the single always-green trunk deploys to production continuously as inert dark code, and a feature-flag flip — not a deploy — is what actually releases the behaviour to users, with the same switch doubling as an instant kill switch.
GitFlow taught a generation of teams to fear main. Release branches, develop branches, hotfix branches, and weeks-old feature branches all promise safety and instead deliver merge hell, integration that happens too late to fix cheaply, and a main nobody trusts to ship. Trunk-based development (TBD) inverts the model: everyone integrates small changes into one shared branch many times a day, incomplete work hides behind flags, and main stays releasable at every commit. This guide is the migration path, not the manifesto. We will define the branching policy, hide unfinished work, decouple deploy from release, keep the build green, and prove with metrics that lead time actually dropped.
What trunk-based development actually means
Before the migration mechanics, pin down the definition, because “trunk-based” is widely misheard as “no branches” or “push broken code to main.” It is neither.
Continuous integration in the original sense. The phrase continuous integration did not originally mean “a CI server runs your tests.” It meant developers integrate their work into the shared mainline continuously — at least once a day, each. TBD is CI in that literal, original sense: the trunk is where integration happens, and it happens constantly. The build server is just the safety net that keeps that constant integration honest.
The two flavours of TBD. There is no single ceremony; there are two, and both are trunk-based:
- Commit straight to trunk. Solo developers and small, co-located teams push directly to
main, guarded by fast local checks (pre-commit hooks, a quick test run) and a fast CI build on trunk. No pull-request overhead. This is the purest form and works beautifully up to a handful of trusted committers. - Short-lived feature branches (scaled TBD). Teams that need code review, branch protection, or regulatory sign-off use branches — but each branch exists for one small change, lives hours to at most a day, is merged through a PR (ideally via a merge queue), and is deleted on merge. It is still trunk-based because the branch never lives long enough to diverge, and integration to the one trunk still happens daily.
The invariant both share: nothing lives long enough to drift, and everyone integrates to a single trunk every day. The moment a branch survives a week, you are back in GitFlow no matter what you call it.
Merge daily is the rule, not the aspiration. If a piece of work cannot be integrated to trunk within a day, it is too big. You have exactly two tools to fix that — decompose it into smaller mergeable slices, or hide the incomplete part behind a feature flag or an abstraction (both covered below). You do not reach for a long branch. That single constraint is what forces the small-batch habit that makes everything downstream cheap.
Why TBD is the precondition for continuous delivery. Continuous delivery (CD) means every commit is a potential release candidate and the path from commit to production is push-button or automatic. That is only possible if the mainline is always in a releasable state. Long-lived branches make that impossible by construction: either your finished features are stranded on branches (so main lags reality) or big merges land rarely and destabilise main when they do. A single, always-green trunk is the only thing that produces a continuous stream of shippable candidates. So the causality is strict and worth memorising: no trunk-based development, no real continuous delivery. Everything else in a CD pipeline — automated tests, progressive rollout, instant rollback — is built on top of a trunk you can trust to ship.
| Dimension | GitFlow / long-lived branches | Trunk-based development |
|---|---|---|
| Long-lived branches | main + develop + release/* + many feature/* |
main only |
| Branch lifetime | days to weeks | hours to a day |
| Integration cadence | at merge time (rarely) | continuous — at least daily per developer |
| Unfinished work lives | on a private branch | on trunk, behind a flag or abstraction |
| Merge pain | grows superlinearly with age × concurrency | trivial (small and frequent) |
State of main |
lags production, or lags develop |
releasable at every commit |
| CD-ready? | no — no stream of shippable candidates | yes — every commit is a release candidate |
1. Why long-lived branches break continuous delivery
The core failure of GitFlow is deferred integration. A feature branch that lives two weeks accumulates conflicts against every other branch merged in that window. The cost of a merge conflict grows superlinearly with branch age and the number of concurrent branches, because each branch must reconcile against the union of all changes since it forked. Teams respond by merging less often, which makes each merge bigger and more dangerous, a vicious cycle.
The second failure is that develop and main diverge. develop accumulates work that is “done” but not released; main reflects production. The delta between them is unreleased risk you cannot see. When you finally cut a release branch, you are integrating a batch of weeks-old changes, and the bugs you find are the most expensive kind: late, batched, and hard to bisect.
Trunk-based development fixes both by construction. Branches live hours, not weeks, so conflicts stay trivial. There is one trunk (main), so there is no divergence to reconcile. The price you pay is discipline: you must decompose work into small mergeable increments and hide anything not ready behind a flag. That trade is almost always worth it, and the rest of this article is how to pay it cleanly.
The DORA research is unambiguous here: teams with fewer than three active branches, branches living less than a day, and no code freezes have materially higher software delivery performance. TBD is not a style preference; it is correlated with the outcomes you are being measured on.
2. Defining the branching policy
Write the policy down. Ambiguity is what killed your last “we should merge more often” initiative. A workable TBD policy has four rules.
- One protected trunk.
mainis the only long-lived branch. Nodevelop, no permanentrelease/*. - Short-lived branches. A branch exists for one small change and is deleted on merge. Target under a day; hard-cap at a few days.
- Small PRs. Cap diff size so review is fast and conflicts are rare. A few hundred changed lines is a sane ceiling; flag the outliers, do not block them outright.
- Serialized integration. All merges go through a merge queue that re-tests against the current tip of
mainbefore landing.
Encode the non-negotiables in branch protection so policy does not depend on memory. With the GitHub CLI:
gh api --method PUT \
repos/acme/payments-api/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": {
"strict": true,
"contexts": ["ci/build", "ci/unit", "ci/lint"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"required_linear_history": true,
"allow_force_pushes": false,
"allow_deletions": false,
"restrictions": null
}
JSON
required_linear_history plus squash-merge gives you a readable trunk where every commit is a complete, reviewed change you can revert atomically. That property is what makes “always releasable” enforceable later.
For PR size, do not rely on reviewer goodwill. Add a CI gate that warns past a threshold so large PRs are a deliberate, visible choice:
# .github/workflows/pr-size.yml
name: pr-size
on: pull_request
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check diff size
run: |
BASE="origin/${{ github.base_ref }}"
git fetch origin "${{ github.base_ref }}" --depth=1
CHANGED=$(git diff --shortstat "$BASE"...HEAD \
| grep -oE '[0-9]+ insertion|[0-9]+ deletion' \
| grep -oE '[0-9]+' | paste -sd+ - | bc)
CHANGED=${CHANGED:-0}
echo "Changed lines: $CHANGED"
if [ "$CHANGED" -gt 600 ]; then
echo "::warning::PR changes $CHANGED lines; consider splitting (>600)."
fi
The merge queue is the load-bearing piece. GitHub’s native merge queue takes each approved PR, rebases it onto the current main, runs the required checks against that combination, and only then fast-forwards. This kills the classic TBD hazard where two PRs each pass CI in isolation but break when combined.
gh api --method PATCH repos/acme/payments-api \
-f allow_merge_commit=false \
-f allow_squash_merge=true \
-f allow_rebase_merge=false
# Then enable "Require merge queue" in the branch protection ruleset UI,
# and require the merge_group event check to pass.
Make CI run on the merge_group event so the queue actually re-validates the combined result:
on:
pull_request:
merge_group:
3. Hiding incomplete work behind feature flags
Short-lived branches only work if you can merge unfinished work safely. The mechanism is the feature flag: code reaches main and ships to production, but the new path stays dark until you turn it on. The flag check should be a single, cheap, centralized call, not scattered booleans.
// flags.ts -- thin wrapper over your provider (OpenFeature-compatible)
import { OpenFeature } from "@openfeature/server-sdk";
const client = OpenFeature.getClient();
export async function isEnabled(
flag: string,
ctx: { userId?: string; tenantId?: string } = {},
): Promise<boolean> {
return client.getBooleanValue(flag, false, ctx);
}
Note the default is false. A flag that defaults open is a flag that ships unfinished work the moment your flag service has a blip. Dark-by-default is the only safe posture for release flags.
In the request path the incomplete feature is a branch on the flag:
if (await isEnabled("checkout-v2", { tenantId })) {
return checkoutV2(cart);
}
return checkoutV1(cart);
For changes too large to wrap in a single if – swapping a payments gateway, replacing a persistence layer – use branch-by-abstraction instead of a long-lived branch. Introduce an interface, route current traffic through the old implementation behind it, then build the new implementation incrementally on trunk. Every step is a small, green, merged PR.
interface PaymentGateway {
charge(amount: Money, token: string): Promise<ChargeResult>;
}
// Step 1: wrap the existing code, no behavior change.
class LegacyGateway implements PaymentGateway { /* current impl */ }
// Step 2..N: build the new one over several PRs, fully tested,
// never reached in prod until the flag flips.
class StripeGateway implements PaymentGateway { /* new impl */ }
function gatewayFor(ctx: Ctx): PaymentGateway {
return ctx.flags.stripeMigration ? new StripeGateway() : new LegacyGateway();
}
The abstraction is the seam that lets a multi-week change live on trunk as a series of one-day changes. When the new implementation is proven, you delete the flag and the legacy class. The interface can stay or go.
Feature flags are not all the same, and conflating the types is where flag debt starts. Keep the taxonomy straight:
| Flag type | Purpose | Lifetime | Default |
|---|---|---|---|
| Release (a.k.a. dark-launch) | hide unfinished work so it can ship dark on trunk | days to weeks — delete after 100% | false |
| Experiment | A/B or multivariate test, decided by metrics | length of the experiment | control |
| Ops / kill switch | turn a subsystem off under load or incident | long-lived, deliberately permanent | on (safe) |
| Permission / entitlement | gate a feature by plan, tenant, or role | permanent — it is the product | per-entitlement |
TBD leans hardest on the release flag, and that is precisely the type you must delete aggressively. Ops and permission flags are meant to live; release flags are debt with a due date (see section 7).
4. Decoupling deploy from release
The mindset shift that makes TBD safe: deploy (push a binary to an environment) is not release (expose behavior to users). Once flags gate behavior, you deploy main continuously and release independently by flipping flags. A deploy carrying dark code is low-risk because nothing user-visible changed.
This is what lets you merge to main and deploy a dozen times a day without a dozen risky launches. The pipeline ships the artifact; the flag service governs exposure. Progressive rollout becomes a percentage on the flag, not a branching exercise:
# Release to 5% of tenants without any deploy.
curl -sS -X PATCH "$FLAGS_API/flags/checkout-v2" \
-H "Authorization: Bearer $FLAGS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"rollout": {"strategy": "percentage", "value": 5}}'
Kill switch and rollout share one control plane, so reverting a bad release is flipping a flag in seconds, not reverting a commit and waiting for a build. That separation is the entire point: it decouples the speed of integration from the risk of exposure.
This is also where TBD connects to the broader family of progressive-delivery techniques — percentage flags, canary, and blue-green all decouple exposure from deploy in different ways, and they compose (deployment strategies: rolling, blue-green, canary, flags). A flag decides who sees the new path; a canary decides how much traffic hits the new binary. Used together, you deploy dark to everyone and then release to 1% of users on the new code path.
5. Keeping the build green with pre-merge checks
In TBD, a red main blocks everyone, so protecting trunk’s greenness is the highest-leverage investment. Two disciplines do most of the work: fast, mandatory pre-merge checks, and the serialized merge queue from step 2.
Keep the pre-merge suite fast (target under ten minutes) or developers will batch changes to avoid the wait, defeating the model. Shard tests to hit the budget:
jobs:
unit:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4 --ci
The queue does the rest: because each PR is tested against the live tip of main before it lands, “passed in isolation, broke on merge” cannot reach trunk. If the queue build fails, that PR is ejected and the others proceed, so one bad change does not stall the line.
When main does break – it will – the team rule is stop the line: no new merges until trunk is green. Fix forward with a tiny PR or revert the offending commit. Because every merge was a small atomic squash, git revert of a single SHA cleanly removes one change:
git revert --no-edit <sha>
git push origin HEAD:refs/heads/revert-<sha>
gh pr create --fill --base main
6. Handling database and API changes under trunk-based flow
Schema and contract changes are where naive TBD bites, because main ships continuously and you cannot ship a migration that the currently-running code cannot tolerate. The discipline is expand/contract (a.k.a. parallel change): never make a breaking change in one step.
Take renaming users.fullname to users.full_name:
Expand. Add the new column; keep the old one. Backfill. Deploys in this window run code that still reads the old column, and that is fine.
ALTER TABLE users ADD COLUMN full_name text;
UPDATE users SET full_name = fullname WHERE full_name IS NULL;
Migrate. Ship code that writes both columns and reads the new one, gated by a flag so you can roll it back instantly. Several small PRs, all green on trunk.
Contract. Once every running instance uses full_name and the flag is fully on, drop the old column in a later deploy.
ALTER TABLE users DROP COLUMN fullname;
Each phase is independently deployable and backward-compatible with the code running beside it, which is exactly the invariant TBD requires. The same pattern governs API evolution: add v2 fields additively, dual-write, migrate consumers, then remove v1 only after telemetry shows it is unused. Never couple a breaking schema change to the deploy that depends on it; the gap between deploy and full rollout is where rollbacks live.
For long backfills on large tables, do them in batches outside the request path so a migration never holds a lock long enough to stall trunk’s deploys:
-- run repeatedly until 0 rows affected
UPDATE users SET full_name = fullname
WHERE full_name IS NULL
LIMIT 5000;
7. Retiring stale flags and preventing flag debt
Feature flags are debt with a coupon. A release flag that has been at 100% for a month is now dead branches, untested fallback code, and conditional complexity that confuses every future reader. TBD without flag hygiene rots into a different mess than GitFlow, but a mess all the same.
Make flag debt visible and time-boxed:
- Tag release flags as temporary at birth, with an owner and an expiry date in code or config.
- Alert on stale flags. A flag fully rolled out (or fully off) for N days is a cleanup ticket.
- Delete the flag and the dead branch together, not just the flag check.
A simple linter catches expired temporary flags in CI:
# fail the build if any temporary flag is past its remove-by date
jq -r '.flags[]
| select(.temporary == true)
| select(.removeBy < (now | strftime("%Y-%m-%d")))
| .key' flags.json | while read -r f; do
echo "::error::Flag '$f' is past its removeBy date; remove it."
FAIL=1
done
[ -z "$FAIL" ] || exit 1
Removing a flag is itself a small trunk-based change: delete the if, delete the now-unreachable branch, delete the flag definition, ship. Treating cleanup as ordinary work, not a someday-project, is what keeps the flag count bounded.
8. Rollout sequencing and metrics to prove it worked
Do not flip the whole org to TBD on a Monday. Sequence it:
- Pilot one team that already ships frequently. Stand up the merge queue, flag SDK, and size gate for their repo only.
- Stabilize the trunk discipline – green-build culture, stop-the-line, flag hygiene – before scaling.
- Template the setup (branch protection, queue config, flag wrapper) so the next team adopts in an afternoon.
- Roll out team by team, retiring
developandrelease/*branches as each migrates.
This staged, team-by-team rollout is a strangler migration: you do not big-bang cut over from GitFlow, you stand the new model up beside the old and let it slowly replace it, repo by repo, until the long-lived branches have nothing left to do and are retired. Each team’s develop is strangled the day its last in-flight feature lands on trunk.
Prove the migration with the DORA four, tracked before and after. Lead time for changes (commit to production) and deployment frequency should improve as branches shrink; change-failure rate and time-to-restore tell you it did not improve at the cost of stability (DORA metrics deep dive). If your CI tags deploys, you can compute lead time straight from git and your deploy log – here, the p50 hours from authorship to deploy:
git log --since="30 days ago" --pretty="%H %aI" main | while read -r sha authored; do
deployed=$(grep "$sha" deploys.log | awk '{print $2}')
[ -z "$deployed" ] && continue
python3 -c "import sys,datetime as d; a=d.datetime.fromisoformat('$authored'); \
p=d.datetime.fromisoformat('$deployed'); print((p-a).total_seconds()/3600)"
done | sort -n | awk '{v[NR]=$1} END{print "p50 lead hours:", v[int(NR/2)]}'
When release branches are still legitimate
“No long-lived release/*” is right for SaaS, where exactly one version runs in production and you always roll forward. But a whole class of products ships versioned artifacts that customers install and run on their own timeline — libraries, CLIs and SDKs, mobile apps gated by store review, firmware, and on-prem or LTS enterprise software with several supported versions at once. For these, a release branch is not GitFlow relapse; it is a legitimate stabilisation line, if you keep two rules.
Rule one: cut late and short. You branch release/2.4 from a green trunk at release time, not weeks ahead, and it lives only as long as that version is supported. It is a read-mostly stabilisation branch, not a place you develop.
Rule two: fixes flow trunk-first, then backport. A bug fix always lands on main first, then is cherry-picked back to the supported release line — never the reverse. Trunk stays the single source of truth; the release branch only receives backports. This is the exact opposite of GitFlow, where develop was the integration point and main/release fed forward into it.
# Fix lands on trunk first, then is backported to the supported release line.
git switch main
git commit -m "fix: null-deref in retry path" # trunk is the source of truth
git switch release/2.4
git cherry-pick <sha> # backport only; never develop here
| Product shape | Ship model | Branching |
|---|---|---|
| SaaS / web app (one live version) | deploy trunk continuously | no release branch; flags gate exposure |
| Library / CLI / SDK (semver artifacts) | publish tagged versions | short release/* cut at the tag; backport from trunk |
| Mobile app (store review lag) | release train to the store | tag a green SHA or short RC branch; fix forward on trunk, cherry-pick to RC |
| On-prem / LTS enterprise (many supported versions) | patch each supported line | one maintenance branch per supported version, fed only by backports |
The tell that separates a healthy release branch from a GitFlow one: direction and lifetime. Healthy release branches are cut late, live only while their version is supported, and receive only backports. A GitFlow release/* is permanent, is developed on, and diverges. If you find yourself writing new features on release/2.4, you have relapsed.
Enterprise scenario
A payments platform team I worked with ran 40 microservices on GitFlow with a weekly release train. Their constraint was hard: PCI-DSS required a documented, auditable change approval on everything reaching the cardholder-data environment, and their security team read “auditable” as “long-lived release branch with a sign-off.” That belief was the real blocker, and it was making their lead time worse, not their compliance better.
We kept the audit requirement and dropped the long-lived branch. The merge queue became the control point. Branch protection mandated one approving review and all required checks, and GitHub’s API exposes the reviewer, the commit, and the merge timestamp – a complete, immutable approval record per change. We piped the pull_request review and merge events into the SIEM, so every change to the regulated service had a queryable approval trail without a release branch existing at all. The auditors accepted per-commit review records as stronger evidence than a batched branch sign-off, because each control mapped to exactly one change.
The seam that made it safe was deploy/release separation. New behavior shipped dark and was released by flipping a flag, and the flag-change API was itself logged as an audited control. A release became a flag flip with its own approval record, decoupled from the continuous deploys of dark code.
# enforce per-change approval the auditors accept, in the ruleset
required_pull_request_reviews:
required_approving_review_count: 1
dismiss_stale_reviews: true
require_code_owner_reviews: true # CODEOWNERS gates the regulated paths
Lead time for changes to the regulated services dropped from roughly seven days to under a day within a quarter, change-failure rate fell because batches shrank, and the audit posture got stronger because evidence moved from coarse branch sign-offs to fine-grained per-commit records.
Going deeper
Once the basics are in place, the interesting problems are at the edges — where the merge queue, the flag runtime, the schema, and the supply chain start to interact. This section is for the reader who already runs TBD and wants the failure modes.
Merge-queue internals and throughput. A naive queue that rebases and fully re-tests one PR at a time is safe but slow: at 30 PRs a day and a 12-minute build, the queue is the bottleneck. Real queues (GitHub’s merge queue, GitLab’s merge trains) run speculatively — they optimistically build the top N queued PRs stacked together, and if the combined merge_group checks pass, all N land at once. If the batch fails, the queue bisects to find the culprit, ejects only that PR, and rebuilds the rest. This trades CI machine-time for latency while preserving the core guarantee: every PR is tested against the exact tip it will land on. Tune batch size against your CI cost and flake rate. The GitLab analogue: each merge-train MR builds on the result of the ones ahead of it, so a failure drops that MR and rebuilds the train behind it.
The flaky-test tax. In TBD a single flaky test wedges the whole line, because everyone merges through the same queue. The tempting fix — blanket “just re-run it” — is corrosive: it hides real race conditions and trains the team to ignore red, which is the one thing TBD cannot afford. Instead, quarantine a known flake into a non-blocking lane, track flakiness rate as a first-class metric (a test failing 2% of runs on unchanged code is a bug, not noise), and treat chronic flakes as stop-the-line-adjacent work. Green has to mean green or the entire model loses its load-bearing property.
Feature-flag evaluation under the hood. Flag SDKs evaluate either locally (rules are streamed to the process and evaluated in-memory — sub-microsecond, and resilient if the flag service blips) or remotely (a network call per evaluation — simpler, but adds latency and a hard dependency). Either way, the mandatory default value is your circuit breaker: on any error the call returns the known-safe value, never null. Stickiness comes from hashing flagKey + a stable targeting key into a bucket, so a user does not flip variants between requests. The hard part is combinatorial: N independent boolean flags define 2^N possible runtime states, and you cannot test them all — so you test the specific combination you actually ship and you keep the live release-flag set small. OpenFeature standardises this evaluation model across vendors.
Dark code is real code in the artifact. The disabled branch is compiled and shipped — it is in your binary, your container image, and your SBOM whether the flag is on or off. Two consequences people miss. First, security: dark code is part of your attack surface and must pass the same SAST/SCA gates; a feature flag cannot gate a CVE out of a dependency that the dark path pulls in, and a flag left server-evaluable can sometimes be forced on by a crafted request. Second, supply chain: any new secret or credential the dark path needs is deployed the moment the code lands, long before release — scope it tightly and rotate on the same schedule as everything else. “It’s behind a flag” is a release control, not a security control.
Expand/contract at scale. On large, hot tables a plain ALTER takes a lock that stalls trunk’s continuous deploys. Use online-schema-change tooling — gh-ost or pt-online-schema-change on MySQL, CREATE INDEX CONCURRENTLY and careful column adds on Postgres — and remember the mixed-version window: during any rollout, code version N and N+1 run simultaneously against one schema, so every intermediate schema must satisfy both. Each of the three phases (expand, migrate, contract) is its own independently rollback-safe deploy.
# representative — online, lock-avoiding column add on a hot table
gh-ost \
--host=db.internal --database=app --table=users \
--alter="ADD COLUMN full_name text" \
--allow-on-master --execute
Release trains versus pure continuous. Some orgs still batch to a cadence — a weekly train, or one forced by mobile app-store review latency. TBD is fully compatible: the train simply tags a green trunk SHA; it is not a long-lived branch, and trunk stays always-releasable between trains. A coordinated multi-service release picks a set of trunk SHAs, never a shared release branch.
Monorepo TBD and bisect economy. One trunk for many services keeps commits small and atomic, which keeps git bisect cheap — a regression is a precise git bisect run away because each commit is one reviewable change. Keep pre-merge fast with an affected-targets build graph (Bazel, Nx, Turborepo) so a PR only builds what it touched, and use CODEOWNERS to scope review to the changed paths.
git bisect start
git bisect bad HEAD
git bisect good <last-known-good-sha>
git bisect run ./ci/repro.sh # small squashed commits → a precise culprit
git bisect reset
Common beginner mistakes
“Trunk-based means no branches — just push broken code to main.” Neither half is true. Short-lived branches are perfectly trunk-based; what you never do is commit broken code. You commit incomplete-but-inert code hidden behind a false-default flag. The right mental model is small, green, and dark — every commit builds and passes, and the unfinished part is simply switched off.
“Feature flags are just if-statements — no big deal.” A flag is config with a lifecycle: an owner, a default, a type, tests, and an expiry. Treated casually they breed dead branches and combinatorial risk. Treated as debt with a due date — temporary, owned, and deleted on time — they are the safest tool you have.
“We’ll adopt TBD by telling everyone to merge more often.” Exhortation without the rails fails every time. You need the enablers first: a fast pre-merge suite, a merge queue, a feature-flag SDK, and a PR-size gate. Build the rails, pilot one team, then scale. Culture follows tooling here, not the other way round.
“main is protected, so it can’t break.” Protection reduces breakage; it does not eliminate it. Two PRs that each pass CI alone can still break when combined — a semantic conflict a text merge cannot see. The merge queue exists precisely for this: it re-tests each PR against the live tip before it lands. And when trunk does break, stop the line until it is green.
“Trunk-based development means we can’t do versioned releases.” You can. Cut a short-lived release/* at the tag and backport fixes from trunk. The confusion is between no long-lived development branches (true) and no release branches ever (false). Versioned and on-prem products absolutely use release branches — trunk-first, backport-only.
“Default the flag to on so the feature works in dev.” A default-open flag ships unfinished work to production the instant your flag service has a blip. Default to false and enable per environment or per context. Dark-by-default is the only safe posture for a release flag.
“This change is too big to do without a long branch.” Almost never true. Reach for branch-by-abstraction: introduce a seam (an interface), route today’s traffic through the old implementation, and build the new one over many small green PRs on trunk. A multi-week change becomes a series of one-day changes.
Practice challenges
Work these in order; each has a worked solution. They escalate from a five-minute measurement to a full zero-downtime migration.
1. (Beginner) Measure your branch ages. In any repo, list every remote branch by how stale it is and spot the ones older than two days — the GitFlow smell.
<details> <summary>Solution</summary>
git fetch --prune
git for-each-ref --sort=committerdate refs/remotes/origin \
--format='%(committerdate:relative) %(refname:short)'
Why: branch age is the single best predictor of merge pain. Anything not measured in hours is drifting toward the GitFlow failure mode. </details>
2. (Beginner) Ship a feature dark. Wrap a new code path in a false-default flag and prove production behaviour is unchanged until you flip it.
<details> <summary>Solution</summary>
// default false → the new path is inert until explicitly enabled
if (await isEnabled("search-v2", { userId })) {
return searchV2(query);
}
return searchV1(query); // unchanged behaviour ships to prod
Deploy it, confirm users still hit searchV1, then enable search-v2 for yourself only. Why: this is the whole trick — merged, deployed, and invisible until released, which is what lets unfinished work live on trunk.
</details>
3. (Intermediate) Gate PR size in CI. Add a check that warns when a PR changes more than 400 lines, nudging the team toward small batches without hard-blocking.
<details> <summary>Solution</summary>
Reuse the pr-size.yml workflow from section 2, lowering the threshold:
if [ "$CHANGED" -gt 400 ]; then
echo "::warning::PR changes $CHANGED lines; split it (>400)."
fi
Why: small PRs review faster and conflict less; a visible, deliberate override beats an unenforced guideline. </details>
4. (Intermediate) Prove the merge queue catches a semantic conflict. Construct two PRs that each pass CI alone but break together, and show the queue rejects the second on the merge_group build.
<details> <summary>Solution</summary>
Enable the queue and run CI on both events:
on:
pull_request:
merge_group:
PR-A renames getUser() → fetchUser() and updates all callers. PR-B (branched before A) adds a new caller of getUser(). Both pass alone. When B is queued after A merges, the merge_group build compiles B against the post-A tip, getUser no longer exists, the build fails, and the queue ejects B. Why: a text merge sees no conflict; only re-testing against the live tip catches the semantic break.
</details>
5. (Advanced) Rename a column with zero downtime. Migrate users.fullname → users.full_name across a continuously-deploying service without a maintenance window.
<details> <summary>Solution</summary>
Three independently deployable phases (expand → migrate → contract):
-- Deploy 1 (expand): add + backfill, old column still authoritative
ALTER TABLE users ADD COLUMN full_name text;
UPDATE users SET full_name = fullname WHERE full_name IS NULL LIMIT 5000; -- batch to 0
// Deploy 2 (migrate): dual-write, read new, flag-gated for instant rollback
user.full_name = name;
if (await isEnabled("read-full-name")) return user.full_name;
return user.fullname;
-- Deploy 3 (contract): only after every instance reads full_name and the flag is 100%
ALTER TABLE users DROP COLUMN fullname;
Why: during each rollout, old and new code run against one schema, so every intermediate state must be backward-compatible — the exact invariant TBD requires. </details>
6. (Advanced) Fail CI on flag debt. Extend the section-7 linter so the build fails not only on expired temporary flags but also on any flag stuck at 100% for more than 30 days.
<details> <summary>Solution</summary>
Add a fullOnSince timestamp when a flag reaches 100%, then check it:
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%d 2>/dev/null || date -u -v-30d +%Y-%m-%d)
jq -r --arg cutoff "$CUTOFF" '.flags[]
| select(.temporary == true)
| select((.removeBy < (now|strftime("%Y-%m-%d")))
or ((.fullOnSince // "9999-12-31") < $cutoff))
| .key' flags.json | while read -r f; do
echo "::error::Flag '$f' is stale (expired or 100% >30d); remove it."
FAIL=1
done
[ -z "$FAIL" ] || exit 1
Why: a release flag that has been fully on for a month is pure dead weight — untested fallback code and conditional noise. Making CI fail on it turns cleanup from a someday-project into ordinary work. </details>
Verify
Confirm the migration is real, not aspirational:
- Branch age.
git for-each-ref --sort=committerdate refs/remotes/origin --format='%(committerdate:relative) %(refname:short)'– active branches should be hours old, anddevelop/release/*should be gone. - Queue is enforced. Open a PR that breaks against current
mainbut passed on its own branch; the merge queue must reject it on themerge_groupbuild. - Dark deploy works. Merge a feature behind a
false-default flag, deploy, and confirm production behavior is unchanged until you flip the flag. - Instant rollback. Toggle the flag off and confirm exposure reverts in seconds with no redeploy.
- Trunk is releasable. Pick any recent
mainSHA at random and confirm it builds, passes checks, and could ship. - Lead time moved. Compare the DORA four for 30 days before and after; lead time and deploy frequency up, change-failure rate flat or down.
Checklist
Glossary
- Trunk (mainline). The one shared, long-lived branch — usually
main— that everyone integrates into. In TBD it is the single source of truth. - Trunk-based development (TBD). A branching model where all developers integrate small changes into one trunk at least daily, keeping it always releasable.
- GitFlow. The older model with permanent
developandrelease/*branches plus long-livedfeature/*branches; optimised for scheduled releases, hostile to continuous delivery. - Short-lived branch. A branch for one small change that lives hours to a day and is deleted on merge — the scaled-team flavour of TBD.
- Commit-to-trunk. Pushing directly to
mainbehind fast local and CI checks; the purest TBD flavour, used by small teams. - Continuous integration (original sense). Developers merging their work into the shared mainline continuously, not merely “a CI server runs tests.”
- Continuous delivery / deployment (CD). Every commit is a release candidate; delivery keeps it button-push, deployment automates the push to production. TBD is its precondition.
- Feature flag (toggle). A runtime switch that turns a code path on or off without a deploy. Types: release, experiment, ops/kill-switch, permission.
- Dark code / dark launch. Code shipped to production but kept inert behind a
falseflag until released. - Branch-by-abstraction. Introducing an interface (a seam) so a large change can be built incrementally on trunk behind the abstraction instead of on a long branch.
- Merge queue / merge train. A system that serialises merges, re-testing each PR against the current tip of trunk before it lands so combined changes cannot break
main. - Squash merge. Collapsing a PR’s commits into one atomic commit on trunk, so each trunk commit is a single revertible change.
- Linear history. A trunk with no merge commits — every change is a straight-line, individually revertible commit.
- Deploy vs release. Deploy pushes a binary to an environment; release exposes behaviour to users. TBD decouples them so you deploy dark and release by flag.
- Expand/contract (parallel change). Making a breaking schema or API change in backward-compatible phases — add the new shape, dual-write, migrate, then remove the old — so mixed code versions coexist safely.
- Backfill. Populating a newly added column/field for existing rows, usually in batches to avoid long locks.
- Kill switch. An ops flag that instantly disables a feature or subsystem under load or during an incident.
- Progressive rollout / canary. Releasing to a growing slice of users or traffic (5% → 50% → 100%) to limit blast radius.
- Flag debt. The accumulated dead branches and untested fallback code of release flags left in place after they hit 100%.
- Stop-the-line. The rule that when trunk goes red, no new merges happen until it is green again.
- DORA four. The metrics — deployment frequency, lead time for changes, change-failure rate, time to restore — used to measure delivery performance before and after a migration.
- Backport / cherry-pick. Applying a fix that landed on trunk to a supported
release/*branch withgit cherry-pick; TBD release branches are backport-only. - Release branch. A short-lived branch cut from trunk at release time to stabilise a versioned artifact; legitimate for installed/versioned products, not for SaaS.
- Release train. A cadence-based release that tags a green trunk SHA on a schedule (or when store review forces it), without a long-lived branch.
- Strangler migration. Standing the new model (TBD) up beside the old (GitFlow) and letting it replace the old incrementally, team by team, until the old branches are retired.