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.


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

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.