Monorepo-Git-Workflows: Trunk-Based Development at Scale
Trunk-Based Development in Monorepos: Code-Ownership, selektive CI-Trigger, Merge-Queues und Branch-Protection für hunderte Entwickler in einem Repo.

Warum Trunk-Based in einem Monorepo
Langlaufende Feature-Branches in einem Monorepo erzeugen Merge-Konflikte, die quadratisch mit der Teamgröße skalieren. Wenn 20 Teams über Wochen Branches pflegen, ist der Merge-Schmerz zum Zeitpunkt der Integration enorm. Trunk-Based Development vermeidet das, indem Branches kurzlebig gehalten werden – typischerweise Stunden, niemals länger als ein paar Tage – und ständig in main gemergt werden.
Kurzlebige Branches und kleine PRs
Die zentrale Disziplin: Jeder Branch ist klein, fokussiert und wird innerhalb eines Tages gemergt. Große Features werden in inkrementelle Änderungen hinter Feature-Flags aufgeteilt, nicht in große 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",
],
};Selektives CI mit Affected-Package Detection
In einem Monorepo ist es verschwenderisch, bei jedem PR alle Tests laufen zu lassen. Ermittle, welche Packages sich geändert haben, und führe nur deren CI-Jobs aus.
// 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 für verteilte Ownership
Wenn viele Teams in einem Repository arbeiten, verhindert klare Ownership das Chaos. GitHubs CODEOWNERS-Datei leitet Review-Anfragen automatisch an das richtige Team weiter.
# 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 für die Stabilität von main
Wenn viele PRs gleichzeitig gemergt werden, können zwei einzeln bestehende PRs main beim Kombinieren brechen. Merge-Queues serialisieren Merges und testen jeden PR gegen den aktuellen main-Stand, bevor er durchgelassen wird.
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-Regeln
Schütze main mit Regeln, die den Workflow ohne Ausnahmen durchsetzen.
// 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,
};Kernaussagen
Trunk-Based Development in Monorepos erfordert Disziplin: kurzlebige Branches, kleine PRs und Feature-Flags für inkrementelle Auslieferung. Ermittle betroffene Packages, um nicht bei jedem PR die gesamte CI-Suite laufen zu lassen – teste nur das, was sich geändert hat, und was davon abhängt.
CODEOWNERS verteilt die Review-Verantwortung, sodass Teams ihren eigenen Code besitzen, ohne andere zu blockieren. Merge-Queues stellen sicher, dass main grün bleibt, indem sie jeden PR gegen den aktuellen Stand testen, bevor er gemergt wird. Branch-Protection erzwingt den Workflow maschinell. Das Ziel: Hunderte Entwickler committen täglich in ein Repository, main ist jederzeit deploybar, und Merge-Konflikte dauern Minuten statt Tage.


