Zum Inhalt springen

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.

4 Min. Lesezeit
Ein Branching-Diagramm für Trunk-Based Development, bei dem kurzlebige Feature-Branches häufig in main gemergt werden, begleitet von automatisierten Checks

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.

tstypescript
// ❌ 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.

tstypescript
// 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));
}
ymlyaml
# .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.

shbash
# 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
tstypescript
// 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.

tstypescript
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.

tstypescript
// 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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX