Monorepo Git Workflows: Trunk-Based Development at Scale
Trunk-based development in monorepos: code ownership, selective CI triggers, merge queues and branch protection for hundreds of developers in one repo.

Why Trunk-Based in a Monorepo
Long-lived feature branches in a monorepo create merge conflicts that scale quadratically with team size. If 20 teams maintain branches for weeks, the merge pain at integration time is enormous. Trunk-based development avoids this by keeping branches short-lived—typically hours, never more than a couple of days—and merging to main constantly.
Short-Lived Branches and Small PRs
The core discipline: every branch is small, focused, and merges within a day. Large features are broken into incremental changes behind feature flags, not large branches.
// ❌ Monolithic PR that touches 40 files across 5 packages
// Branch: feature/new-checkout-flow (open for 3 weeks)
// 2,400 lines changed, 12 merge conflicts, blocked 4 other teams
// ✅ Incremental changes that merge daily
// PR 1: Add checkout API types (30 lines, 1 file)
// PR 2: Implement payment validation behind flag (80 lines, 3 files)
// PR 3: Add checkout form component behind flag (120 lines, 2 files)
// PR 4: Wire up checkout flow behind flag (60 lines, 4 files)
// PR 5: Enable flag for internal users (5 lines, 1 file)
interface PRGuidelines {
maxFiles: number;
maxLinesChanged: number;
maxOpenDuration: string;
requiredChecks: string[];
}
const monorepoRules: PRGuidelines = {
maxFiles: 15,
maxLinesChanged: 400,
maxOpenDuration: "24h",
requiredChecks: [
"affected-packages-lint",
"affected-packages-test",
"affected-packages-build",
"codeowners-approval",
],
};Selective CI with Affected Package Detection
In a monorepo, running every test for every PR is wasteful. Detect which packages changed and only run their CI jobs.
// scripts/affected.ts
import { execSync } from "node:child_process";
interface Package {
name: string;
path: string;
dependencies: string[];
}
function getChangedFiles(baseBranch: string = "origin/main"): string[] {
const output = execSync(
`git diff --name-only ${baseBranch}...HEAD`
).toString();
return output.trim().split("\n").filter(Boolean);
}
function getAffectedPackages(
changedFiles: string[],
packages: Package[]
): Package[] {
const directlyChanged = new Set<string>();
for (const file of changedFiles) {
for (const pkg of packages) {
if (file.startsWith(pkg.path + "/")) {
directlyChanged.add(pkg.name);
}
}
}
// Include packages that depend on changed packages
const affected = new Set(directlyChanged);
let changed = true;
while (changed) {
changed = false;
for (const pkg of packages) {
if (affected.has(pkg.name)) continue;
if (pkg.dependencies.some((dep) => affected.has(dep))) {
affected.add(pkg.name);
changed = true;
}
}
}
return packages.filter((p) => affected.has(p.name));
}# .github/workflows/ci.yml
name: Monorepo CI
on:
pull_request:
branches: [main]
jobs:
detect-affected:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.affected.outputs.packages }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: affected
run: |
PACKAGES=$(node scripts/affected.ts)
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
test:
needs: detect-affected
if: needs.detect-affected.outputs.packages != '[]'
strategy:
matrix:
package: ${{ fromJson(needs.detect-affected.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test --workspace=${{ matrix.package }}CODEOWNERS for Distributed Ownership
With many teams in one repository, clear ownership prevents chaos. GitHub's CODEOWNERS file routes review requests to the right team automatically.
# CODEOWNERS
# Each team owns their packages and gets auto-assigned to PRs
# Platform team owns shared infrastructure
/packages/shared-config/ @org/platform-team
/packages/eslint-config/ @org/platform-team
/.github/ @org/platform-team
# Payments team
/packages/checkout/ @org/payments-team
/packages/payment-gateway/ @org/payments-team
/packages/billing/ @org/payments-team
# Frontend platform team
/packages/design-system/ @org/frontend-platform
/packages/shared-components/ @org/frontend-platform
# Any team can modify their own package.json, but shared deps need platform approval
/package.json @org/platform-team
/pnpm-lock.yaml @org/platform-team// Automated CODEOWNERS validation
function validateCodeowners(
changedFiles: string[],
codeowners: Map<string, string[]>,
approvers: string[]
): { valid: boolean; missingApprovals: string[] } {
const requiredTeams = new Set<string>();
for (const file of changedFiles) {
for (const [pattern, teams] of codeowners) {
if (matchGlob(file, pattern)) {
teams.forEach((t) => requiredTeams.add(t));
}
}
}
const missingApprovals = [...requiredTeams].filter(
(team) => !approvers.some((a) => isTeamMember(a, team))
);
return {
valid: missingApprovals.length === 0,
missingApprovals,
};
}Merge Queues for Main Branch Stability
When many PRs merge simultaneously, two individually-passing PRs can break main when combined. Merge queues serialize merges, testing each PR against the latest main before allowing it through.
interface MergeQueueEntry {
prNumber: number;
sha: string;
enqueuedAt: Date;
status: "pending" | "testing" | "passed" | "failed";
}
class MergeQueue {
private queue: MergeQueueEntry[] = [];
enqueue(pr: MergeQueueEntry): void {
this.queue.push({ ...pr, status: "pending" });
this.processNext();
}
private async processNext(): Promise<void> {
const next = this.queue.find((e) => e.status === "pending");
if (!next) return;
next.status = "testing";
// Rebase PR onto latest main and run CI
const passed = await this.testAgainstLatestMain(next);
if (passed) {
next.status = "passed";
await this.mergePR(next);
this.queue = this.queue.filter((e) => e.prNumber !== next.prNumber);
} else {
next.status = "failed";
this.queue = this.queue.filter((e) => e.prNumber !== next.prNumber);
await this.notifyFailure(next);
}
// Process next in queue
this.processNext();
}
private async testAgainstLatestMain(
entry: MergeQueueEntry
): Promise<boolean> {
// Create a temporary merge commit and run CI
// This catches conflicts between PRs that passed individually
const tempBranch = `merge-queue/${entry.prNumber}`;
await git.createBranch(tempBranch, "main");
const mergeSucceeded = await git.merge(entry.sha);
if (!mergeSucceeded) return false;
return await runCI(tempBranch);
}
}Branch Protection Rules
Protect main with rules that enforce the workflow without exceptions.
// Recommended branch protection configuration
const branchProtection = {
requiredStatusChecks: {
strict: true, // Branch must be up to date before merging
contexts: [
"ci/affected-tests",
"ci/affected-lint",
"ci/affected-build",
],
},
requiredPullRequestReviews: {
requiredApprovingReviewCount: 1,
requireCodeOwnerReviews: true,
dismissStaleReviews: true,
},
restrictions: {
// Only merge queue bot can push to main
users: [],
teams: ["merge-queue-bot"],
},
enforceAdmins: true,
requiredLinearHistory: true, // No merge commits
allowForcePushes: false,
allowDeletions: false,
};Key Takeaways
Trunk-based development in monorepos requires discipline: short-lived branches, small PRs, and feature flags for incremental delivery. Detect affected packages to avoid running the entire CI suite on every PR—test only what changed and what depends on what changed.
CODEOWNERS distributes review responsibility so teams own their code without blocking others. Merge queues ensure main stays green by testing each PR against the latest state before merging. Branch protection enforces the workflow mechanically. The goal is that hundreds of developers commit to one repository daily, main is always deployable, and merge conflicts are measured in minutes, not days.


