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.
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 = falsecreates 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-verifybypasses 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 --hardto undo a merge that has already been pushed tomain. It rewrites shared history and will break every team memberβs local clone. Always usegit 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
| Symptom | Likely cause | Fix |
|---|---|---|
CI passes locally but fails on main | Branch not rebased before merge; strict: true in required-status-checks enforces this | Run git fetch origin && git rebase origin/main then re-push |
commitlint rejects a valid commit type | Custom type not added to type-enum rule | Add the type to commitlint.config.js and redeploy the CI validator |
semantic-release creates no tag after merge | No feat: or fix: commits in the push; chore: commits do not trigger releases | Confirm commit types with git log --oneline origin/main..HEAD |
| Merge queue timeout on high-concurrency PRs | Default queue serialisation window too short | Increase the merge queue timeout setting; parallelize independent CI jobs |
| Feature flag not toggling in production | Environment variable cached at container startup | Add a config reload endpoint or use a runtime flag service; restart pods after env change |
| Branch protection bypass alert in audit log | Admin merged directly without a PR | Set 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.
Related Jump to heading
- How to Enforce Conventional Commits with commitlint β step-by-step commitlint configuration with Husky, CI fallback, and monorepo scopes
- Release Tagging & Versioning β automating semantic version tags and changelogs from conventional commit history
- Feature Branch Isolation β the long-lived branch model, its tradeoffs, and when it remains the better fit
- Trunk-Based vs GitFlow for SaaS Teams β a direct decision matrix comparing both models against SaaS release cadence requirements
- Pre-Push Validation Rules β configuring
pre-pushhooks that catch build failures before they reach CI - Lint-Staged Formatting Automation β running Prettier and ESLint only on staged files to keep
pre-commithooks fast