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;
}
}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);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:
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;
}
}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);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;
}
}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# 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.ymlDeep 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# Traditional (script)
build:
script:
- npm ci
- npm run build
# New (run) — GitLab 18.5+
build:
run:
- exec:
run: npm ci
- exec:
run: npm run buildIn 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; }/// <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
Impact on Generated Code
The version merger ensures that:
- All properties from all versions are present — the unified model is the superset
- Version annotations are accurate —
[SinceVersion]marks when each property appeared - Latest type wins — if a property's type changed between versions, the latest definition is used
- 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; }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"
}
}
};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: aggressivestages:
- build
new_experimental_keyword:
enabled: true
mode: aggressive2. 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"
}
};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" }
}
}
};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.base-job:
image: node:20
tags:
- docker
build:
extends:
- .base-job
script:
- npm ciSummary: 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.
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;
}
}/// <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");// 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
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}");
}
}
}
}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);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");
}
}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);
}
}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
}
}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.");
}
}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;
}
}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.