PHASE 3 OF 14
Branching Strategies & Code Review Workflows
GitFlow vs trunk-based development, feature flags, release branches, hotfix backports, CODEOWNERS enforcement, merge queues, and the review culture decisions that separate high-performing teams from slow ones
GitFlow
Trunk-Based
Feature Flags
Merge Queue
Code Review
Branch Protection
3.1
GitFlow vs Trunk-Based Development vs GitHub Flow
The branching strategy you choose is really a deployment frequency decision. Pick the model that matches how often your team can safely ship, not the one with the most-impressive diagram.
GitFlow
Introduced by Vincent Driessen in 2010. Designed for software with discrete version releases — desktop apps, mobile apps, versioned libraries, firmware.
── release/1.2 ──────────────────────────────────────────
release ─────────────────── R ──────────────────────────────────
╲ ╲
main ──── A ────────────── B ────────── C ──────────────────
╲ ╲ ╲
develop ──── A ──── D ──── E ──── F ──── G ──── H
╱ ╱
feature/x x1 ── x2 ─╯
hotfix hf1 ── hf2 (cherry-picked to develop too)
Five branch types: main (production), develop (integration), feature/*, release/*, hotfix/*. Every feature merges to develop. When develop is ready for a release, a release/* branch is cut for stabilisation, then merged to both main and back to develop.
| GitFlow |
| Best fit | Mobile apps, desktop apps, versioned APIs, firmware — anything with a formal release cycle and customers who can't auto-update |
| Release frequency | Weekly to monthly |
| Main is always deployable? | Yes — but only after a release branch is merged |
| Pain points | Complex merge conflicts between develop and release; hotfixes must be applied to both main and develop; mental overhead for new engineers |
Trunk-Based Development (TBD)
Everyone commits directly to main (or merges short-lived feature branches within 1–2 days). No long-lived branches. The trunk is always releasable because unfinished features are hidden behind feature flags.
main ── A ── B ── C ── D ── E ── F ── G ──►
╲ f1╱ ╲f2╱ ╲fix╱
(max 1-2 day branches, merged immediately)
| Trunk-Based Development |
| Best fit | SaaS products, web services, internal tools — anything that can deploy multiple times per day |
| Release frequency | Multiple times per day |
| Main is always deployable? | Yes — by definition. Feature flags gate unfinished work. |
| Pain points | Requires strong CI, feature flag infrastructure, and team discipline. Hard to adopt gradually — needs full buy-in. |
GitHub Flow
The pragmatic middle ground: main is always deployable; features live on short-lived branches (days to a week); every branch ships via a PR. No develop, no release branches — deploy from main directly after merge.
main ── A ──────────────────── M1 ──────────── M2 ──►
╲ ╱ ╲ ╱
feat/login L1 ── L2 ── L3 ─╯
fix/typo T1 ──╯
| GitHub Flow |
| Best fit | Web apps and APIs that deploy continuously; teams of 2–20; early-stage products |
| Release frequency | Multiple times per week to multiple per day |
| Main is always deployable? | Yes — branch protection + required CI enforces this |
| Pain points | No formal release branch for stabilisation; hotfixes go through the normal PR flow (which is usually fine) |
LEAD DECISION FRAMEWORK
Ask:
"Can we deploy at any moment, unannounced?" If yes → GitHub Flow or TBD. If no (regulated industry, coordinated customer releases, mobile app store review) → GitFlow. The most common mistake is using GitFlow on a web app that could deploy daily, adding ceremony with no benefit.
3.2
Feature Flags as an Alternative to Long-Lived Branches
Feature flags (also called feature toggles) let you merge incomplete code to main while keeping it invisible to users. The flag is the gate, not the branch. This is the technique that makes trunk-based development viable at scale.
The four flag types
| Type | Lifetime | Use case | Example |
| Release toggle | Days to weeks — removed when shipped | Hide an incomplete feature from production while development continues on main | ff.newCheckoutFlow |
| Experiment toggle | Days to weeks | A/B test — show variant to a % of users, compare metrics, clean up | ff.checkoutCTAColor |
| Ops toggle | Months to permanent | Kill switch for a risky code path in production — disable without deploying | ff.thirdPartyPayment |
| Permission toggle | Permanent | Feature available only to certain users / plans | ff.advancedReporting |
Flag lifecycle in a PR-based workflow
- Engineer adds
if (flags.isEnabled("new_checkout")) { ... } around new code
- PR merges to
main with flag defaulting to false
- QA enables the flag in staging, tests the feature
- Gradual rollout: 1% → 10% → 50% → 100% in production
- Remove the flag after full rollout — this is non-optional. Flags left in place become permanent technical debt.
FLAG DEBT
Every flag is a code path branch that must be tested. A codebase with 50 stale flags has 2
50 theoretical code paths. Enforce a TTL on release toggles — create a ticket to remove the flag at the time you create it. Some teams block merges if the feature flag count exceeds a threshold.
Lightweight flag implementation vs platform
const flags = {
newCheckout: process.env.FF_NEW_CHECKOUT === 'true',
advancedSearch: process.env.FF_ADVANCED_SEARCH === 'true',
}
const client = new LaunchDarkly.LDClient(SDK_KEY)
const showNewCheckout = await client.variation('new-checkout', user, false)
For most teams, start with env-var flags to validate the discipline, then invest in a flag platform once you're doing more than 5 active flags concurrently.
3.3
Release Branches & Hotfix Backports
Even trunk-based teams sometimes need release branches — for mobile apps awaiting store review, for customers on older supported versions, or for regulated environments where every deploy requires sign-off. Here's how to manage them without creating a maintenance nightmare.
When to cut a release branch
- You support multiple versions simultaneously (e.g. v2.x and v3.x of an API)
- Deployment is gated (mobile App Store, enterprise on-prem installs)
- A release needs a stabilisation period (bug-bashing week) without blocking new feature development on
main
Release branch naming
git checkout -b release/v2.4 main
git push -u origin release/v2.4
git checkout -b release/2026-06-20 main
Hotfix backport workflow
A critical bug found in v2.4 after v2.5 is already on main. The fix must land in both.
main ── A ── B ── C(v2.5) ──────────────── M(fix on main)
╲
release/v2.4 R1 ── R2 ── FIX(cherry-picked)
↓
tag v2.4.1
git checkout main
git checkout -b fix/payment-null-pointer
git commit -m "fix: guard against null payment method in checkout"
gh pr create --base main --title "fix: null pointer in checkout"
git checkout release/v2.4
git cherry-pick abc1234
git push origin release/v2.4
git tag -a v2.4.1 -m "Patch: fix null pointer in payment checkout"
git push origin v2.4.1
FIX FORWARD PRINCIPLE
Always fix on
main first, then backport. Never fix only on the release branch — the same bug will reappear in the next release. If the code differs too much to cherry-pick cleanly, apply an equivalent fix on the release branch, but document it clearly and verify
main is also patched.
Automating backports with GitHub Actions
on:
pull_request:
types: [closed]
branches: [main]
jobs:
backport:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: korthout/backport-action@v2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
label_pattern: '^backport (.+)$'
The korthout/backport-action reads labels like backport release/v2.4 from the merged PR, cherry-picks the commits, and opens a new PR against release/v2.4 automatically.
3.4
CODEOWNERS: Patterns, Team Owners & Bypass Rules
We introduced CODEOWNERS in Phase 2. Here we go deeper on the pattern matching rules and the integration with branch protection that makes ownership enforceable.
Pattern matching rules
*
*.js
/src/
src/
docs/*
docs/**
/scripts/*.sh
!README.md
LAST PATTERN WINS
Unlike
.gitignore, CODEOWNERS uses
last matching rule wins, not first. Put broad catch-all patterns at the top, specific ones at the bottom. A file matching both
* and
/services/payments/ is owned by whoever the last matching rule assigns.
Multi-owner and team syntax
/infra/ @alice
/infra/ @alice @bob
/infra/ @acme/ops-team
/payments/ @acme/payments-team @carol
/legacy/ contractor@example.com
Requiring CODEOWNER review in branch protection
Settings → Branches → Edit rule for main → check "Require review from Code Owners".
When this is enabled:
- Changing
/infra/ files requires approval from @acme/ops-team — not just any reviewer
- If a PR only touches
/docs/, only the docs owner is required
- A PR touching files owned by three different teams requires at least one approval from each
Bypass actors in rulesets
Sometimes automation (Dependabot, release bots) needs to merge without a CODEOWNER review. In rulesets this is explicit:
{
"bypass_actors": [
{
"actor_id": 2,
"actor_type": "Integration",
"bypass_mode": "pull_request"
},
{
"actor_id": 5,
"actor_type": "Integration",
"bypass_mode": "always"
}
]
}
3.5
Branch Protection Rules: Required Checks, Dismissal & Force-Push Controls
A complete branch protection rule for a production branch covers five distinct concerns:
1. Required status checks
Specifies which CI jobs must pass before merge. The check name must exactly match the job name (or status context) reported by the check run.
✓ Require branches to be up to date before merging
✓ ci/test
✓ ci/lint
✓ ci/build
✓ CodeQL / analyze (javascript)
UP-TO-DATE REQUIREMENT
"Require branches to be up to date" means the PR branch must include all commits from the base branch before merge. This prevents two PRs from passing CI independently on the same base and then conflicting when merged. It's the right setting for
main on most teams — but it creates a merge queue problem at scale (see 3.9).
2. Required reviews
- Required approving reviews: typically 1 for small teams, 2 for critical paths
- Dismiss stale reviews when new commits are pushed: forces re-review after any change post-approval — prevents the "approved before the fix was added" problem
- Require review from Code Owners: links to CODEOWNERS as described above
- Restrict who can dismiss pull request reviews: prevents a reviewer from dismissing their own review or another reviewer's approval
3. Restrict force pushes
Block all force pushes to the protected branch. This prevents history rewriting on shared branches. If your CI system or release automation must force push (e.g., to update a gh-pages branch), add it to the bypass actors in a ruleset rather than disabling this protection globally.
4. Restrict deletions
Prevents branch deletion. Critical for main, release/*, and any branch referenced in external systems (deploy pipelines, status pages).
5. Lock branch (read-only)
Makes the branch read-only — no pushes or merges. Useful for archiving a release branch after all patches have been applied and the version is EOL. Leaves the branch visible for reference without allowing accidental modification.
Full protection settings for a production main branch
$ gh api repos/org/repo/branches/main/protection \
--method PUT \
--field 'required_status_checks={"strict":true,"contexts":["ci/test","ci/lint","ci/build"]}' \
--field 'enforce_admins=true' \
--field 'required_pull_request_reviews={"dismissal_restrictions":{},"dismiss_stale_reviews":true,"require_code_owner_reviews":true,"required_approving_review_count":1}' \
--field 'restrictions=null' \
--field 'allow_force_pushes=false' \
--field 'allow_deletions=false'
3.6
Rulesets: Layered Rules & Org-Wide Enforcement
We covered the basics of rulesets in Phase 2. Here we focus on the layering model and org-wide enforcement — the features that make rulesets worth migrating to.
How layering works
Multiple rulesets can target the same branch. GitHub applies the most restrictive value of any given rule across all active rulesets. You can't "override" a stricter org-level rule with a more permissive repo-level one.
- block force pushes
- require 1 approving review
- require 2 approving reviews ← stricter → 2 wins
- require CODEOWNER review
- require status checks: ci/test, ci/pci-compliance
- block force pushes (from org-baseline)
- require 2 approving reviews (payments-strict is stricter)
- require CODEOWNER review (from payments-strict)
- require ci/test + ci/pci-compliance
Org-wide ruleset (GitHub Enterprise)
An org-level ruleset targets repositories by name pattern or topic tag, and applies to matching branches across ALL repos in the org — without touching each repo's settings.
$ gh api orgs/acme/rulesets \
--method POST \
--field name="org-baseline-protection" \
--field target="branch" \
--field enforcement="active" \
--field 'conditions={
"repository_name": {"include": ["~ALL"], "exclude": ["~ARCHIVED"]},
"ref_name": {"include": ["refs/heads/main"], "exclude": []}
}' \
--field 'rules=[
{"type":"deletion"},
{"type":"non_fast_forward"},
{"type":"pull_request","parameters":{"required_approving_review_count":1}}
]'
ROLLOUT PATTERN
Use the repository
topic tag as a rollout gate. Tag a pilot repo with
ruleset-v2-pilot and target only repos with that topic in your org ruleset. After validation, change the condition to
~ALL. Topics are lightweight and don't affect code.
Required workflows (Enterprise-only)
The most powerful org-level rule: force every repo to run a specific reusable workflow before any branch can be merged. This is how platform teams enforce security scanning or compliance checks without relying on each team to copy-paste a workflow.
{
"type": "workflows",
"parameters": {
"workflows": [
{
"repository_id": 12345,
"path": ".github/workflows/security-scan.yml",
"ref": "refs/heads/main"
}
]
}
}
3.7
Code Review Culture: Async vs Sync, SLAs & Stale PR Policy
Branch protection enforces the minimum bar. Culture determines whether reviews are fast, useful, and psychologically safe. Both matter; tooling can only do so much.
Async-first code review
Most code review should be asynchronous — reviewer responds when they're ready, within a defined SLA. This respects focus time and works across timezones. Sync review (pair at a screen) is valuable for complex design discussions but should be the exception.
| Async review | Sync review (pair) |
| Default for all PRs | Reserve for architectural decisions or large refactors |
| Works across timezones | Requires schedule coordination |
| Leaves a written record | Outcome must be written up afterward |
| Slower initial response | Immediate feedback loop |
Review SLA
The single most impactful policy change most teams can make. Define it explicitly, publish it in CONTRIBUTING.md, and enforce it socially (not via automation).
## Code Review SLA
| PR size | First response SLA | Resolution SLA |
|-------------|-------------------|----------------|
| XS (<50 lines) | 2 business hours | Same day |
| S (50-200) | 4 business hours | Next business day |
| M (200-500) | 1 business day | 2 business days |
| L (500+) | Discuss with author first — consider splitting |
We define "first response" as a substantive comment or approval,
not an emoji reaction or "LGTM" with no content.
Stale PR policy
Stale PRs are a team health signal. A PR waiting for review for more than 3 days indicates either the PR is too large, the reviewer is overwhelmed, or there's a social/political blocker. Use the actions/stale action to surface them:
on:
schedule:
- cron: '0 9 * * 1-5'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
days-before-pr-stale: 3
days-before-pr-close: 10
stale-pr-label: needs-review
stale-pr-message: 'This PR has been waiting for review for 3 days. Reviewers: please prioritise, or the author should ping in Slack.'
exempt-pr-labels: 'draft,blocked,on-hold'
Effective review comments
The GitHub review comment convention used by high-performing teams:
| Prefix | Meaning | Requires action? |
nit: | Nitpick — stylistic preference, not a blocking concern | Author decides |
q: | Question — the reviewer wants to understand something, not necessarily change it | Answer the question |
suggest: | Non-blocking suggestion for improvement | Author decides |
block: | Blocking concern — must be addressed before approval | Yes — mandatory |
issue: | A bug or correctness problem found during review | Yes — mandatory |
praise: | Explicit positive feedback on good work | No action needed |
LEAD ACTION
Adopt and document this convention in CONTRIBUTING.md. It eliminates the ambiguity of "Approved with comments" — reviewers who approve but leave unaddressed
block: comments are sending conflicting signals. Make the distinction explicit.
3.8
Draft PRs, Auto-Merge & the Merge Queue
Draft pull requests
A draft PR signals "not ready for review, but I want CI to run and I want early feedback." Draft PRs:
- Cannot be merged (the merge button is disabled)
- Do not trigger CODEOWNER review requests automatically
- Still run Actions workflows triggered on
pull_request
- Show the "Draft" label in the PR list for easy filtering
$ gh pr create --draft --title "WIP: new payment retry logic" --body "..."
$ gh pr ready 247
Good uses for draft PRs: starting a PR early to get CI feedback while the code is still being written; large features where you want architectural feedback before implementation is complete; blocked work waiting for a dependency.
Auto-merge
Auto-merge lets a PR merge automatically as soon as all required checks pass and all required reviews are approved. Ideal for Dependabot patch updates and low-risk automated PRs.
$ gh pr merge 247 --auto --squash
on:
pull_request:
permissions:
pull-requests: write
contents: write
jobs:
automerge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Fetch Dependabot metadata
id: meta
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-approve patch and minor updates
if: steps.meta.outputs.update-type == 'version-update:semver-patch'
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for patch updates
if: steps.meta.outputs.update-type == 'version-update:semver-patch'
run: gh pr merge --auto --squash "$PR_URL"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3.9
Merge Queue: Batch Merging at Scale
The "require up-to-date branches" branch protection setting causes a problem at scale: with 20 engineers merging PRs throughout the day, the queue becomes a serialisation bottleneck. Each PR must rebase and re-run CI after every other merge. On a 10-minute CI pipeline with 20 PRs in flight, the last engineer waits 200 minutes.
GitHub's merge queue solves this. It batches queued PRs, runs CI on the batch, and merges them atomically if CI passes — no individual rebasing required.
How the merge queue works
Without merge queue:
PR #1 approved → rebase onto main → CI (10 min) → merge
PR #2 approved → wait → rebase onto new main → CI (10 min) → merge
PR #3 approved → wait → wait → rebase → CI (10 min) → merge
Total: 30 min serialised
With merge queue (batch size 3):
PR #1 ─┐
PR #2 ─┼─► queue batches them ─► CI on [main + #1 + #2 + #3] ─► merge all 3
PR #3 ─┘
Total: 10 min — all three merged in parallel
Enabling the merge queue
Settings → Branches → Edit rule for main → check "Require merge queue". Then configure:
- Merge method: merge commit, squash, or rebase
- Build concurrency: max number of PRs batched together (typically 5–10)
- Minimum group size: wait for at least N PRs before starting a batch (reduces CI runs)
- Maximum time to wait: start CI after this timeout even if minimum group size isn't reached
Developer workflow with merge queue enabled
$ gh pr merge 247 --merge
$ gh api repos/org/repo/merge-queue
{
"queued_pull_requests": [
{"number": 247, "position": 1, "state": "queued"},
{"number": 251, "position": 2, "state": "queued"}
]
}
Failure handling
If the batch fails CI, GitHub bisects: it splits the batch in half, re-runs CI on each half, and removes only the failing PR from the queue. The other PRs are re-queued and merge. This means a single failing PR doesn't block everyone else.
MERGE QUEUE REQUIREMENT
Merge queue requires the "Require status checks to pass" rule. If you don't have required status checks configured, the queue has nothing to evaluate and every PR merges immediately — which defeats the purpose. Always pair merge queue with required CI checks.
Up Next — Phase 4: Pull Requests at Depth
PR templates, review suggestions in bulk, stacked PRs, PR size discipline, the gh CLI for PR workflows, and measuring cycle time and review thoroughness.
Continue to Phase 4 →
Back to Hub