Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Scenario 1: Monorepo with Multiple Services

A monorepo containing three services, each with its own test suite, Docker image, and deployment:

public class MonorepoPipelineBuilder
{
    private readonly string[] _services;
    private readonly string _registry;

    public MonorepoPipelineBuilder(string[] services, string registry)
    {
        _services = services;
        _registry = registry;
    }

    public GitLabCiFile Build()
    {
        var file = new GitLabCiFile
        {
            Stages = new List<object>
            {
                "prepare", "build", "test", "docker", "deploy-staging", "deploy-prod"
            },
            Variables = new Dictionary<string, object?>
            {
                ["DOCKER_DRIVER"] = "overlay2",
                ["DOCKER_TLS_CERTDIR"] = "",
                ["GIT_STRATEGY"] = "fetch",
                ["GIT_DEPTH"] = "10"
            },
            Default = new GitLabCiDefault
            {
                Tags = new List<object> { "docker", "linux" },
                BeforeScript = new List<string>
                {
                    "echo \"Running on $(hostname) at $(date)\""
                }
            },
            Jobs = new Dictionary<string, GitLabCiJob>()
        };

        // Shared: dependency resolution
        file.Jobs["restore-dependencies"] = new GitLabCiJob
        {
            Image = "mcr.microsoft.com/dotnet/sdk:9.0",
            Script = new List<string>
            {
                "dotnet restore --packages .nuget-packages"
            },
            Cache = new List<object>
            {
                new GitLabCiCacheItem
                {
                    Key = "$CI_COMMIT_REF_SLUG-nuget",
                    Paths = new List<string> { ".nuget-packages/" },
                    Policy = "pull-push"
                }
            },
            Artifacts = new GitLabCiArtifacts
            {
                Paths = new List<string> { ".nuget-packages/" },
                ExpireIn = "1 hour"
            }
        };

        // Per-service jobs
        foreach (var service in _services)
        {
            var serviceDir = $"src/{service}";
            var imageName = $"{_registry}/{service.ToLowerInvariant()}";

            // Build
            file.Jobs[$"build-{service.ToLowerInvariant()}"] = new GitLabCiJob
            {
                Image = "mcr.microsoft.com/dotnet/sdk:9.0",
                Script = new List<string>
                {
                    $"dotnet build {serviceDir} -c Release --no-restore " +
                    "--packages .nuget-packages"
                },
                Artifacts = new GitLabCiArtifacts
                {
                    Paths = new List<string>
                    {
                        $"{serviceDir}/bin/",
                        $"{serviceDir}/obj/"
                    },
                    ExpireIn = "2 hours"
                },
                Needs = new List<GitLabCiJobTemplateNeedsConfig>
                {
                    new() { Job = "restore-dependencies" }
                }
            };

            // Test
            file.Jobs[$"test-{service.ToLowerInvariant()}"] = new GitLabCiJob
            {
                Image = "mcr.microsoft.com/dotnet/sdk:9.0",
                Script = new List<string>
                {
                    $"dotnet test {serviceDir}.Tests -c Release --no-build " +
                    "--logger trx --collect:\"XPlat Code Coverage\" " +
                    "--results-directory TestResults"
                },
                Artifacts = new GitLabCiArtifacts
                {
                    Reports = new GitLabCiArtifactsReports
                    {
                        Junit = new List<string> { "TestResults/**/*.trx" },
                        CoverageReport = new List<string>
                        {
                            "TestResults/**/coverage.cobertura.xml"
                        }
                    },
                    ExpireIn = "30 days"
                },
                Needs = new List<GitLabCiJobTemplateNeedsConfig>
                {
                    new() { Job = $"build-{service.ToLowerInvariant()}" }
                },
                Coverage = "/Total\\s*\\|\\s*(\\d+(?:\\.\\d+)?)/"
            };

            // Docker build
            file.Jobs[$"docker-{service.ToLowerInvariant()}"] = new GitLabCiJob
            {
                Image = "docker:24",
                Script = new List<string>
                {
                    "docker login -u $CI_REGISTRY_USER " +
                    "-p $CI_REGISTRY_PASSWORD $CI_REGISTRY",
                    $"docker build -t {imageName}:$CI_COMMIT_SHORT_SHA " +
                    $"-f {serviceDir}/Dockerfile .",
                    $"docker push {imageName}:$CI_COMMIT_SHORT_SHA"
                },
                Tags = new List<object> { "docker", "dind" },
                Needs = new List<GitLabCiJobTemplateNeedsConfig>
                {
                    new() { Job = $"test-{service.ToLowerInvariant()}" }
                }
            };

            // Deploy staging (auto)
            file.Jobs[$"deploy-staging-{service.ToLowerInvariant()}"] = new GitLabCiJob
            {
                Image = "bitnami/kubectl:latest",
                Script = new List<string>
                {
                    "kubectl config use-context $STAGING_CONTEXT",
                    $"kubectl set image deployment/{service.ToLowerInvariant()} " +
                    $"app={imageName}:$CI_COMMIT_SHORT_SHA " +
                    "-n staging",
                    $"kubectl rollout status deployment/{service.ToLowerInvariant()} " +
                    "-n staging --timeout=300s"
                },
                Environment = new GitLabCiJobTemplateEnvironmentConfig
                {
                    Name = $"staging/{service.ToLowerInvariant()}",
                    Url = $"https://staging-{service.ToLowerInvariant()}.example.com"
                },
                Needs = new List<GitLabCiJobTemplateNeedsConfig>
                {
                    new() { Job = $"docker-{service.ToLowerInvariant()}" }
                }
            };

            // Deploy production (manual)
            file.Jobs[$"deploy-prod-{service.ToLowerInvariant()}"] = new GitLabCiJob
            {
                Image = "bitnami/kubectl:latest",
                Script = new List<string>
                {
                    "kubectl config use-context $PRODUCTION_CONTEXT",
                    $"kubectl set image deployment/{service.ToLowerInvariant()} " +
                    $"app={imageName}:$CI_COMMIT_SHORT_SHA " +
                    "-n production",
                    $"kubectl rollout status deployment/{service.ToLowerInvariant()} " +
                    "-n production --timeout=600s"
                },
                When = "manual",
                AllowFailure = false,
                Environment = new GitLabCiJobTemplateEnvironmentConfig
                {
                    Name = $"production/{service.ToLowerInvariant()}",
                    Url = $"https://{service.ToLowerInvariant()}.example.com"
                },
                Needs = new List<GitLabCiJobTemplateNeedsConfig>
                {
                    new()
                    {
                        Job = $"deploy-staging-{service.ToLowerInvariant()}"
                    }
                }
            };
        }

        return file;
    }
}

Usage:

var builder = new MonorepoPipelineBuilder(
    services: new[] { "UserService", "OrderService", "PaymentService" },
    registry: "registry.example.com/myapp");

var pipeline = builder.Build();
var yaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(".gitlab-ci.yml", yaml);

This generates 16 jobs (1 shared + 5 per service x 3 services) with a full DAG of dependencies:

Diagram
Sixteen jobs — one shared restore plus five per service across three services — laid out as a full DAG that the MonorepoPipelineBuilder emits in one Build call.

Scenario 2: Library Release Pipeline

A NuGet library with version management, changelog generation, and multi-target testing:

public class LibraryReleasePipelineBuilder
{
    private readonly string _packageName;
    private readonly string[] _targetFrameworks;

    public LibraryReleasePipelineBuilder(
        string packageName,
        string[] targetFrameworks)
    {
        _packageName = packageName;
        _targetFrameworks = targetFrameworks;
    }

    public GitLabCiFile Build()
    {
        var file = new GitLabCiFile
        {
            Stages = new List<object>
            {
                "build", "test", "quality", "pack", "publish"
            },
            Variables = new Dictionary<string, object?>
            {
                ["CONFIGURATION"] = "Release",
                ["PACKAGE_OUTPUT_DIR"] = "nupkg/"
            },
            Default = new GitLabCiDefault
            {
                Image = "mcr.microsoft.com/dotnet/sdk:9.0"
            },
            Jobs = new Dictionary<string, GitLabCiJob>()
        };

        // Build
        file.Jobs["build"] = new GitLabCiJob
        {
            Script = new List<string>
            {
                "dotnet restore",
                "dotnet build -c $CONFIGURATION --no-restore"
            },
            Artifacts = new GitLabCiArtifacts
            {
                Paths = new List<string> { "src/*/bin/", "src/*/obj/" },
                ExpireIn = "1 day"
            }
        };

        // Test per framework
        foreach (var framework in _targetFrameworks)
        {
            var sdkVersion = framework switch
            {
                "net8.0" => "8.0",
                "net9.0" => "9.0",
                "net10.0" => "10.0",
                _ => "9.0"
            };

            file.Jobs[$"test-{framework}"] = new GitLabCiJob
            {
                Image = $"mcr.microsoft.com/dotnet/sdk:{sdkVersion}",
                Script = new List<string>
                {
                    $"dotnet test -f {framework} -c $CONFIGURATION --no-build " +
                    "--logger trx --collect:\"XPlat Code Coverage\""
                },
                Artifacts = new GitLabCiArtifacts
                {
                    Reports = new GitLabCiArtifactsReports
                    {
                        Junit = new List<string> { "**/*.trx" },
                        CoverageReport = new List<string>
                        {
                            "**/coverage.cobertura.xml"
                        }
                    }
                },
                Needs = new List<GitLabCiJobTemplateNeedsConfig>
                {
                    new() { Job = "build" }
                },
                AllowFailure = framework == "net10.0"
            };
        }

        // Code quality analysis
        file.Jobs["format-check"] = new GitLabCiJob
        {
            Script = new List<string>
            {
                "dotnet format --verify-no-changes --severity warn"
            },
            AllowFailure = true,
            Needs = new List<GitLabCiJobTemplateNeedsConfig>
            {
                new() { Job = "build" }
            }
        };

        file.Jobs["analyzers"] = new GitLabCiJob
        {
            Script = new List<string>
            {
                "dotnet build -c $CONFIGURATION " +
                "/p:TreatWarningsAsErrors=true /warnaserror"
            },
            AllowFailure = true,
            Needs = new List<GitLabCiJobTemplateNeedsConfig>
            {
                new() { Job = "build" }
            }
        };

        // Pack NuGet
        file.Jobs["pack"] = new GitLabCiJob
        {
            Script = new List<string>
            {
                "dotnet pack -c $CONFIGURATION --no-build " +
                "-o $PACKAGE_OUTPUT_DIR " +
                "/p:PackageVersion=$CI_COMMIT_TAG"
            },
            Artifacts = new GitLabCiArtifacts
            {
                Paths = new List<string> { "$PACKAGE_OUTPUT_DIR/" },
                ExpireIn = "30 days"
            },
            Needs = _targetFrameworks
                .Select(f => new GitLabCiJobTemplateNeedsConfig
                {
                    Job = $"test-{f}"
                })
                .ToList()
        };

        // Publish to NuGet (tag-triggered, manual)
        file.Jobs["publish-nuget"] = new GitLabCiJob
        {
            Script = new List<string>
            {
                "dotnet nuget push $PACKAGE_OUTPUT_DIR/*.nupkg " +
                "--source https://api.nuget.org/v3/index.json " +
                "--api-key $NUGET_API_KEY"
            },
            When = "manual",
            Environment = new GitLabCiJobTemplateEnvironmentConfig
            {
                Name = "nuget-org",
                Url = $"https://www.nuget.org/packages/{_packageName}/"
            },
            Needs = new List<GitLabCiJobTemplateNeedsConfig>
            {
                new() { Job = "pack" }
            }
        };

        // Publish to internal feed (automatic)
        file.Jobs["publish-internal"] = new GitLabCiJob
        {
            Script = new List<string>
            {
                "dotnet nuget push $PACKAGE_OUTPUT_DIR/*.nupkg " +
                "--source $INTERNAL_FEED_URL " +
                "--api-key $INTERNAL_FEED_KEY"
            },
            Environment = new GitLabCiJobTemplateEnvironmentConfig
            {
                Name = "internal-feed"
            },
            Needs = new List<GitLabCiJobTemplateNeedsConfig>
            {
                new() { Job = "pack" }
            }
        };

        return file;
    }
}

Usage:

var pipeline = new LibraryReleasePipelineBuilder(
    packageName: "FrenchExDev.Net.GitLab.Ci.Yaml",
    targetFrameworks: new[] { "net8.0", "net9.0", "net10.0" }
).Build();

var yaml = GitLabCiYamlWriter.Serialize(pipeline);

Scenario 3: Pipeline-as-Code Generator CLI

Build a CLI tool that generates .gitlab-ci.yml files for your organization:

public static class PipelineGeneratorCli
{
    public static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("Generate .gitlab-ci.yml pipelines");

        var typeOption = new Option<string>(
            "--type", "Project type: service, library, frontend")
        {
            IsRequired = true
        };

        var nameOption = new Option<string>(
            "--name", "Project name")
        {
            IsRequired = true
        };

        var outputOption = new Option<string>(
            "--output", () => ".gitlab-ci.yml",
            "Output file path");

        var registryOption = new Option<string>(
            "--registry", () => "registry.example.com",
            "Docker registry URL");

        rootCommand.AddOption(typeOption);
        rootCommand.AddOption(nameOption);
        rootCommand.AddOption(outputOption);
        rootCommand.AddOption(registryOption);

        rootCommand.SetHandler((type, name, output, registry) =>
        {
            var pipeline = type switch
            {
                "service" => BuildServicePipeline(name, registry),
                "library" => BuildLibraryPipeline(name),
                "frontend" => BuildFrontendPipeline(name, registry),
                _ => throw new ArgumentException(
                    $"Unknown project type: {type}")
            };

            var yaml = GitLabCiYamlWriter.Serialize(pipeline);
            File.WriteAllText(output, yaml);

            Console.WriteLine($"Generated {output}:");
            Console.WriteLine($"  Stages: {pipeline.Stages?.Count ?? 0}");
            Console.WriteLine($"  Jobs: {pipeline.Jobs?.Count ?? 0}");
            Console.WriteLine(
                $"  Variables: {pipeline.Variables?.Count ?? 0}");
        },
        typeOption, nameOption, outputOption, registryOption);

        return await rootCommand.InvokeAsync(args);
    }

    private static GitLabCiFile BuildServicePipeline(
        string name, string registry)
    {
        return new GitLabCiFile()
            .Apply(new DotNetBuildContributor())
            .Apply(new DotNetTestContributor())
            .Apply(new DockerBuildContributor($"{registry}/{name}:latest"))
            .Apply(new MultiEnvironmentDeployContributor(name, registry));
    }

    private static GitLabCiFile BuildLibraryPipeline(string name)
    {
        return new LibraryReleasePipelineBuilder(
            name,
            new[] { "net8.0", "net9.0" }
        ).Build();
    }

    private static GitLabCiFile BuildFrontendPipeline(
        string name, string registry)
    {
        var file = new GitLabCiFile
        {
            Stages = new List<object>
            {
                "install", "lint", "test", "build", "deploy"
            },
            Default = new GitLabCiDefault
            {
                Image = "node:20-alpine"
            },
            Variables = new Dictionary<string, object?>
            {
                ["NPM_CONFIG_CACHE"] = ".npm-cache"
            },
            Jobs = new Dictionary<string, GitLabCiJob>
            {
                ["install"] = new GitLabCiJob
                {
                    Script = new List<string> { "npm ci" },
                    Artifacts = new GitLabCiArtifacts
                    {
                        Paths = new List<string> { "node_modules/" },
                        ExpireIn = "1 hour"
                    }
                },
                ["lint"] = new GitLabCiJob
                {
                    Script = new List<string>
                    {
                        "npm run lint",
                        "npm run type-check"
                    },
                    Needs = new List<GitLabCiJobTemplateNeedsConfig>
                    {
                        new() { Job = "install" }
                    }
                },
                ["test"] = new GitLabCiJob
                {
                    Script = new List<string>
                    {
                        "npm run test -- --coverage --reporters=default " +
                        "--reporters=jest-junit"
                    },
                    Artifacts = new GitLabCiArtifacts
                    {
                        Reports = new GitLabCiArtifactsReports
                        {
                            Junit = new List<string> { "junit.xml" },
                            CoverageReport = new List<string>
                            {
                                "coverage/cobertura-coverage.xml"
                            }
                        }
                    },
                    Needs = new List<GitLabCiJobTemplateNeedsConfig>
                    {
                        new() { Job = "install" }
                    }
                },
                ["build"] = new GitLabCiJob
                {
                    Script = new List<string> { "npm run build" },
                    Artifacts = new GitLabCiArtifacts
                    {
                        Paths = new List<string> { "dist/" },
                        ExpireIn = "7 days"
                    },
                    Needs = new List<GitLabCiJobTemplateNeedsConfig>
                    {
                        new() { Job = "lint" },
                        new() { Job = "test" }
                    }
                },
                ["deploy-cdn"] = new GitLabCiJob
                {
                    Script = new List<string>
                    {
                        "aws s3 sync dist/ s3://$S3_BUCKET/ --delete",
                        "aws cloudfront create-invalidation " +
                        "--distribution-id $CF_DIST_ID --paths '/*'"
                    },
                    When = "manual",
                    Environment = new GitLabCiJobTemplateEnvironmentConfig
                    {
                        Name = "production",
                        Url = $"https://{name}.example.com"
                    },
                    Needs = new List<GitLabCiJobTemplateNeedsConfig>
                    {
                        new() { Job = "build" }
                    }
                }
            }
        };

        return file;
    }
}

Usage:

# Generate a service pipeline
dotnet run -- --type service --name my-api --registry registry.example.com

# Generate a library pipeline
dotnet run -- --type library --name MyLib

# Generate a frontend pipeline
dotnet run -- --type frontend --name my-spa --output ci/gitlab-ci.yml

Deep Dive: Schema Evolution Across GitLab Versions

Understanding how the schema changes between versions illustrates why multi-version support matters.

Version 18.0 → 18.5: The run Keyword

GitLab 18.5 introduced the run: keyword as an alternative to script:. This represents a fundamental shift in how jobs define their execution:

# Traditional (script)
build:
  script:
    - npm ci
    - npm run build

# New (run) — GitLab 18.5+
build:
  run:
    - exec:
        run: npm ci
    - exec:
        run: npm run build

In the generated code, the Run property appears with a version annotation:

/// <summary>
/// Specifies a list of steps to execute in the job.
/// The `run` keyword is an alternative to `script`.
/// </summary>
public global::System.Collections.Generic.List<object>? Run { get; set; }

Without multi-version merging, a consumer targeting GitLab 18.3 wouldn't know that Run doesn't exist in their version. With it, they can check programmatically.

Version 18.5 → 18.6: Steps, Inputs, and Manual Confirmation

GitLab 18.6 added several properties:

Property Type Purpose
inputs (on jobs) Dictionary<string, object?>? Job-level input parameters
manual_confirmation string? Custom message for manual jobs
Step types Various step_exec, step_git_reference, step_oci_reference

These all appear in the generated code with [SinceVersion("18.6.0")].

Tracking the Evolution

Diagram
The 18.x line's evolution in one glance — most minors only refine descriptions, while 18.5 brings the run/step family and 18.6 adds job-level inputs and manual_confirmation.

Impact on Generated Code

The version merger ensures that:

  1. All properties from all versions are present — the unified model is the superset
  2. Version annotations are accurate[SinceVersion] marks when each property appeared
  3. Latest type wins — if a property's type changed between versions, the latest definition is used
  4. No properties are removed — adding a new version only adds, never removes

This means a single compiled library supports every GitLab version from 18.0 to 18.10, with version-aware annotations for properties that changed along the way.


Deep Dive: The Extensions Dictionary Pattern

Every generated model class includes an Extensions dictionary:

public Dictionary<string, object?>? Extensions { get; set; }

This serves three purposes:

1. Forward Compatibility

When GitLab adds new root-level keywords before the schema is updated, they're stored in Extensions rather than lost:

var file = new GitLabCiFile
{
    Stages = new List<object> { "build" },
    // New keyword not yet in the schema
    Extensions = new Dictionary<string, object?>
    {
        ["new_experimental_keyword"] = new Dictionary<string, object?>
        {
            ["enabled"] = true,
            ["mode"] = "aggressive"
        }
    }
};

The writer includes extensions in the output:

stages:
- build
new_experimental_keyword:
  enabled: true
  mode: aggressive

2. Custom Root Properties

Organizations often use custom root-level YAML keys processed by their CI infrastructure:

var file = new GitLabCiFile
{
    Extensions = new Dictionary<string, object?>
    {
        // Custom organizational metadata
        ["x-team"] = "platform",
        ["x-oncall-channel"] = "#platform-oncall",
        ["x-cost-center"] = "eng-infrastructure"
    }
};

3. Template Definitions

GitLab CI templates (keys starting with .) can be stored in extensions for the writer to emit:

var file = new GitLabCiFile
{
    Extensions = new Dictionary<string, object?>
    {
        [".base-job"] = new Dictionary<string, object?>
        {
            ["image"] = "node:20",
            ["tags"] = new List<string> { "docker" }
        }
    },
    Jobs = new Dictionary<string, GitLabCiJob>
    {
        ["build"] = new GitLabCiJob
        {
            Extends = new List<string> { ".base-job" },
            Script = new List<string> { "npm ci" }
        }
    }
};

Output:

.base-job:
  image: node:20
  tags:
  - docker
build:
  extends:
  - .base-job
  script:
  - npm ci

Summary: What You Get

GitLab.Ci.Yaml transforms GitLab's official CI JSON Schema into a first-class C# API:

Feature What You Get
30 typed model classes Every GitLab CI concept with IntelliSense
30 fluent builders Validated, composable pipeline construction
YAML round-trip Read, modify, and write .gitlab-ci.yml files
11 version support GitLab 18.0 through 18.10 in a single API
Version annotations [SinceVersion]/[UntilVersion] on every property
Contributor pattern Modular, reusable pipeline fragments
Zero runtime cost All code generated at compile time
Automatic updates Run Design CLI + rebuild for new GitLab versions
20 tests Models, serialization, deserialization, version parsing
Comprehensive docs Architecture, how-to, and philosophy documentation

The library demonstrates that schema-driven code generation is the right approach for complex, evolving configuration formats. Instead of hand-writing and maintaining models, let the official schema drive your API. When the schema evolves, your code evolves automatically.

Diagram
The whole library on one page — two inputs (the official schema and a marker attribute) drive a Roslyn generator that produces every surface C# consumers actually touch.

Deep Dive: Generating Pipelines at Scale

When managing dozens or hundreds of projects, the typed API becomes a force multiplier. Instead of copy-pasting YAML templates and hoping for consistency, you build a shared library of pipeline generators.

Organization-Wide Pipeline Standards

/// <summary>
/// Defines organization-wide pipeline standards.
/// Every project pipeline must pass through these checks.
/// </summary>
public static class OrganizationPipelineStandards
{
    /// <summary>
    /// Applies mandatory security and compliance checks to any pipeline.
    /// </summary>
    public static GitLabCiFile ApplySecurityBaseline(this GitLabCiFile file)
    {
        file.Stages ??= new List<object>();

        // Ensure security stage exists
        if (!file.Stages.Contains("security"))
        {
            // Insert before deploy stages
            var deployIdx = file.Stages
                .Cast<string>()
                .ToList()
                .FindIndex(s => s.Contains("deploy"));
            if (deployIdx >= 0)
                file.Stages.Insert(deployIdx, "security");
            else
                file.Stages.Add("security");
        }

        file.Jobs ??= new Dictionary<string, GitLabCiJob>();

        // SAST scanning
        file.Jobs["sast-scan"] = new GitLabCiJob
        {
            Image = "registry.example.com/security/sast-scanner:latest",
            Script = new List<string>
            {
                "sast-scan --format gitlab --output gl-sast-report.json"
            },
            Artifacts = new GitLabCiArtifacts
            {
                Reports = new GitLabCiArtifactsReports
                {
                    // SAST report for GitLab Security Dashboard
                },
                Paths = new List<string> { "gl-sast-report.json" },
                ExpireIn = "30 days"
            },
            AllowFailure = true
        };

        // Dependency scanning
        file.Jobs["dependency-scan"] = new GitLabCiJob
        {
            Image = "registry.example.com/security/dep-scanner:latest",
            Script = new List<string>
            {
                "dep-scan --lockfiles --format gitlab " +
                "--output gl-dependency-report.json"
            },
            Artifacts = new GitLabCiArtifacts
            {
                Paths = new List<string> { "gl-dependency-report.json" },
                ExpireIn = "30 days"
            },
            AllowFailure = true
        };

        // License compliance
        file.Jobs["license-check"] = new GitLabCiJob
        {
            Image = "registry.example.com/security/license-checker:latest",
            Script = new List<string>
            {
                "license-check --policy strict " +
                "--allow MIT,Apache-2.0,BSD-2-Clause,BSD-3-Clause " +
                "--deny GPL-2.0,GPL-3.0,AGPL-3.0"
            },
            AllowFailure = false // Block pipeline on license violations
        };

        // Container scanning (only if Docker jobs exist)
        var dockerJobs = file.Jobs.Keys
            .Where(k => k.Contains("docker"))
            .ToList();
        if (dockerJobs.Count > 0)
        {
            file.Jobs["container-scan"] = new GitLabCiJob
            {
                Image = "registry.example.com/security/trivy:latest",
                Script = new List<string>
                {
                    "trivy image --severity HIGH,CRITICAL " +
                    "--format gitlab $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA"
                },
                Needs = dockerJobs
                    .Select(j => new GitLabCiJobTemplateNeedsConfig
                    {
                        Job = j
                    })
                    .ToList(),
                AllowFailure = true
            };
        }

        return file;
    }

    /// <summary>
    /// Applies notification hooks for pipeline events.
    /// </summary>
    public static GitLabCiFile ApplyNotifications(
        this GitLabCiFile file, string slackChannel)
    {
        file.Stages ??= new List<object>();
        if (!file.Stages.Contains("notify"))
            file.Stages.Add("notify");

        file.Jobs ??= new Dictionary<string, GitLabCiJob>();

        file.Jobs["notify-success"] = new GitLabCiJob
        {
            Image = "curlimages/curl:latest",
            Script = new List<string>
            {
                $"curl -X POST $SLACK_WEBHOOK_URL " +
                "-H 'Content-Type: application/json' " +
                $"-d '{{\"channel\": \"{slackChannel}\", " +
                "\"text\": \"Pipeline succeeded for " +
                "$CI_PROJECT_NAME ($CI_COMMIT_REF_NAME)\"}}'",
            },
            When = "on_success",
            Dependencies = new List<string>()
        };

        file.Jobs["notify-failure"] = new GitLabCiJob
        {
            Image = "curlimages/curl:latest",
            Script = new List<string>
            {
                $"curl -X POST $SLACK_WEBHOOK_URL " +
                "-H 'Content-Type: application/json' " +
                $"-d '{{\"channel\": \"{slackChannel}\", " +
                "\"text\": \"Pipeline FAILED for " +
                "$CI_PROJECT_NAME ($CI_COMMIT_REF_NAME)\"}}'",
            },
            When = "on_failure",
            Dependencies = new List<string>()
        };

        return file;
    }

    /// <summary>
    /// Validates that a pipeline meets organization requirements.
    /// Returns a list of violations.
    /// </summary>
    public static List<string> ValidateCompliance(GitLabCiFile pipeline)
    {
        var violations = new List<string>();

        // Must have security stage
        if (pipeline.Stages is null ||
            !pipeline.Stages.Cast<string>().Contains("security"))
            violations.Add("Pipeline must include a 'security' stage.");

        // Must have SAST
        if (pipeline.Jobs?.ContainsKey("sast-scan") != true)
            violations.Add("Pipeline must include 'sast-scan' job.");

        // Must have dependency scanning
        if (pipeline.Jobs?.ContainsKey("dependency-scan") != true)
            violations.Add("Pipeline must include 'dependency-scan' job.");

        // Must have license check
        if (pipeline.Jobs?.ContainsKey("license-check") != true)
            violations.Add("Pipeline must include 'license-check' job.");

        // Deploy jobs must be manual
        if (pipeline.Jobs is not null)
        {
            foreach (var (name, job) in pipeline.Jobs)
            {
                if (name.Contains("prod") &&
                    job.Environment?.Name?.Contains("production") == true &&
                    job.When != "manual")
                {
                    violations.Add(
                        $"Job '{name}' deploys to production " +
                        "but is not set to 'when: manual'.");
                }
            }
        }

        // Must have at least one test job with reports
        var hasTestReports = pipeline.Jobs?.Values
            .Any(j => j.Artifacts?.Reports?.Junit is not null) ?? false;
        if (!hasTestReports)
            violations.Add(
                "Pipeline must have at least one job " +
                "producing JUnit test reports.");

        return violations;
    }
}

Complete Organization Workflow

// 1. Build pipeline from project-specific contributors
var pipeline = new GitLabCiFile()
    .Apply(new DotNetBuildContributor())
    .Apply(new DotNetTestContributor())
    .Apply(new DockerBuildContributor("registry.example.com/my-api"))
    .Apply(new MultiEnvironmentDeployContributor("my-api", "registry.example.com"));

// 2. Apply organization-wide security baseline
pipeline.ApplySecurityBaseline();

// 3. Apply notifications
pipeline.ApplyNotifications("#my-team-ci");

// 4. Validate compliance before writing
var violations = OrganizationPipelineStandards.ValidateCompliance(pipeline);
if (violations.Count > 0)
{
    Console.WriteLine("Pipeline compliance violations:");
    foreach (var v in violations)
        Console.WriteLine($"  - {v}");
    Environment.Exit(1);
}

// 5. Write the pipeline
var yaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(".gitlab-ci.yml", yaml);

Console.WriteLine($"Pipeline generated: " +
    $"{pipeline.Stages?.Count} stages, " +
    $"{pipeline.Jobs?.Count} jobs");

This workflow ensures that every pipeline in the organization:

  • Has security scanning (SAST, dependency, license)
  • Has container scanning for Docker-based services
  • Has production deploy gates (manual approval)
  • Has notification hooks
  • Produces test reports
  • Passes compliance validation before being committed
Diagram
Project-specific contributors and organization-wide security/notification helpers assemble one pipeline, then a compliance gate blocks the write if any mandatory stage or job is missing.

Pipeline Version Compatibility Report

Generate a compatibility report for your pipeline against different GitLab versions:

public static class PipelineCompatibilityReport
{
    public static void Generate(GitLabCiFile pipeline, TextWriter output)
    {
        output.WriteLine("# Pipeline Compatibility Report");
        output.WriteLine();
        output.WriteLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC");
        output.WriteLine($"Schema versions: {GitLabCiSchemaVersions.Oldest} " +
                         $"to {GitLabCiSchemaVersions.Latest}");
        output.WriteLine();

        // Check all job properties for version constraints
        var jobType = typeof(GitLabCiJob);
        var versionedProperties = new List<(string Property, string Since)>();

        foreach (var prop in jobType.GetProperties())
        {
            var since = prop.GetCustomAttribute<SinceVersionAttribute>();
            if (since is not null)
                versionedProperties.Add((prop.Name, since.Version));
        }

        // Check which versioned properties are actually used
        output.WriteLine("## Version-Sensitive Properties Used");
        output.WriteLine();
        output.WriteLine("| Property | Required Version | Used In Jobs |");
        output.WriteLine("|----------|:----------------:|-------------|");

        if (pipeline.Jobs is not null)
        {
            foreach (var (propName, sinceVersion) in versionedProperties
                .OrderBy(p => p.Since))
            {
                var prop = jobType.GetProperty(propName);
                if (prop is null) continue;

                var usedInJobs = new List<string>();
                foreach (var (jobName, job) in pipeline.Jobs)
                {
                    var value = prop.GetValue(job);
                    if (value is not null)
                        usedInJobs.Add(jobName);
                }

                if (usedInJobs.Count > 0)
                {
                    output.WriteLine(
                        $"| `{propName}` | {sinceVersion}+ | " +
                        $"{string.Join(", ", usedInJobs)} |");
                }
            }
        }

        // Determine minimum required version
        var minRequired = "18.0.0";
        if (pipeline.Jobs is not null)
        {
            foreach (var (propName, sinceVersion) in versionedProperties)
            {
                var prop = jobType.GetProperty(propName);
                if (prop is null) continue;

                foreach (var job in pipeline.Jobs.Values)
                {
                    if (prop.GetValue(job) is not null)
                    {
                        if (SchemaVersionMerger.CompareVersions(
                            sinceVersion, minRequired) > 0)
                            minRequired = sinceVersion;
                    }
                }
            }
        }

        output.WriteLine();
        output.WriteLine($"## Minimum Required GitLab Version: **{minRequired}**");
        output.WriteLine();

        // Summary
        output.WriteLine("## Pipeline Summary");
        output.WriteLine();
        output.WriteLine($"- **Stages:** {pipeline.Stages?.Count ?? 0}");
        output.WriteLine($"- **Jobs:** {pipeline.Jobs?.Count ?? 0}");
        output.WriteLine($"- **Variables:** {pipeline.Variables?.Count ?? 0}");
        output.WriteLine($"- **Has default config:** " +
                         $"{pipeline.Default is not null}");
        output.WriteLine($"- **Has workflow rules:** " +
                         $"{pipeline.Workflow is not null}");

        if (pipeline.Jobs is not null)
        {
            var manualJobs = pipeline.Jobs
                .Where(j => j.Value.When == "manual")
                .Select(j => j.Key)
                .ToList();
            if (manualJobs.Count > 0)
            {
                output.WriteLine($"- **Manual gates:** " +
                                 $"{string.Join(", ", manualJobs)}");
            }

            var envJobs = pipeline.Jobs
                .Where(j => j.Value.Environment is not null)
                .Select(j => $"{j.Key}{j.Value.Environment.Name}")
                .ToList();
            if (envJobs.Count > 0)
            {
                output.WriteLine($"- **Environment deployments:**");
                foreach (var ej in envJobs)
                    output.WriteLine($"  - {ej}");
            }
        }
    }
}

Usage:

var pipeline = PipelineFactory.CreatePipeline(config);

// Write YAML
var yaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(".gitlab-ci.yml", yaml);

// Write compatibility report
using var reportWriter = new StreamWriter("pipeline-report.md");
PipelineCompatibilityReport.Generate(pipeline, reportWriter);

This generates a markdown report showing which version-sensitive properties your pipeline uses and what minimum GitLab version is required.


Deep Dive: Testing Strategies for Pipeline Generators

When your pipeline definitions are code, they can be tested like code. This section covers strategies for testing contributors, builders, and round-trip serialization.

Testing Contributors in Isolation

Each contributor can be tested independently by applying it to an empty GitLabCiFile and asserting the result:

public class DotNetBuildContributorTests
{
    [Fact]
    public void Adds_build_stage()
    {
        var file = new GitLabCiFile();
        new DotNetBuildContributor().Contribute(file);

        file.Stages.ShouldNotBeNull();
        file.Stages.ShouldContain("build");
    }

    [Fact]
    public void Creates_build_job_with_script()
    {
        var file = new GitLabCiFile();
        new DotNetBuildContributor().Contribute(file);

        file.Jobs.ShouldNotBeNull();
        file.Jobs.ShouldContainKey("dotnet-build");
        file.Jobs["dotnet-build"].Script.ShouldNotBeNull();
        file.Jobs["dotnet-build"].Script!.Count.ShouldBeGreaterThan(0);
    }

    [Fact]
    public void Sets_default_image()
    {
        var file = new GitLabCiFile();
        new DotNetBuildContributor().Contribute(file);

        file.Default.ShouldNotBeNull();
        file.Default!.Image.ShouldNotBeNullOrEmpty();
        file.Default.Image.ShouldContain("dotnet/sdk");
    }

    [Fact]
    public void Does_not_duplicate_stage_when_already_present()
    {
        var file = new GitLabCiFile
        {
            Stages = new List<object> { "build" }
        };
        new DotNetBuildContributor().Contribute(file);

        file.Stages!.Cast<string>()
            .Count(s => s == "build").ShouldBe(1);
    }

    [Fact]
    public void Custom_sdk_image_is_used()
    {
        var file = new GitLabCiFile();
        new DotNetBuildContributor(
            sdkImage: "mcr.microsoft.com/dotnet/sdk:8.0"
        ).Contribute(file);

        file.Default!.Image.ShouldBe(
            "mcr.microsoft.com/dotnet/sdk:8.0");
    }
}

Testing Contributor Composition

Test that contributors compose correctly without conflicts:

public class ContributorCompositionTests
{
    [Fact]
    public void Build_and_test_contributors_compose_cleanly()
    {
        var file = new GitLabCiFile()
            .Apply(new DotNetBuildContributor())
            .Apply(new DotNetTestContributor());

        // Both stages present
        file.Stages.ShouldContain("build");
        file.Stages.ShouldContain("test");

        // Both jobs present
        file.Jobs.ShouldContainKey("dotnet-build");
        file.Jobs.ShouldContainKey("dotnet-test");

        // No duplicate stages
        file.Stages!.Cast<string>().Distinct().Count()
            .ShouldBe(file.Stages.Count);
    }

    [Fact]
    public void All_standard_contributors_compose_without_conflict()
    {
        var file = new GitLabCiFile()
            .Apply(new DotNetBuildContributor())
            .Apply(new DotNetTestContributor())
            .Apply(new DockerBuildContributor("test-image"))
            .Apply(new MultiEnvironmentDeployContributor(
                "test-app", "registry.test"));

        // All stages unique
        var stages = file.Stages!.Cast<string>().ToList();
        stages.Distinct().Count().ShouldBe(stages.Count);

        // All job names unique (no overwrites)
        file.Jobs!.Count.ShouldBeGreaterThanOrEqualTo(5);
    }
}

Testing YAML Round-Trip

Verify that serialization and deserialization are consistent:

public class RoundTripTests
{
    [Fact]
    public void Simple_pipeline_survives_round_trip()
    {
        var original = new GitLabCiFile
        {
            Stages = new List<object> { "build", "test" },
            Jobs = new Dictionary<string, GitLabCiJob>
            {
                ["build"] = new GitLabCiJob
                {
                    Image = "node:20",
                    Script = new List<string> { "npm ci", "npm run build" }
                },
                ["test"] = new GitLabCiJob
                {
                    Script = new List<string> { "npm test" }
                }
            }
        };

        // Serialize
        var yaml = GitLabCiYamlWriter.Serialize(original);

        // Deserialize
        var parsed = GitLabCiYamlReader.Deserialize(yaml);

        // Verify structure preserved
        parsed.Stages!.Count.ShouldBe(2);
        parsed.Jobs!.Count.ShouldBe(2);
        parsed.Jobs["build"].Image.ShouldBe("node:20");
        parsed.Jobs["build"].Script!.Count.ShouldBe(2);
        parsed.Jobs["build"].Script![0].ShouldBe("npm ci");
        parsed.Jobs["test"].Script!.Count.ShouldBe(1);
    }

    [Fact]
    public void Builder_output_serializes_correctly()
    {
        var result = new GitLabCiFileBuilder()
            .WithStages(new List<object> { "build" })
            .WithJob("build", job => job
                .WithScript(new List<string> { "echo hello" }))
            .Build();

        var yaml = GitLabCiYamlWriter.Serialize(result);

        yaml.ShouldContain("stages:");
        yaml.ShouldContain("- build");
        yaml.ShouldContain("build:");
        yaml.ShouldContain("- echo hello");
        yaml.ShouldNotContain("jobs:");
    }

    [Fact]
    public void Contributor_output_matches_expected_yaml()
    {
        var file = new GitLabCiFile()
            .Apply(new DotNetBuildContributor());

        var yaml = GitLabCiYamlWriter.Serialize(file);

        // Verify key structural elements
        yaml.ShouldContain("stages:");
        yaml.ShouldContain("- build");
        yaml.ShouldContain("dotnet-build:");
        yaml.ShouldContain("dotnet restore");
        yaml.ShouldContain("dotnet build");
        yaml.ShouldNotContain("jobs:");  // Flat root
    }
}

Snapshot Testing

For complex pipelines, snapshot testing ensures the full YAML output doesn't change unexpectedly:

public class SnapshotTests
{
    [Fact]
    public void Full_pipeline_matches_snapshot()
    {
        var pipeline = new GitLabCiFile()
            .Apply(new DotNetBuildContributor())
            .Apply(new DotNetTestContributor());

        var yaml = GitLabCiYamlWriter.Serialize(pipeline);

        // Compare against stored snapshot
        var snapshotPath = "Snapshots/dotnet-pipeline.yml";
        if (!File.Exists(snapshotPath))
        {
            // First run: create snapshot
            Directory.CreateDirectory("Snapshots");
            File.WriteAllText(snapshotPath, yaml);
            return;
        }

        var expected = File.ReadAllText(snapshotPath);
        yaml.ShouldBe(expected,
            "Pipeline output changed. If intentional, " +
            "delete the snapshot and re-run.");
    }
}

Testing Version Compatibility

public class VersionCompatibilityTests
{
    [Fact]
    public void Pipeline_with_run_keyword_requires_18_5()
    {
        var file = new GitLabCiFile
        {
            Jobs = new Dictionary<string, GitLabCiJob>
            {
                ["build"] = new GitLabCiJob
                {
                    Run = new List<object> { "exec: npm ci" }
                }
            }
        };

        var minVersion = GetMinimumRequiredVersion(file);
        minVersion.ShouldBe("18.5.0");
    }

    [Fact]
    public void Pipeline_with_inputs_requires_18_6()
    {
        var file = new GitLabCiFile
        {
            Jobs = new Dictionary<string, GitLabCiJob>
            {
                ["build"] = new GitLabCiJob
                {
                    Inputs = new Dictionary<string, object?>
                    {
                        ["param1"] = "value1"
                    }
                }
            }
        };

        var minVersion = GetMinimumRequiredVersion(file);
        minVersion.ShouldBe("18.6.0");
    }

    [Fact]
    public void Basic_pipeline_works_with_18_0()
    {
        var file = new GitLabCiFile
        {
            Stages = new List<object> { "build" },
            Jobs = new Dictionary<string, GitLabCiJob>
            {
                ["build"] = new GitLabCiJob
                {
                    Script = new List<string> { "echo hello" }
                }
            }
        };

        var minVersion = GetMinimumRequiredVersion(file);
        minVersion.ShouldBe("18.0.0");
    }

    private static string GetMinimumRequiredVersion(GitLabCiFile file)
    {
        var minRequired = "18.0.0";
        var jobType = typeof(GitLabCiJob);

        if (file.Jobs is null) return minRequired;

        foreach (var prop in jobType.GetProperties())
        {
            var since = prop.GetCustomAttribute<SinceVersionAttribute>();
            if (since is null) continue;

            foreach (var job in file.Jobs.Values)
            {
                if (prop.GetValue(job) is not null &&
                    string.Compare(since.Version, minRequired,
                        StringComparison.Ordinal) > 0)
                {
                    minRequired = since.Version;
                }
            }
        }

        return minRequired;
    }
}

GitLab.Ci.Yaml is part of the FrenchExDev.Net ecosystem. Built with Roslyn incremental source generators, JSON Schema draft-07 parsing, fluent builders, Result-based error handling, and the same four-project architecture used by BinaryWrapper and DockerCompose.Bundle.

⬇ Download