Trunk-Based Development Setup Jump to heading

Trunk-based development is the branch topology that underpins continuous delivery: every engineer integrates directly into main (or a single long-lived trunk) at least once per day through small, validated pull requests. This setup guide sits within the broader Git Workflow Architecture & Branching Strategies framework and assumes you want to move from longer-lived divergence models toward rapid, low-risk integration.


Prerequisites Jump to heading

Before configuring trunk-based development, verify your environment meets these requirements:


Branch Topology Overview Jump to heading

The diagram below shows the lifecycle of a short-lived feature branch in a trunk-based setup: create, validate locally, open a pull request, pass CI, merge, and delete β€” all within a single working day.

Trunk-based development branch lifecycleShows main (trunk) as a horizontal line with a short-lived feature branch forking off, passing CI validation, then merging back within 24 hours. A release tag is applied on main after merge.CI: PASScommitbranchmergetagv2.4.10 hfeature branch life (< 24 h)deploymainfeat/short-lived

Step 1 β€” Configure Branch Protection on main Jump to heading

Intent: Make main immutable to direct pushes so that every change flows through a validated pull request.

On GitHub, the branch protection ruleset lives at Settings β†’ Branches β†’ Add rule. Encode it as infrastructure-as-code using GitHub’s Terraform provider so protection is version-controlled alongside application code:

# terraform/branch_protection.tf
resource "github_branch_protection" "main" {
  repository_id = var.repository_id
  pattern       = "main"

  # Block direct pushes β€” all changes must come through a pull request
  enforce_admins         = true
  allows_force_pushes    = false
  allows_deletions       = false
  require_signed_commits = true   # GPG or SSH signing required

  required_status_checks {
    strict   = true               # Branch must be up-to-date before merge
    contexts = [
      "ci/lint",
      "ci/unit-tests",
      "ci/security-scan",
    ]
  }

  required_pull_request_reviews {
    dismiss_stale_reviews           = true
    required_approving_review_count = 1
    require_code_owner_reviews      = true
  }
}

Verify: Attempt a direct push to main:

git push origin main
# Expected: remote: error: GH006: Protected branch update failed

Safety Warning: Setting enforce_admins = false creates a bypass that administrators can exploit, silently undermining the protection model. Always enforce protection for all roles, and audit the bypass log weekly.


Step 2 β€” Enforce Short-Lived Branch Policies Jump to heading

Intent: Prevent integration debt by ensuring branches cannot silently age past their TTL.

Configure a repository automation rule to close stale branches automatically. On GitHub Actions:

# .github/workflows/stale-branches.yml
name: Stale branch cleanup

on:
  schedule:
    - cron: "0 6 * * *"   # Run daily at 06:00 UTC

jobs:
  warn-stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const cutoff = new Date();
            cutoff.setHours(cutoff.getHours() - 24);  // 24-hour TTL

            const branches = await github.paginate(
              github.rest.repos.listBranches,
              { owner: context.repo.owner, repo: context.repo.repo }
            );

            for (const branch of branches) {
              if (branch.name === 'main') continue;

              const commit = await github.rest.repos.getCommit({
                owner: context.repo.owner,
                repo: context.repo.repo,
                ref: branch.commit.sha,
              });

              const lastActivity = new Date(commit.data.commit.author.date);
              if (lastActivity < cutoff) {
                // Post a comment on the open PR, or create an issue
                console.log(`Stale: ${branch.name} (last active: ${lastActivity})`);
              }
            }

Locally, configure Git to rebase on pull so that git pull never creates unnecessary merge commits:

# Per-repo configuration (Git 2.30+)
git config pull.rebase true           # Rebase instead of merge on pull
git config rebase.autoStash true      # Stash dirty working tree automatically
git config rebase.autoSquash true     # Honour fixup!/squash! commit prefixes

Verify: Create a test branch, wait for the workflow to run, and confirm stale branches appear in the workflow log:

git switch -c test/stale-check
git commit --allow-empty -m "chore: stale check test"
git push origin test/stale-check
# After the scheduled run, check Actions β†’ stale-branches β†’ logs

Safety Warning: Automating branch deletion without posting a warning first can lose work if a developer has uncommitted local changes tied to that branch. Always notify before deleting; never auto-delete branches that have open pull requests.


Step 3 β€” Wire CI Merge Gates Jump to heading

Intent: Guarantee that only code that passes all quality checks can land on main.

The pipeline below runs three jobs in parallel β€” lint, tests, and security scan β€” and all three must be green before the merge button becomes active:

# .github/workflows/ci.yml
name: CI merge gate

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  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           # ESLint, Prettier, or project-specific linter

  unit-tests:
    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 -- --coverage  # Must complete in under 5 minutes

  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit --audit-level=high   # Fail on high-severity vulnerabilities

Use path-specific CI triggers to skip jobs that are irrelevant to the changed files β€” this keeps median pipeline latency below five minutes, which is the threshold above which developers start batching changes into larger, riskier commits.

Verify: Open a pull request with a deliberate lint error. The CI status check should appear as failed and the merge button should be disabled.


Step 4 β€” Enforce Conventional Commit Messages Jump to heading

Intent: Make commit history machine-readable so automated changelog generation and semantic versioning work without manual intervention.

Combine local Husky hooks with a CI validator as a server-side fallback. The detailed configuration walkthrough lives in How to Enforce Conventional Commits with commitlint; the minimal setup is:

# Install Husky and commitlint
npm install --save-dev husky @commitlint/cli @commitlint/config-conventional

# Initialise Husky (Git 2.30+ hook directory)
npx husky init
// commitlint.config.js
export default {
  extends: ["@commitlint/config-conventional"],
  rules: {
    // Enforce: feat|fix|chore|docs|refactor|test|ci|perf
    "type-enum": [2, "always", [
      "feat", "fix", "chore", "docs", "refactor", "test", "ci", "perf"
    ]],
    "subject-max-length": [2, "always", 72],
    "header-max-length":  [2, "always", 100],
  },
};
# .husky/commit-msg  β€” hook installed by Husky
npx --no -- commitlint --edit "$1"

CI fallback (runs even when a developer bypasses the local hook):

# Part of .github/workflows/ci.yml
  commit-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npx commitlint --from origin/main --to HEAD

Verify:

git commit -m "wip stuff"
# Expected: β§—  input: wip stuff
#           βœ–  subject may not be empty [subject-empty]
#           βœ–  type may not be empty [type-empty]

Safety Warning: git commit --no-verify bypasses the local hook entirely. The CI commit-lint job is the authoritative enforcement point β€” local hooks are convenience, not security.


Step 5 β€” Feature Flags for Safe Deployment Jump to heading

Intent: Decouple code deployment from feature release so that incomplete work can safely land on main without affecting users.

A minimal environment-variable-driven flag implementation that requires no external service:

// src/flags.js β€” evaluated at startup from environment variables
export const flags = {
  newCheckoutFlow:      process.env.FLAG_NEW_CHECKOUT === "true",
  improvedSearchIndex:  process.env.FLAG_SEARCH_V2   === "true",
};
// Usage in application code
import { flags } from "./flags.js";

function renderCheckout(cart) {
  if (flags.newCheckoutFlow) {
    return renderCheckoutV2(cart);   // Under development β€” flag-gated
  }
  return renderCheckoutV1(cart);     // Stable path for all users
}

For teams that need runtime toggling without redeployment, consider a lightweight configuration store (LaunchDarkly, Unleash, or a key-value store in Cloudflare Workers KV). The principle is the same: the flag evaluation lives outside the code path, so toggling never requires a commit.

Pair feature flags with the Release Tagging & Versioning workflow β€” once a flag-gated feature ships, tag the release and schedule flag removal as a follow-up task.

Verify: Deploy to staging with FLAG_NEW_CHECKOUT=true set; confirm the new flow appears. Deploy with FLAG_NEW_CHECKOUT=false; confirm the old flow appears. No code change, no redeploy required to toggle.


Step 6 β€” Automate Releases and Rollback Jump to heading

Intent: Remove manual release decisions by triggering versioning and deployment automatically from every green merge to main.

semantic-release reads conventional commit messages to determine the next semantic version, writes the changelog, creates a Git tag, and publishes the artifact β€” all without human involvement:

# .github/workflows/release.yml
name: Automated release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write      # Required to push tags and create releases
      issues:   write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0, persist-credentials: false }
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: $
          NPM_TOKEN:    $     # Omit if not publishing to npm

For rollback, prefer feature flag toggles over git revert β€” they take effect immediately. When a revert is genuinely required, use git revert (not git reset) so the history remains linear and auditable:

# Revert a single merge commit safely
git revert -m 1 <merge-commit-sha>
# Explanation: -m 1 selects the first parent (main) as the mainline;
# the revert commit is pushed through the normal PR flow.
git push origin HEAD:refs/heads/revert/bad-feature
# Open a PR from this branch β€” CI gates apply as normal

Safety Warning: Never use git reset --hard to undo a merge that has already been pushed to main. It rewrites shared history and will break every team member’s local clone. Always use git revert.

Verify: Merge a fix: commit. After the release workflow completes, verify that a new patch tag (e.g. v2.4.2) and GitHub Release appear with the correct changelog entry.


Integration with Adjacent Workflows Jump to heading

Trunk-based development does not operate in isolation β€” it depends on two neighbouring workflows for its quality guarantees.

Pre-push validation via pre-push hook enforcement catches build failures before they reach the remote, reducing wasted CI minutes. Husky installs the pre-push hook alongside the commit-msg hook; configure it to run the same unit-test suite that CI executes, so local and remote results agree.

Lint-staged formatting via lint-staged ensures that only changed files are formatted on commit, keeping the pre-commit hook fast enough that developers don’t bypass it. Wire lint-staged so that it runs Prettier and ESLint only on staged files β€” a whole-project format run on every commit defeats the purpose.

The boundary of responsibility is clear: local hooks handle formatting and commit-message syntax; CI handles correctness (tests, security scans, type-checking). Never duplicate expensive work across both layers.

For teams coming from a long-lived branch model, the Feature Branch Isolation page documents the patterns you are moving away from, and Trunk-Based vs GitFlow for SaaS Teams provides a direct decision-matrix comparison to validate that trunk-based development fits your release cadence.


Integration is either continuous or deferredTrunk-based development is not about avoiding branches; it is about integrating within a day so problems surface while they are small. Everything else β€” flags, short branches, fast CI β€” exists to make that possible.long-lived branchestrunk-based developmentintegration happens at merge timeconflicts accumulate in the darkthe branch is a private stateCI verifies branches, not the trunkintegration happens continuouslyconflicts surface within a daythe trunk is the shared stateCI verifies what everyone builds on

Troubleshooting Jump to heading

SymptomLikely causeFix
CI passes locally but fails on mainBranch not rebased before merge; strict: true in required-status-checks enforces thisRun git fetch origin && git rebase origin/main then re-push
commitlint rejects a valid commit typeCustom type not added to type-enum ruleAdd the type to commitlint.config.js and redeploy the CI validator
semantic-release creates no tag after mergeNo feat: or fix: commits in the push; chore: commits do not trigger releasesConfirm commit types with git log --oneline origin/main..HEAD
Merge queue timeout on high-concurrency PRsDefault queue serialisation window too shortIncrease the merge queue timeout setting; parallelize independent CI jobs
Feature flag not toggling in productionEnvironment variable cached at container startupAdd a config reload endpoint or use a runtime flag service; restart pods after env change
Branch protection bypass alert in audit logAdmin merged directly without a PRSet enforce_admins = true in branch protection; rotate admin credentials if abuse is suspected

Why the day matters more than the practiceA branch merged the same day it is cut conflicts only where two people touched the same lines. Two weeks later the conflict surface includes refactors, renames and moves the author never saw.merged same dayconflicts are recognisable and smallmerged within three daysstill manageable; drift is visiblemerged after two weeksconflicts span files the author never openedthe bar length is the conflict surface, and it grows faster than the time the branch has been open

Frequently Asked Questions Jump to heading

How long should a feature branch live in trunk-based development? Jump to heading

Branches should merge within one business day β€” ideally under 24 hours. Anything longer starts accumulating integration debt and conflicts with other contributors’ changes. If the work genuinely cannot fit in a day, split it into smaller increments behind a feature flag.

Can trunk-based development work without feature flags? Jump to heading

For small, self-contained changes: yes. For any work that spans multiple days or must be hidden from users until a planned release, feature flags are essential. Without them you must either keep branches alive longer or ship incomplete functionality β€” both undermine the model.

What merge strategy should I use β€” squash, merge commit, or rebase? Jump to heading

Rebase and squash both produce a linear history that is easy to bisect. Merge commits work but add noise. The critical rule is consistency: pick one strategy and enforce it in the platform’s merge settings so that git bisect and git log --first-parent produce predictable results.

How do I handle hotfixes in a trunk-based model? Jump to heading

Fix on main first through the normal pull request flow β€” this is the canonical fix. Then use cherry-pick backporting to apply the same patch to any release branches that need it. This keeps the hotfix canonical on main and prevents the fix from existing only on a release branch.

What is the minimum CI pipeline required before adopting trunk-based development? Jump to heading

At minimum: a lint pass, a fast unit-test suite completing in under five minutes, and a dependency vulnerability scan. All three must be configured as required_status_checks so they gate merges β€” optional checks are ignored by the merge button and defeat the model entirely.