CI/CD case study

Moving release risk closer to the change.

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.

Skill
CI/CD modernization
Change
Release branches → main
Cadence model
Monthly → daily
Checks
Gate code · YAML · tests
Language
C#/.NET · TypeScript
Source files Show files in the companion repository

C#/.NET primary path

TypeScript parallel path

Shared delivery gate

01 — Problem

Slow releases are usually a feedback problem.

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.

  • Stale changes wait days or weeks before meeting the rest of the system.
  • Testing happens near the release window instead of at the point of change.
  • Bug fixes compete with release coordination instead of flowing independently.
  • Business value waits for the train even when the code is already useful.
02 — Options

Branching choices are tradeoffs, not slogans.

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.

Use short-lived feature branches into main

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.

Commit directly to main

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.

03 — Chosen approach

Short-lived branches into main, backed by gates that mean something.

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.

  • 01

    Main is the integration branch and has to stay deployable.

  • 02

    Feature branches are fine when they are short-lived and reviewed quickly.

  • 03

    Every merge runs unit, integration, contract, and smoke validation.

  • 04

    Release timing stays separate from merge timing through feature flags or promotion controls.

  • 05

    Database changes need a rollback or roll-forward path before the merge is accepted.

04 — Workflow

The delivery path changes where risk shows up.

Before

Release-branch delivery

  1. Developers branch from dev or a release branch.
  2. Work accumulates until the release window.
  3. Integration, regression, and deployment risk concentrate near the end.
  4. Business feedback waits for the release train.

After

Trunk-based delivery

  1. Developers make small changes against main.
  2. Automated gates reject unsafe changes early.
  3. Main remains deployable even when unreleased work is hidden behind flags.
  4. Release decisions become smaller and easier to reverse.
Release cadence
30-day model -> 1-day model
Stale-change window
18-day branch -> 1-day branch
Integration branch
dev + release/* -> main
Feedback loop
medium automated coverage -> high automated coverage
Recovery
manual coordination -> toggle or rollback
05 — Implementation

The gate makes the policy executable.

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.

DeliveryGate.cs

Define what healthy trunk-based delivery means.

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");
DeliveryGate.cs

Reject stale, unvalidated, or unsafe changes before merge.

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.
}
delivery-gates.ts

The same delivery policy in TypeScript.

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"
};
delivery-gates.ts

Reject stale, unvalidated, or unsafe changes before merge.

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."
  };
}
Show the model comparison helper
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}`
  };
}
06 — Scenarios

Three scenarios show the difference between fast and reckless.

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.

Choose a delivery scenario
Blocked

Monthly release branch with stale feature work

unit: pass · integration: manual · contract: missing · security: manual · smoke: missing

Change shape

{
  "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"
  }
}

Gate result

{
  "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."
}
Show the test that locks this scenario in
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" })
      ])
    );
  });
Ready To Merge

Short-lived branch merging into main

unit: pass · integration: pass · contract: pass · security: pass · smoke: pass

Change shape

{
  "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"
  }
}

Gate result

{
  "verdict": "ready-to-merge",
  "riskScore": 3,
  "findings": [],
  "nextAction": "Merge to main and let the deployment pipeline promote the build."
}
Show the test that locks this scenario in
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);
  });
Blocked

Direct-to-main change without enough gates

unit: pass · integration: missing · contract: missing · security: pass · smoke: missing

Change shape

{
  "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"
  }
}

Gate result

{
  "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."
}
Show the test that locks this scenario in
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"
      ])
    );
  });
07 — Pipeline

The pipeline protects main instead of preparing a late release branch.

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.

For a .NET service

The companion repository runs dotnet test against the C# gate, then runs a scenario project to print the delivery reports.

GitHub Actions

A minimal mainline delivery gate.

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
08 — Tests

Tests cover the delivery rule, not just the code path.

Scenario Behavior Test
Stale release branch

Blocks delayed integration, missing gates, and migration risk.

blocks stale release-branch work that delays integration feedback
Short-lived main PR

Allows a small change when automated validation is green.

allows a short-lived mainline change when automated gates are green
Reckless main change

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

Show the C# test excerpt and TypeScript test file
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"
    });
  });
});
09 — Tradeoffs

What gets better, and what it does not magically solve.

Business impact

  • Smaller batches reduce stale changes and late-release surprises.
  • Automated gates catch bugs closer to the developer who made the change.
  • Deployable main shortens time to market without forcing every change to be visible immediately.
  • Rollback and feature flags reduce the anxiety around release windows.

Production notes

  • Branch policy. Protect main, require green checks, and keep bypass rights rare and visible.
  • Feature flags. Use flags when merge timing needs to stay separate from customer-visible release timing.
  • Release observability. Treat deployment as an observable event with smoke checks, logs, metrics, and rollback signals.
  • Team agreement. Trunk-based delivery works when engineers agree on batch size, review speed, and test ownership.

What this case leaves out

  • Team training and branch-protection rollout for the actual switch.
  • Observability dashboards for deployment frequency, lead time, and rollback.
  • Environment promotion policy for staged release decoupling.
  • A migration plan so the delivery model changes without surprising the business.

Related pages.