Keep GitFlow-style release branches
Good when: Familiar to many teams and can support heavy manual release control.
Risk: Integration happens late. Merge conflicts, stale changes, and QA surprises get pushed toward the release window.
CI/CD case study
A delivery-modernization example about moving from monthly or quarterly release coordination toward trunk-based delivery. The useful part is not the process label; it is smaller batches, earlier integration, stronger tests, and fewer surprises when code reaches production.
csharp/DeliveryModernizationGates/DeliveryGate.cs Branching model, required checks, risk scoring, and merge verdicts. csharp/DeliveryModernizationGates/Scenarios.cs Release-branch, short-lived mainline, and unsafe mainline examples. csharp/DeliveryModernizationGates.Tests/DeliveryGateTests.cs xUnit tests that separate fast delivery from weak validation. src/delivery-gates.ts The same delivery policy implemented for a TypeScript/Node.js stack. src/scenarios.ts The matching scenario set used by the TypeScript runner. test/delivery-gates.test.ts Vitest coverage for the parallel implementation. .github/workflows/ci.yml Validates both C#/.NET and TypeScript implementations on main and pull requests. Long-lived release branches can feel safe because they protect production from constant change. The hidden cost is that integration, testing, and business feedback happen late, when every fix is more expensive and every merge conflict carries more history.
Good when: Familiar to many teams and can support heavy manual release control.
Risk: Integration happens late. Merge conflicts, stale changes, and QA surprises get pushed toward the release window.
Good when: Keeps code review and branch protection while still integrating every small change quickly.
Risk: Requires strong automated gates, small batches, and discipline around branch age.
Good when: Fastest feedback loop when the team has mature pairing, flags, and high trust in the test suite.
Risk: Too risky if validation is weak. Trunk-based delivery is not a reason to bypass review or tests.
The selected model keeps code review and branch protection, but removes long-lived integration branches as the normal delivery path. The point is not to merge recklessly; it is to move risk detection closer to the developer while the change is still small enough to understand.
Main is the integration branch and has to stay deployable.
Feature branches are fine when they are short-lived and reviewed quickly.
Every merge runs unit, integration, contract, and smoke validation.
Release timing stays separate from merge timing through feature flags or promotion controls.
Database changes need a rollback or roll-forward path before the merge is accepted.
Before
After
The C#/.NET implementation is the primary path because this delivery problem is common in enterprise backend teams. The TypeScript implementation is a parallel version of the same policy, so the example is about the rule more than one runtime.
Main is the integration branch; release timing is handled by promotion, flags, or rollback. Open the source file.
public static readonly BranchingModel TrunkBasedModel = new(
Name: "Trunk-based delivery",
IntegrationBranch: "main",
ReleaseBranch: null,
ReleaseCadenceDays: 1,
TypicalBranchAgeDays: 1,
AutomatedGateCoverage: "high",
RollbackStyle: "toggle or rollback"); The gate blocks delayed integration, missing tests, large batches, and migrations without recovery plans.
public static DeliveryGateReport Evaluate(ChangeSet change)
{
var findings = new List<GateFinding>();
if (!string.Equals(change.TargetBranch, TrunkBasedModel.IntegrationBranch, StringComparison.Ordinal))
{
findings.Add(new GateFinding(
FindingSeverity.Blocker,
"not_integrating_to_trunk",
"The change is not integrating against main, so feedback is delayed."));
}
if (change.BranchAgeDays > 2)
{
findings.Add(new GateFinding(
change.BranchAgeDays > 5 ? FindingSeverity.Blocker : FindingSeverity.Risk,
"stale_branch",
"Long-lived branches raise merge risk and hide integration problems."));
}
foreach (var check in RequiredChecks)
{
if (GetCheckState(change.Checks, check) != CheckState.Pass)
{
findings.Add(new GateFinding(
FindingSeverity.Blocker,
"${check}_gate_not_green",
"${check} validation must pass before the change can merge."));
}
}
if (change.HasDatabaseMigration && !change.HasRollbackPlan)
{
findings.Add(new GateFinding(
FindingSeverity.Blocker,
"migration_without_rollback",
"Database changes need an explicit rollback or roll-forward plan."));
}
// risk scoring and next-action selection use the collected findings.
} The TypeScript version mirrors the C# implementation for Node.js teams. Open the source file.
export const trunkBasedModel: DeliveryModel = {
name: "Trunk-based delivery",
integrationBranch: "main",
releaseBranch: null,
releaseCadenceDays: 1,
typicalBranchAgeDays: 1,
automatedGateCoverage: "high",
rollbackStyle: "toggle or rollback"
}; The same rule set is implemented with TypeScript types and vitest coverage.
export function evaluateChangeSet(change: ChangeSet): DeliveryGateReport {
const findings: GateFinding[] = [];
if (change.targetBranch !== trunkBasedModel.integrationBranch) {
findings.push({
severity: "blocker",
code: "not_integrating_to_trunk",
message: "The change is not integrating against main, so feedback is delayed."
});
}
if (change.branchAgeDays > 2) {
findings.push({
severity: change.branchAgeDays > 5 ? "blocker" : "risk",
code: "stale_branch",
message: "Long-lived branches raise merge risk and hide integration problems."
});
}
for (const check of requiredChecks) {
if (change.checks[check] !== "pass") {
findings.push({
severity: "blocker",
code: `${check}_gate_not_green`,
message: `${check} validation must pass before the change can merge.`
});
}
}
if (change.checks.security !== "pass") {
findings.push({
severity: change.checks.security === "fail" ? "blocker" : "risk",
code: "security_gate_not_green",
message: "Security validation should be automated or explicitly reviewed before merge."
});
}
if (change.hasDatabaseMigration && !change.hasRollbackPlan) {
findings.push({
severity: "blocker",
code: "migration_without_rollback",
message: "Database changes need an explicit rollback or roll-forward plan."
});
}
if (change.linesChanged > 600) {
findings.push({
severity: "risk",
code: "large_batch",
message: "Large batches are harder to review, test, and recover."
});
}
if (!change.hasFeatureFlag) {
findings.push({
severity: "note",
code: "no_feature_flag",
message: "A feature flag would let release timing decouple from merge timing."
});
}
const blockers = findings.filter((finding) => finding.severity === "blocker").length;
const risks = findings.filter((finding) => finding.severity === "risk").length;
const notes = findings.filter((finding) => finding.severity === "note").length;
const riskScore = blockers * 35 + risks * 15 + notes * 2 + Math.min(change.branchAgeDays * 3, 20);
return {
verdict: blockers > 0 ? "blocked" : risks > 0 ? "needs-work" : "ready-to-merge",
riskScore,
findings,
nextAction:
blockers > 0
? "Fix the blocked gates before merging to main."
: risks > 0
? "Reduce the batch or add safeguards before merging."
: "Merge to main and let the deployment pipeline promote the build."
};
} export function compareDeliveryModels(before: DeliveryModel, after: DeliveryModel) {
return {
releaseCadence:
`${before.releaseCadenceDays}-day model -> ${after.releaseCadenceDays}-day model`,
staleChangeWindow:
`${before.typicalBranchAgeDays}-day branch -> ${after.typicalBranchAgeDays}-day branch`,
integrationBranch:
`${before.integrationBranch}${before.releaseBranch ? ` + ${before.releaseBranch}` : ""} -> ${after.integrationBranch}`,
feedbackLoop:
`${before.automatedGateCoverage} automated coverage -> ${after.automatedGateCoverage} automated coverage`,
recovery:
`${before.rollbackStyle} -> ${after.rollbackStyle}`
};
} The reports below are generated from the same gate function shown above. The third scenario is included because trunk-based delivery should still reject weak validation. Moving faster only helps if the gate still says no.
unit: pass · integration: manual · contract: missing · security: manual · smoke: missing
{
"id": "release-branch-pileup",
"title": "Monthly release branch with stale feature work",
"targetBranch": "release/2026.05",
"branchAgeDays": 21,
"linesChanged": 1800,
"hasFeatureFlag": false,
"hasDatabaseMigration": true,
"hasRollbackPlan": false,
"checks": {
"unit": "pass",
"integration": "manual",
"contract": "missing",
"security": "manual",
"smoke": "missing"
}
} {
"verdict": "blocked",
"riskScore": 262,
"findings": [
{
"severity": "blocker",
"code": "not_integrating_to_trunk",
"message": "The change is not integrating against main, so feedback is delayed."
},
{
"severity": "blocker",
"code": "stale_branch",
"message": "Long-lived branches raise merge risk and hide integration problems."
},
{
"severity": "blocker",
"code": "integration_gate_not_green",
"message": "integration validation must pass before the change can merge."
},
{
"severity": "blocker",
"code": "contract_gate_not_green",
"message": "contract validation must pass before the change can merge."
},
{
"severity": "blocker",
"code": "smoke_gate_not_green",
"message": "smoke validation must pass before the change can merge."
},
{
"severity": "risk",
"code": "security_gate_not_green",
"message": "Security validation should be automated or explicitly reviewed before merge."
},
{
"severity": "blocker",
"code": "migration_without_rollback",
"message": "Database changes need an explicit rollback or roll-forward plan."
},
{
"severity": "risk",
"code": "large_batch",
"message": "Large batches are harder to review, test, and recover."
},
{
"severity": "note",
"code": "no_feature_flag",
"message": "A feature flag would let release timing decouple from merge timing."
}
],
"nextAction": "Fix the blocked gates before merging to main."
} it("blocks stale release-branch work that delays integration feedback", () => {
const report = evaluateChangeSet(exampleChangeSets[0]);
expect(report.verdict).toBe("blocked");
expect(report.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "not_integrating_to_trunk" }),
expect.objectContaining({ code: "stale_branch" }),
expect.objectContaining({ code: "contract_gate_not_green" }),
expect.objectContaining({ code: "migration_without_rollback" })
])
);
}); unit: pass · integration: pass · contract: pass · security: pass · smoke: pass
{
"id": "short-lived-main-pr",
"title": "Short-lived branch merging into main",
"targetBranch": "main",
"branchAgeDays": 1,
"linesChanged": 180,
"hasFeatureFlag": true,
"hasDatabaseMigration": false,
"hasRollbackPlan": true,
"checks": {
"unit": "pass",
"integration": "pass",
"contract": "pass",
"security": "pass",
"smoke": "pass"
}
} {
"verdict": "ready-to-merge",
"riskScore": 3,
"findings": [],
"nextAction": "Merge to main and let the deployment pipeline promote the build."
} it("allows a short-lived mainline change when automated gates are green", () => {
const report = evaluateChangeSet(exampleChangeSets[1]);
expect(report).toMatchObject({
verdict: "ready-to-merge",
riskScore: 3,
nextAction: "Merge to main and let the deployment pipeline promote the build."
});
expect(report.findings).toHaveLength(0);
}); unit: pass · integration: missing · contract: missing · security: pass · smoke: missing
{
"id": "reckless-main-change",
"title": "Direct-to-main change without enough gates",
"targetBranch": "main",
"branchAgeDays": 0,
"linesChanged": 95,
"hasFeatureFlag": false,
"hasDatabaseMigration": false,
"hasRollbackPlan": true,
"checks": {
"unit": "pass",
"integration": "missing",
"contract": "missing",
"security": "pass",
"smoke": "missing"
}
} {
"verdict": "blocked",
"riskScore": 107,
"findings": [
{
"severity": "blocker",
"code": "integration_gate_not_green",
"message": "integration validation must pass before the change can merge."
},
{
"severity": "blocker",
"code": "contract_gate_not_green",
"message": "contract validation must pass before the change can merge."
},
{
"severity": "blocker",
"code": "smoke_gate_not_green",
"message": "smoke validation must pass before the change can merge."
},
{
"severity": "note",
"code": "no_feature_flag",
"message": "A feature flag would let release timing decouple from merge timing."
}
],
"nextAction": "Fix the blocked gates before merging to main."
} it("blocks a mainline change when trunk-based delivery is missing validation", () => {
const report = evaluateChangeSet(exampleChangeSets[2]);
expect(report.verdict).toBe("blocked");
expect(report.findings.map((finding) => finding.code)).toEqual(
expect.arrayContaining([
"integration_gate_not_green",
"contract_gate_not_green",
"smoke_gate_not_green"
])
);
}); The important shift is when validation runs. Pull requests to main get the same gates that pushes to main get. When main is green, the team has a deployable build instead of a pile of work waiting for release stabilization.
The companion repository runs dotnet test against the C# gate, then runs a
scenario project to print the delivery reports.
The companion repository includes this workflow at .github/workflows/ci.yml.
name: delivery-gate
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: npm ci
- run: npm test
- run: npm run build
- run: npm run scenario
- run: npm run dotnet:test
- run: npm run dotnet:scenario
Blocks delayed integration, missing gates, and migration risk.
blocks stale release-branch work that delays integration feedback Allows a small change when automated validation is green.
allows a short-lived mainline change when automated gates are green Shows that trunk-based delivery still blocks unvalidated work.
blocks a mainline change when trunk-based delivery is missing validation Full C# test source: DeliveryGateTests.cs
public void BlocksMainlineChangeWhenTrunkBasedDeliveryIsMissingValidation()
{
var report = DeliveryGate.Evaluate(Scenarios.RecklessMainChange);
var codes = report.Findings.Select(finding => finding.Code).ToHashSet();
Assert.Equal(DeliveryVerdict.Blocked, report.Verdict);
Assert.Contains("integration_gate_not_green", codes);
Assert.Contains("contract_gate_not_green", codes);
Assert.Contains("smoke_gate_not_green", codes);
} import { describe, expect, it } from "vitest";
import {
compareDeliveryModels,
evaluateChangeSet,
exampleChangeSets,
releaseBranchModel,
trunkBasedModel
} from "./gates";
describe("delivery modernization gates", () => {
// snippet:test-stale-release-branch-start
it("blocks stale release-branch work that delays integration feedback", () => {
const report = evaluateChangeSet(exampleChangeSets[0]);
expect(report.verdict).toBe("blocked");
expect(report.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "not_integrating_to_trunk" }),
expect.objectContaining({ code: "stale_branch" }),
expect.objectContaining({ code: "contract_gate_not_green" }),
expect.objectContaining({ code: "migration_without_rollback" })
])
);
});
// snippet:test-stale-release-branch-end
// snippet:test-short-lived-main-start
it("allows a short-lived mainline change when automated gates are green", () => {
const report = evaluateChangeSet(exampleChangeSets[1]);
expect(report).toMatchObject({
verdict: "ready-to-merge",
riskScore: 3,
nextAction: "Merge to main and let the deployment pipeline promote the build."
});
expect(report.findings).toHaveLength(0);
});
// snippet:test-short-lived-main-end
// snippet:test-trunk-is-not-reckless-start
it("blocks a mainline change when trunk-based delivery is missing validation", () => {
const report = evaluateChangeSet(exampleChangeSets[2]);
expect(report.verdict).toBe("blocked");
expect(report.findings.map((finding) => finding.code)).toEqual(
expect.arrayContaining([
"integration_gate_not_green",
"contract_gate_not_green",
"smoke_gate_not_green"
])
);
});
// snippet:test-trunk-is-not-reckless-end
it("flags manual security validation as risk instead of silently accepting it", () => {
const report = evaluateChangeSet({
...exampleChangeSets[1],
checks: {
...exampleChangeSets[1].checks,
security: "manual"
}
});
expect(report.verdict).toBe("needs-work");
expect(report.findings).toContainEqual(
expect.objectContaining({
severity: "risk",
code: "security_gate_not_green"
})
);
});
it("summarizes the before and after delivery model in evaluator-friendly terms", () => {
expect(compareDeliveryModels(releaseBranchModel, trunkBasedModel)).toEqual({
releaseCadence: "30-day model -> 1-day model",
staleChangeWindow: "18-day branch -> 1-day branch",
integrationBranch: "dev + release/* -> main",
feedbackLoop: "medium automated coverage -> high automated coverage",
recovery: "manual coordination -> toggle or rollback"
});
});
});