Recipe 1: Multi-Environment Deployment Pipeline
A common pattern is deploying to staging first, then promoting to production with manual approval:
public class MultiEnvironmentDeployContributor : IGitLabCiContributor
{
private readonly string _appName;
private readonly string _registryBase;
public MultiEnvironmentDeployContributor(string appName, string registryBase)
{
_appName = appName;
_registryBase = registryBase;
}
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
foreach (var stage in new[] { "build", "test", "staging", "production" })
{
if (!ciFile.Stages.Contains(stage))
ciFile.Stages.Add(stage);
}
ciFile.Variables ??= new Dictionary<string, object?>();
ciFile.Variables["DOCKER_IMAGE"] = $"{_registryBase}/{_appName}";
ciFile.Variables["DOCKER_TAG"] = "$CI_COMMIT_SHORT_SHA";
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
// Docker build
ciFile.Jobs["docker-build"] = new GitLabCiJob
{
Image = "docker:24",
Script = new List<string>
{
"docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY",
"docker build -t $DOCKER_IMAGE:$DOCKER_TAG .",
"docker push $DOCKER_IMAGE:$DOCKER_TAG",
"docker tag $DOCKER_IMAGE:$DOCKER_TAG $DOCKER_IMAGE:latest",
"docker push $DOCKER_IMAGE:latest"
},
Tags = new List<object> { "docker", "dind" }
};
// Deploy to staging (automatic)
ciFile.Jobs["deploy-staging"] = new GitLabCiJob
{
Script = new List<string>
{
"kubectl config use-context staging",
"kubectl set image deployment/$APP_NAME " +
"$APP_NAME=$DOCKER_IMAGE:$DOCKER_TAG",
"kubectl rollout status deployment/$APP_NAME --timeout=300s"
},
Environment = new GitLabCiJobTemplateEnvironmentConfig
{
Name = "staging",
Url = $"https://staging.{_appName}.example.com"
},
Dependencies = new List<string> { "docker-build" }
};
// Deploy to production (manual gate)
ciFile.Jobs["deploy-production"] = new GitLabCiJob
{
Script = new List<string>
{
"kubectl config use-context production",
"kubectl set image deployment/$APP_NAME " +
"$APP_NAME=$DOCKER_IMAGE:$DOCKER_TAG",
"kubectl rollout status deployment/$APP_NAME --timeout=300s"
},
When = "manual",
AllowFailure = false,
Environment = new GitLabCiJobTemplateEnvironmentConfig
{
Name = "production",
Url = $"https://{_appName}.example.com"
},
Dependencies = new List<string> { "docker-build" },
Needs = new List<GitLabCiJobTemplateNeedsConfig>
{
new() { Job = "deploy-staging" }
}
};
}
}public class MultiEnvironmentDeployContributor : IGitLabCiContributor
{
private readonly string _appName;
private readonly string _registryBase;
public MultiEnvironmentDeployContributor(string appName, string registryBase)
{
_appName = appName;
_registryBase = registryBase;
}
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
foreach (var stage in new[] { "build", "test", "staging", "production" })
{
if (!ciFile.Stages.Contains(stage))
ciFile.Stages.Add(stage);
}
ciFile.Variables ??= new Dictionary<string, object?>();
ciFile.Variables["DOCKER_IMAGE"] = $"{_registryBase}/{_appName}";
ciFile.Variables["DOCKER_TAG"] = "$CI_COMMIT_SHORT_SHA";
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
// Docker build
ciFile.Jobs["docker-build"] = new GitLabCiJob
{
Image = "docker:24",
Script = new List<string>
{
"docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY",
"docker build -t $DOCKER_IMAGE:$DOCKER_TAG .",
"docker push $DOCKER_IMAGE:$DOCKER_TAG",
"docker tag $DOCKER_IMAGE:$DOCKER_TAG $DOCKER_IMAGE:latest",
"docker push $DOCKER_IMAGE:latest"
},
Tags = new List<object> { "docker", "dind" }
};
// Deploy to staging (automatic)
ciFile.Jobs["deploy-staging"] = new GitLabCiJob
{
Script = new List<string>
{
"kubectl config use-context staging",
"kubectl set image deployment/$APP_NAME " +
"$APP_NAME=$DOCKER_IMAGE:$DOCKER_TAG",
"kubectl rollout status deployment/$APP_NAME --timeout=300s"
},
Environment = new GitLabCiJobTemplateEnvironmentConfig
{
Name = "staging",
Url = $"https://staging.{_appName}.example.com"
},
Dependencies = new List<string> { "docker-build" }
};
// Deploy to production (manual gate)
ciFile.Jobs["deploy-production"] = new GitLabCiJob
{
Script = new List<string>
{
"kubectl config use-context production",
"kubectl set image deployment/$APP_NAME " +
"$APP_NAME=$DOCKER_IMAGE:$DOCKER_TAG",
"kubectl rollout status deployment/$APP_NAME --timeout=300s"
},
When = "manual",
AllowFailure = false,
Environment = new GitLabCiJobTemplateEnvironmentConfig
{
Name = "production",
Url = $"https://{_appName}.example.com"
},
Dependencies = new List<string> { "docker-build" },
Needs = new List<GitLabCiJobTemplateNeedsConfig>
{
new() { Job = "deploy-staging" }
}
};
}
}Usage:
var pipeline = new GitLabCiFile()
.Apply(new DotNetBuildContributor())
.Apply(new DotNetTestContributor())
.Apply(new MultiEnvironmentDeployContributor("my-api", "registry.example.com"));
var yaml = GitLabCiYamlWriter.Serialize(pipeline);var pipeline = new GitLabCiFile()
.Apply(new DotNetBuildContributor())
.Apply(new DotNetTestContributor())
.Apply(new MultiEnvironmentDeployContributor("my-api", "registry.example.com"));
var yaml = GitLabCiYamlWriter.Serialize(pipeline);This produces a complete pipeline with build → test → staging (auto) → production (manual) flow.
Recipe 2: Matrix Testing
Use variables and parallel execution for testing across multiple configurations:
public class DotNetMatrixTestContributor : IGitLabCiContributor
{
private readonly string[] _frameworks;
private readonly string[] _configurations;
public DotNetMatrixTestContributor(
string[] frameworks,
string[]? configurations = null)
{
_frameworks = frameworks;
_configurations = configurations ?? new[] { "Debug", "Release" };
}
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
if (!ciFile.Stages.Contains("test"))
ciFile.Stages.Add("test");
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
foreach (var framework in _frameworks)
{
foreach (var config in _configurations)
{
var jobName = $"test-{framework}-{config}".ToLowerInvariant();
var sdkVersion = framework switch
{
"net8.0" => "8.0",
"net9.0" => "9.0",
"net10.0" => "10.0",
_ => "9.0"
};
ciFile.Jobs[jobName] = new GitLabCiJob
{
Image = $"mcr.microsoft.com/dotnet/sdk:{sdkVersion}",
Script = new List<string>
{
$"dotnet test -f {framework} -c {config} " +
$"--logger \"trx;LogFileName={framework}-{config}.trx\"",
},
Artifacts = new GitLabCiArtifacts
{
Reports = new GitLabCiArtifactsReports
{
Junit = new List<string>
{
$"**/{framework}-{config}.trx"
}
},
ExpireIn = "7 days"
},
AllowFailure = framework == "net10.0" // Preview framework
};
}
}
}
}public class DotNetMatrixTestContributor : IGitLabCiContributor
{
private readonly string[] _frameworks;
private readonly string[] _configurations;
public DotNetMatrixTestContributor(
string[] frameworks,
string[]? configurations = null)
{
_frameworks = frameworks;
_configurations = configurations ?? new[] { "Debug", "Release" };
}
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
if (!ciFile.Stages.Contains("test"))
ciFile.Stages.Add("test");
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
foreach (var framework in _frameworks)
{
foreach (var config in _configurations)
{
var jobName = $"test-{framework}-{config}".ToLowerInvariant();
var sdkVersion = framework switch
{
"net8.0" => "8.0",
"net9.0" => "9.0",
"net10.0" => "10.0",
_ => "9.0"
};
ciFile.Jobs[jobName] = new GitLabCiJob
{
Image = $"mcr.microsoft.com/dotnet/sdk:{sdkVersion}",
Script = new List<string>
{
$"dotnet test -f {framework} -c {config} " +
$"--logger \"trx;LogFileName={framework}-{config}.trx\"",
},
Artifacts = new GitLabCiArtifacts
{
Reports = new GitLabCiArtifactsReports
{
Junit = new List<string>
{
$"**/{framework}-{config}.trx"
}
},
ExpireIn = "7 days"
},
AllowFailure = framework == "net10.0" // Preview framework
};
}
}
}
}Usage:
var pipeline = new GitLabCiFile()
.Apply(new DotNetMatrixTestContributor(
frameworks: new[] { "net8.0", "net9.0", "net10.0" },
configurations: new[] { "Debug", "Release" }));
var yaml = GitLabCiYamlWriter.Serialize(pipeline);var pipeline = new GitLabCiFile()
.Apply(new DotNetMatrixTestContributor(
frameworks: new[] { "net8.0", "net9.0", "net10.0" },
configurations: new[] { "Debug", "Release" }));
var yaml = GitLabCiYamlWriter.Serialize(pipeline);This generates 6 test jobs: test-net8.0-debug, test-net8.0-release, test-net9.0-debug, etc.
Recipe 3: Conditional Contributors
Contributors can be composed conditionally based on project characteristics:
public static class PipelineFactory
{
public static GitLabCiFile CreatePipeline(ProjectConfig config)
{
var file = new GitLabCiFile();
var contributors = new List<IGitLabCiContributor>();
// Always include build and test
contributors.Add(new DotNetBuildContributor(
sdkImage: $"mcr.microsoft.com/dotnet/sdk:{config.DotNetVersion}"));
contributors.Add(new DotNetTestContributor());
// Docker only if Dockerfile exists
if (config.HasDockerfile)
contributors.Add(new DockerBuildContributor(config.DockerImage));
// NuGet publish for library projects
if (config.IsLibrary)
contributors.Add(new NuGetPublishContributor(config.NuGetSource));
// Deployment for service projects
if (config.IsService)
contributors.Add(new MultiEnvironmentDeployContributor(
config.AppName, config.Registry));
// Code quality (optional)
if (config.EnableCodeQuality)
contributors.Add(new CodeQualityContributor());
// Security scanning (optional)
if (config.EnableSecurityScanning)
contributors.Add(new SecurityScanContributor());
// Apply all contributors
file.Apply(contributors.ToArray());
return file;
}
}
public record ProjectConfig
{
public string DotNetVersion { get; init; } = "9.0";
public bool HasDockerfile { get; init; }
public string? DockerImage { get; init; }
public bool IsLibrary { get; init; }
public string? NuGetSource { get; init; }
public bool IsService { get; init; }
public string? AppName { get; init; }
public string? Registry { get; init; }
public bool EnableCodeQuality { get; init; }
public bool EnableSecurityScanning { get; init; }
}public static class PipelineFactory
{
public static GitLabCiFile CreatePipeline(ProjectConfig config)
{
var file = new GitLabCiFile();
var contributors = new List<IGitLabCiContributor>();
// Always include build and test
contributors.Add(new DotNetBuildContributor(
sdkImage: $"mcr.microsoft.com/dotnet/sdk:{config.DotNetVersion}"));
contributors.Add(new DotNetTestContributor());
// Docker only if Dockerfile exists
if (config.HasDockerfile)
contributors.Add(new DockerBuildContributor(config.DockerImage));
// NuGet publish for library projects
if (config.IsLibrary)
contributors.Add(new NuGetPublishContributor(config.NuGetSource));
// Deployment for service projects
if (config.IsService)
contributors.Add(new MultiEnvironmentDeployContributor(
config.AppName, config.Registry));
// Code quality (optional)
if (config.EnableCodeQuality)
contributors.Add(new CodeQualityContributor());
// Security scanning (optional)
if (config.EnableSecurityScanning)
contributors.Add(new SecurityScanContributor());
// Apply all contributors
file.Apply(contributors.ToArray());
return file;
}
}
public record ProjectConfig
{
public string DotNetVersion { get; init; } = "9.0";
public bool HasDockerfile { get; init; }
public string? DockerImage { get; init; }
public bool IsLibrary { get; init; }
public string? NuGetSource { get; init; }
public bool IsService { get; init; }
public string? AppName { get; init; }
public string? Registry { get; init; }
public bool EnableCodeQuality { get; init; }
public bool EnableSecurityScanning { get; init; }
}Usage:
var config = new ProjectConfig
{
DotNetVersion = "9.0",
HasDockerfile = true,
DockerImage = "registry.example.com/my-api:latest",
IsService = true,
AppName = "my-api",
Registry = "registry.example.com",
EnableCodeQuality = true,
EnableSecurityScanning = true
};
var pipeline = PipelineFactory.CreatePipeline(config);
var yaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(".gitlab-ci.yml", yaml);var config = new ProjectConfig
{
DotNetVersion = "9.0",
HasDockerfile = true,
DockerImage = "registry.example.com/my-api:latest",
IsService = true,
AppName = "my-api",
Registry = "registry.example.com",
EnableCodeQuality = true,
EnableSecurityScanning = true
};
var pipeline = PipelineFactory.CreatePipeline(config);
var yaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(".gitlab-ci.yml", yaml);Recipe 4: Pipeline Linting and Validation
Use the reader to parse existing pipelines and validate them against organizational rules:
public static class PipelineLinter
{
public static List<string> Lint(string yamlPath)
{
var warnings = new List<string>();
var yaml = File.ReadAllText(yamlPath);
var pipeline = GitLabCiYamlReader.Deserialize(yaml);
// Rule 1: Pipeline must define stages
if (pipeline.Stages is null || pipeline.Stages.Count == 0)
warnings.Add("Pipeline should define explicit stages.");
// Rule 2: All jobs must have scripts
if (pipeline.Jobs is not null)
{
foreach (var (name, job) in pipeline.Jobs)
{
if (job.Script is null || job.Script.Count == 0)
warnings.Add($"Job '{name}' has no script commands.");
// Rule 3: Production jobs must be manual
if (job.Environment?.Name?.Contains("production") == true &&
job.When != "manual")
warnings.Add($"Job '{name}' deploys to production " +
"but is not manual.");
// Rule 4: Jobs should have timeout
if (job.Timeout is null)
warnings.Add($"Job '{name}' has no timeout set. " +
"Consider adding one.");
// Rule 5: Test jobs should have artifacts/reports
if (name.Contains("test") &&
job.Artifacts?.Reports is null)
warnings.Add($"Test job '{name}' does not produce " +
"test reports.");
}
}
// Rule 6: Check version compatibility
var targetVersion = GitLabCiVersion.Parse("18.3.0");
if (pipeline.Jobs is not null)
{
foreach (var (name, job) in pipeline.Jobs)
{
if (job.Run is not null)
{
warnings.Add($"Job '{name}' uses 'run:' which " +
$"requires GitLab 18.5+, but target is " +
$"{targetVersion}.");
}
if (job.Inputs is not null)
{
warnings.Add($"Job '{name}' uses 'inputs:' which " +
$"requires GitLab 18.6+, but target is " +
$"{targetVersion}.");
}
}
}
return warnings;
}
}public static class PipelineLinter
{
public static List<string> Lint(string yamlPath)
{
var warnings = new List<string>();
var yaml = File.ReadAllText(yamlPath);
var pipeline = GitLabCiYamlReader.Deserialize(yaml);
// Rule 1: Pipeline must define stages
if (pipeline.Stages is null || pipeline.Stages.Count == 0)
warnings.Add("Pipeline should define explicit stages.");
// Rule 2: All jobs must have scripts
if (pipeline.Jobs is not null)
{
foreach (var (name, job) in pipeline.Jobs)
{
if (job.Script is null || job.Script.Count == 0)
warnings.Add($"Job '{name}' has no script commands.");
// Rule 3: Production jobs must be manual
if (job.Environment?.Name?.Contains("production") == true &&
job.When != "manual")
warnings.Add($"Job '{name}' deploys to production " +
"but is not manual.");
// Rule 4: Jobs should have timeout
if (job.Timeout is null)
warnings.Add($"Job '{name}' has no timeout set. " +
"Consider adding one.");
// Rule 5: Test jobs should have artifacts/reports
if (name.Contains("test") &&
job.Artifacts?.Reports is null)
warnings.Add($"Test job '{name}' does not produce " +
"test reports.");
}
}
// Rule 6: Check version compatibility
var targetVersion = GitLabCiVersion.Parse("18.3.0");
if (pipeline.Jobs is not null)
{
foreach (var (name, job) in pipeline.Jobs)
{
if (job.Run is not null)
{
warnings.Add($"Job '{name}' uses 'run:' which " +
$"requires GitLab 18.5+, but target is " +
$"{targetVersion}.");
}
if (job.Inputs is not null)
{
warnings.Add($"Job '{name}' uses 'inputs:' which " +
$"requires GitLab 18.6+, but target is " +
$"{targetVersion}.");
}
}
}
return warnings;
}
}Recipe 5: Pipeline Migration Tool
Read an existing pipeline, transform it, and write it back:
public static class PipelineMigrator
{
/// <summary>
/// Migrates a pipeline from deprecated global keywords to the
/// recommended 'default:' block.
/// </summary>
public static void MigrateToDefaultBlock(string yamlPath)
{
var yaml = File.ReadAllText(yamlPath);
var pipeline = GitLabCiYamlReader.Deserialize(yaml);
// Move deprecated global keywords to default block
if (pipeline.Image is not null ||
pipeline.BeforeScript is not null ||
pipeline.AfterScript is not null ||
pipeline.Cache is not null)
{
pipeline.Default ??= new GitLabCiDefault();
if (pipeline.Image is not null)
{
pipeline.Default.Image ??= pipeline.Image;
pipeline.Image = null;
}
if (pipeline.BeforeScript is not null)
{
pipeline.Default.BeforeScript ??= pipeline.BeforeScript;
pipeline.BeforeScript = null;
}
if (pipeline.AfterScript is not null)
{
pipeline.Default.AfterScript ??= pipeline.AfterScript;
pipeline.AfterScript = null;
}
if (pipeline.Cache is not null)
{
pipeline.Default.Cache ??= pipeline.Cache;
pipeline.Cache = null;
}
}
var updatedYaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(yamlPath, updatedYaml);
}
/// <summary>
/// Adds standard organizational defaults to all jobs that don't
/// already have them.
/// </summary>
public static void ApplyOrganizationDefaults(string yamlPath)
{
var yaml = File.ReadAllText(yamlPath);
var pipeline = GitLabCiYamlReader.Deserialize(yaml);
if (pipeline.Jobs is not null)
{
foreach (var (name, job) in pipeline.Jobs)
{
// Ensure all jobs have a timeout
job.Timeout ??= "30 minutes";
// Ensure deploy jobs have allow_failure = false
if (name.Contains("deploy"))
job.AllowFailure ??= false;
// Ensure test jobs produce reports
if (name.Contains("test") && job.Artifacts?.Reports is null)
{
job.Artifacts ??= new GitLabCiArtifacts();
job.Artifacts.Reports ??= new GitLabCiArtifactsReports();
job.Artifacts.Reports.Junit ??= new List<string>
{
"**/*.trx", "**/*junit*.xml"
};
}
}
}
var updatedYaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(yamlPath, updatedYaml);
}
}public static class PipelineMigrator
{
/// <summary>
/// Migrates a pipeline from deprecated global keywords to the
/// recommended 'default:' block.
/// </summary>
public static void MigrateToDefaultBlock(string yamlPath)
{
var yaml = File.ReadAllText(yamlPath);
var pipeline = GitLabCiYamlReader.Deserialize(yaml);
// Move deprecated global keywords to default block
if (pipeline.Image is not null ||
pipeline.BeforeScript is not null ||
pipeline.AfterScript is not null ||
pipeline.Cache is not null)
{
pipeline.Default ??= new GitLabCiDefault();
if (pipeline.Image is not null)
{
pipeline.Default.Image ??= pipeline.Image;
pipeline.Image = null;
}
if (pipeline.BeforeScript is not null)
{
pipeline.Default.BeforeScript ??= pipeline.BeforeScript;
pipeline.BeforeScript = null;
}
if (pipeline.AfterScript is not null)
{
pipeline.Default.AfterScript ??= pipeline.AfterScript;
pipeline.AfterScript = null;
}
if (pipeline.Cache is not null)
{
pipeline.Default.Cache ??= pipeline.Cache;
pipeline.Cache = null;
}
}
var updatedYaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(yamlPath, updatedYaml);
}
/// <summary>
/// Adds standard organizational defaults to all jobs that don't
/// already have them.
/// </summary>
public static void ApplyOrganizationDefaults(string yamlPath)
{
var yaml = File.ReadAllText(yamlPath);
var pipeline = GitLabCiYamlReader.Deserialize(yaml);
if (pipeline.Jobs is not null)
{
foreach (var (name, job) in pipeline.Jobs)
{
// Ensure all jobs have a timeout
job.Timeout ??= "30 minutes";
// Ensure deploy jobs have allow_failure = false
if (name.Contains("deploy"))
job.AllowFailure ??= false;
// Ensure test jobs produce reports
if (name.Contains("test") && job.Artifacts?.Reports is null)
{
job.Artifacts ??= new GitLabCiArtifacts();
job.Artifacts.Reports ??= new GitLabCiArtifactsReports();
job.Artifacts.Reports.Junit ??= new List<string>
{
"**/*.trx", "**/*junit*.xml"
};
}
}
}
var updatedYaml = GitLabCiYamlWriter.Serialize(pipeline);
File.WriteAllText(yamlPath, updatedYaml);
}
}Recipe 6: Pipeline Diff Tool
Compare two pipelines programmatically:
public static class PipelineDiff
{
public static PipelineComparison Compare(string yamlA, string yamlB)
{
var pipelineA = GitLabCiYamlReader.Deserialize(yamlA);
var pipelineB = GitLabCiYamlReader.Deserialize(yamlB);
var jobsA = pipelineA.Jobs?.Keys.ToHashSet() ?? new();
var jobsB = pipelineB.Jobs?.Keys.ToHashSet() ?? new();
return new PipelineComparison
{
AddedJobs = jobsB.Except(jobsA).ToList(),
RemovedJobs = jobsA.Except(jobsB).ToList(),
CommonJobs = jobsA.Intersect(jobsB).ToList(),
StagesA = pipelineA.Stages?.Cast<string>().ToList() ?? new(),
StagesB = pipelineB.Stages?.Cast<string>().ToList() ?? new(),
VariablesAddedCount = CountNewVariables(
pipelineA.Variables, pipelineB.Variables),
VariablesRemovedCount = CountRemovedVariables(
pipelineA.Variables, pipelineB.Variables),
};
}
private static int CountNewVariables(
Dictionary<string, object?>? a,
Dictionary<string, object?>? b)
{
if (b is null) return 0;
if (a is null) return b.Count;
return b.Keys.Except(a.Keys).Count();
}
private static int CountRemovedVariables(
Dictionary<string, object?>? a,
Dictionary<string, object?>? b)
{
if (a is null) return 0;
if (b is null) return a.Count;
return a.Keys.Except(b.Keys).Count();
}
}
public record PipelineComparison
{
public List<string> AddedJobs { get; init; } = new();
public List<string> RemovedJobs { get; init; } = new();
public List<string> CommonJobs { get; init; } = new();
public List<string> StagesA { get; init; } = new();
public List<string> StagesB { get; init; } = new();
public int VariablesAddedCount { get; init; }
public int VariablesRemovedCount { get; init; }
}public static class PipelineDiff
{
public static PipelineComparison Compare(string yamlA, string yamlB)
{
var pipelineA = GitLabCiYamlReader.Deserialize(yamlA);
var pipelineB = GitLabCiYamlReader.Deserialize(yamlB);
var jobsA = pipelineA.Jobs?.Keys.ToHashSet() ?? new();
var jobsB = pipelineB.Jobs?.Keys.ToHashSet() ?? new();
return new PipelineComparison
{
AddedJobs = jobsB.Except(jobsA).ToList(),
RemovedJobs = jobsA.Except(jobsB).ToList(),
CommonJobs = jobsA.Intersect(jobsB).ToList(),
StagesA = pipelineA.Stages?.Cast<string>().ToList() ?? new(),
StagesB = pipelineB.Stages?.Cast<string>().ToList() ?? new(),
VariablesAddedCount = CountNewVariables(
pipelineA.Variables, pipelineB.Variables),
VariablesRemovedCount = CountRemovedVariables(
pipelineA.Variables, pipelineB.Variables),
};
}
private static int CountNewVariables(
Dictionary<string, object?>? a,
Dictionary<string, object?>? b)
{
if (b is null) return 0;
if (a is null) return b.Count;
return b.Keys.Except(a.Keys).Count();
}
private static int CountRemovedVariables(
Dictionary<string, object?>? a,
Dictionary<string, object?>? b)
{
if (a is null) return 0;
if (b is null) return a.Count;
return a.Keys.Except(b.Keys).Count();
}
}
public record PipelineComparison
{
public List<string> AddedJobs { get; init; } = new();
public List<string> RemovedJobs { get; init; } = new();
public List<string> CommonJobs { get; init; } = new();
public List<string> StagesA { get; init; } = new();
public List<string> StagesB { get; init; } = new();
public int VariablesAddedCount { get; init; }
public int VariablesRemovedCount { get; init; }
}