Git en monorepos: desarrollo basado en tronco a escala
Desarrollo basado en tronco en monorepos: propiedad del código, CI selectiva, colas de merge y protección de ramas para cientos de desarrolladores.

Por qué el desarrollo basado en tronco en un monorepo
Las ramas de funcionalidad de larga duración en un monorepo generan conflictos de merge que escalan de forma cuadrática con el tamaño del equipo. Si 20 equipos mantienen ramas durante semanas, el dolor del merge en el momento de la integración es enorme. El desarrollo basado en tronco evita esto manteniendo las ramas de corta duración —típicamente horas, nunca más de un par de días— y fusionándose constantemente a main.
Ramas de corta duración y PRs pequeños
La disciplina central: cada rama es pequeña, enfocada y se fusiona en menos de un día. Las funcionalidades grandes se dividen en cambios incrementales detrás de feature flags, no en ramas grandes.
// ❌ 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",
],
};CI selectivo con detección de paquetes afectados
En un monorepo, ejecutar todas las pruebas para cada PR es un desperdicio. Detecta qué paquetes cambiaron y ejecuta solo sus jobs de CI.
// 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 para una propiedad distribuida
Con muchos equipos en un mismo repositorio, la propiedad clara evita el caos. El archivo CODEOWNERS de GitHub asigna automáticamente las solicitudes de revisión al equipo correcto.
# 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,
};
}Colas de merge para la estabilidad de main
Cuando muchos PRs se fusionan simultáneamente, dos PRs que pasan individualmente pueden romper main al combinarse. Las colas de merge serializan las fusiones, probando cada PR contra la última versión de main antes de permitir su paso.
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);
}
}Reglas de protección de ramas
Protege main con reglas que hacen cumplir el flujo de trabajo sin excepciones.
// 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,
};Puntos clave
El desarrollo basado en tronco en monorepos requiere disciplina: ramas de corta duración, PRs pequeños y feature flags para la entrega incremental. Detecta los paquetes afectados para evitar ejecutar todo el CI en cada PR —prueba solo lo que cambió y lo que depende de lo que cambió.
CODEOWNERS distribuye la responsabilidad de revisión para que cada equipo sea dueño de su código sin bloquear a otros. Las colas de merge aseguran que main permanezca verde probando cada PR contra el último estado antes de fusionar. La protección de ramas hace cumplir el flujo de trabajo de forma mecánica. El objetivo es que cientos de desarrolladores confirmen cambios en un repositorio diariamente, main esté siempre desplegable y los conflictos de merge se midan en minutos, no en días.


