The Interface
/// <summary>
/// Extension point for composing a GitLabCiFile.
/// Contributors add/modify jobs, stages, variables, and other CI elements.
/// </summary>
public interface IGitLabCiContributor
{
void Contribute(GitLabCiFile ciFile);
}/// <summary>
/// Extension point for composing a GitLabCiFile.
/// Contributors add/modify jobs, stages, variables, and other CI elements.
/// </summary>
public interface IGitLabCiContributor
{
void Contribute(GitLabCiFile ciFile);
}Fluent Extensions
public static class GitLabCiFileExtensions
{
public static GitLabCiFile Apply(this GitLabCiFile file,
IGitLabCiContributor contributor)
{
contributor.Contribute(file);
return file;
}
public static GitLabCiFile Apply(this GitLabCiFile file,
params IGitLabCiContributor[] contributors)
{
foreach (var c in contributors)
c.Contribute(file);
return file;
}
}public static class GitLabCiFileExtensions
{
public static GitLabCiFile Apply(this GitLabCiFile file,
IGitLabCiContributor contributor)
{
contributor.Contribute(file);
return file;
}
public static GitLabCiFile Apply(this GitLabCiFile file,
params IGitLabCiContributor[] contributors)
{
foreach (var c in contributors)
c.Contribute(file);
return file;
}
}Real-World Contributors
DotNet Build Contributor:
public class DotNetBuildContributor : IGitLabCiContributor
{
private readonly string _sdkImage;
private readonly string _configuration;
public DotNetBuildContributor(
string sdkImage = "mcr.microsoft.com/dotnet/sdk:9.0",
string configuration = "Release")
{
_sdkImage = sdkImage;
_configuration = configuration;
}
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
if (!ciFile.Stages.Contains("build"))
ciFile.Stages.Add("build");
ciFile.Variables ??= new Dictionary<string, object?>();
ciFile.Variables["DOTNET_CONFIGURATION"] = _configuration;
ciFile.Default ??= new GitLabCiDefault();
ciFile.Default.Image = _sdkImage;
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
ciFile.Jobs["dotnet-build"] = new GitLabCiJob
{
Script = new List<string>
{
"dotnet restore",
"dotnet build -c $DOTNET_CONFIGURATION"
},
Artifacts = new GitLabCiArtifacts
{
Paths = new List<string> { "bin/", "obj/" },
ExpireIn = "7 days"
}
};
}
}public class DotNetBuildContributor : IGitLabCiContributor
{
private readonly string _sdkImage;
private readonly string _configuration;
public DotNetBuildContributor(
string sdkImage = "mcr.microsoft.com/dotnet/sdk:9.0",
string configuration = "Release")
{
_sdkImage = sdkImage;
_configuration = configuration;
}
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
if (!ciFile.Stages.Contains("build"))
ciFile.Stages.Add("build");
ciFile.Variables ??= new Dictionary<string, object?>();
ciFile.Variables["DOTNET_CONFIGURATION"] = _configuration;
ciFile.Default ??= new GitLabCiDefault();
ciFile.Default.Image = _sdkImage;
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
ciFile.Jobs["dotnet-build"] = new GitLabCiJob
{
Script = new List<string>
{
"dotnet restore",
"dotnet build -c $DOTNET_CONFIGURATION"
},
Artifacts = new GitLabCiArtifacts
{
Paths = new List<string> { "bin/", "obj/" },
ExpireIn = "7 days"
}
};
}
}DotNet Test Contributor:
public class DotNetTestContributor : IGitLabCiContributor
{
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>();
ciFile.Jobs["dotnet-test"] = new GitLabCiJob
{
Script = new List<string>
{
"dotnet test --logger trx --collect:\"XPlat Code Coverage\""
},
Artifacts = new GitLabCiArtifacts
{
Reports = new GitLabCiArtifactsReports
{
Junit = new List<string> { "**/*.trx" },
CoverageReport = new List<string>
{
"**/coverage.cobertura.xml"
}
}
}
};
}
}public class DotNetTestContributor : IGitLabCiContributor
{
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>();
ciFile.Jobs["dotnet-test"] = new GitLabCiJob
{
Script = new List<string>
{
"dotnet test --logger trx --collect:\"XPlat Code Coverage\""
},
Artifacts = new GitLabCiArtifacts
{
Reports = new GitLabCiArtifactsReports
{
Junit = new List<string> { "**/*.trx" },
CoverageReport = new List<string>
{
"**/coverage.cobertura.xml"
}
}
}
};
}
}Docker Build Contributor:
public class DockerBuildContributor : IGitLabCiContributor
{
private readonly string _imageName;
public DockerBuildContributor(string imageName)
=> _imageName = imageName;
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
if (!ciFile.Stages.Contains("docker"))
ciFile.Stages.Add("docker");
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
ciFile.Jobs["docker-build"] = new GitLabCiJob
{
Image = "docker:24",
Script = new List<string>
{
$"docker build -t {_imageName} .",
$"docker push {_imageName}"
}
};
}
}public class DockerBuildContributor : IGitLabCiContributor
{
private readonly string _imageName;
public DockerBuildContributor(string imageName)
=> _imageName = imageName;
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages ??= new List<object>();
if (!ciFile.Stages.Contains("docker"))
ciFile.Stages.Add("docker");
ciFile.Jobs ??= new Dictionary<string, GitLabCiJob>();
ciFile.Jobs["docker-build"] = new GitLabCiJob
{
Image = "docker:24",
Script = new List<string>
{
$"docker build -t {_imageName} .",
$"docker push {_imageName}"
}
};
}
}Composing a Pipeline
var ciFile = new GitLabCiFile()
.Apply(new DotNetBuildContributor())
.Apply(new DotNetTestContributor())
.Apply(new DockerBuildContributor("registry.example.com/app:latest"));
var yaml = GitLabCiYamlWriter.Serialize(ciFile);var ciFile = new GitLabCiFile()
.Apply(new DotNetBuildContributor())
.Apply(new DotNetTestContributor())
.Apply(new DockerBuildContributor("registry.example.com/app:latest"));
var yaml = GitLabCiYamlWriter.Serialize(ciFile);This pattern mirrors IComposeFileContributor from DockerCompose.Bundle. Each contributor is independently testable, reusable, and composable.
GitLabCiVersion: Semantic Versioning
The GitLabCiVersion type provides semantic version parsing, comparison, and formatting for GitLab version numbers:
public sealed record GitLabCiVersion(int Major, int Minor, int Patch)
: IComparable<GitLabCiVersion>
{
public int CompareTo(GitLabCiVersion? other)
{
if (other is null) return 1;
var c = Major.CompareTo(other.Major);
if (c != 0) return c;
c = Minor.CompareTo(other.Minor);
return c != 0 ? c : Patch.CompareTo(other.Patch);
}
public override string ToString() => $"{Major}.{Minor}.{Patch}";
public static GitLabCiVersion Parse(string version)
{
var v = version.StartsWith('v') ? version[1..] : version;
var parts = v.Split('.');
if (parts.Length != 3)
throw new FormatException(
$"Invalid version format: '{version}'");
return new GitLabCiVersion(
int.Parse(parts[0]),
int.Parse(parts[1]),
int.Parse(parts[2]));
}
public static bool TryParse(string version,
out GitLabCiVersion? result)
{
try
{
result = Parse(version);
return true;
}
catch
{
result = null;
return false;
}
}
public static bool operator <(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) < 0;
public static bool operator >(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) > 0;
public static bool operator <=(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) <= 0;
public static bool operator >=(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) >= 0;
}public sealed record GitLabCiVersion(int Major, int Minor, int Patch)
: IComparable<GitLabCiVersion>
{
public int CompareTo(GitLabCiVersion? other)
{
if (other is null) return 1;
var c = Major.CompareTo(other.Major);
if (c != 0) return c;
c = Minor.CompareTo(other.Minor);
return c != 0 ? c : Patch.CompareTo(other.Patch);
}
public override string ToString() => $"{Major}.{Minor}.{Patch}";
public static GitLabCiVersion Parse(string version)
{
var v = version.StartsWith('v') ? version[1..] : version;
var parts = v.Split('.');
if (parts.Length != 3)
throw new FormatException(
$"Invalid version format: '{version}'");
return new GitLabCiVersion(
int.Parse(parts[0]),
int.Parse(parts[1]),
int.Parse(parts[2]));
}
public static bool TryParse(string version,
out GitLabCiVersion? result)
{
try
{
result = Parse(version);
return true;
}
catch
{
result = null;
return false;
}
}
public static bool operator <(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) < 0;
public static bool operator >(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) > 0;
public static bool operator <=(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) <= 0;
public static bool operator >=(GitLabCiVersion left,
GitLabCiVersion right)
=> left.CompareTo(right) >= 0;
}Usage
// Parse versions
var v = GitLabCiVersion.Parse("18.6.0");
var vWithPrefix = GitLabCiVersion.Parse("v18.6.0"); // v prefix handled
// Compare versions
var target = GitLabCiVersion.Parse("18.3.0");
var required = GitLabCiVersion.Parse("18.5.0");
if (target < required)
Console.WriteLine("Target GitLab version doesn't support 'run:' keyword");
// Check schema metadata
Console.WriteLine($"Latest: {GitLabCiSchemaVersions.Latest}"); // 18.10.0
Console.WriteLine($"Oldest: {GitLabCiSchemaVersions.Oldest}"); // 18.0.0
foreach (var ver in GitLabCiSchemaVersions.Available)
Console.WriteLine(ver);
// Check property version support via reflection
var prop = typeof(GitLabCiJob).GetProperty("Inputs");
var since = prop?.GetCustomAttribute<SinceVersionAttribute>();
if (since is not null)
Console.WriteLine($"'inputs' available since GitLab {since.Version}");// Parse versions
var v = GitLabCiVersion.Parse("18.6.0");
var vWithPrefix = GitLabCiVersion.Parse("v18.6.0"); // v prefix handled
// Compare versions
var target = GitLabCiVersion.Parse("18.3.0");
var required = GitLabCiVersion.Parse("18.5.0");
if (target < required)
Console.WriteLine("Target GitLab version doesn't support 'run:' keyword");
// Check schema metadata
Console.WriteLine($"Latest: {GitLabCiSchemaVersions.Latest}"); // 18.10.0
Console.WriteLine($"Oldest: {GitLabCiSchemaVersions.Oldest}"); // 18.0.0
foreach (var ver in GitLabCiSchemaVersions.Available)
Console.WriteLine(ver);
// Check property version support via reflection
var prop = typeof(GitLabCiJob).GetProperty("Inputs");
var since = prop?.GetCustomAttribute<SinceVersionAttribute>();
if (since is not null)
Console.WriteLine($"'inputs' available since GitLab {since.Version}");Testing
The library has 20 tests across 4 test classes, covering models, YAML writer, YAML reader, and version parsing.
ModelTests
public class ModelTests
{
[Fact]
public void GitLabCiFile_can_be_created()
{
var file = new GitLabCiFile();
file.ShouldNotBeNull();
}
[Fact]
public void GitLabCiFile_jobs_can_be_set()
{
var file = new GitLabCiFile
{
Jobs = new Dictionary<string, GitLabCiJob>
{
["build"] = new GitLabCiJob()
}
};
file.Jobs.ShouldContainKey("build");
}
[Fact]
public void GitLabCiFile_stages_can_be_set()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build", "test", "deploy" }
};
file.Stages!.Count.ShouldBe(3);
}
[Fact]
public void Contributor_can_modify_file()
{
var file = new GitLabCiFile();
var contributor = new TestContributor();
file.Apply(contributor);
file.Stages.ShouldNotBeNull();
file.Stages!.Count.ShouldBe(1);
}
private sealed class TestContributor : IGitLabCiContributor
{
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages = new List<object> { "contributed" };
}
}
}public class ModelTests
{
[Fact]
public void GitLabCiFile_can_be_created()
{
var file = new GitLabCiFile();
file.ShouldNotBeNull();
}
[Fact]
public void GitLabCiFile_jobs_can_be_set()
{
var file = new GitLabCiFile
{
Jobs = new Dictionary<string, GitLabCiJob>
{
["build"] = new GitLabCiJob()
}
};
file.Jobs.ShouldContainKey("build");
}
[Fact]
public void GitLabCiFile_stages_can_be_set()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build", "test", "deploy" }
};
file.Stages!.Count.ShouldBe(3);
}
[Fact]
public void Contributor_can_modify_file()
{
var file = new GitLabCiFile();
var contributor = new TestContributor();
file.Apply(contributor);
file.Stages.ShouldNotBeNull();
file.Stages!.Count.ShouldBe(1);
}
private sealed class TestContributor : IGitLabCiContributor
{
public void Contribute(GitLabCiFile ciFile)
{
ciFile.Stages = new List<object> { "contributed" };
}
}
}WriterTests
public class WriterTests
{
[Fact]
public void Serialize_stages()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build", "test", "deploy" }
};
var yaml = GitLabCiYamlWriter.Serialize(file);
yaml.ShouldContain("stages:");
yaml.ShouldContain("- build");
yaml.ShouldContain("- test");
yaml.ShouldContain("- deploy");
}
[Fact]
public void Serialize_jobs_at_root_level()
{
var file = new GitLabCiFile
{
Jobs = new Dictionary<string, GitLabCiJob>
{
["build"] = new GitLabCiJob
{
Script = new List<string> { "npm ci", "npm run build" }
}
}
};
var yaml = GitLabCiYamlWriter.Serialize(file);
// Jobs at root level, NOT nested under "jobs:"
yaml.ShouldContain("build:");
yaml.ShouldNotContain("jobs:");
yaml.ShouldContain("npm ci");
}
[Fact]
public void Serialize_omits_null_properties()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build" }
};
var yaml = GitLabCiYamlWriter.Serialize(file);
yaml.ShouldNotContain("variables:");
yaml.ShouldNotContain("include:");
}
[Fact]
public void Serialize_to_writer()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build" }
};
using var sw = new StringWriter();
GitLabCiYamlWriter.Serialize(file, sw);
var yaml = sw.ToString();
yaml.ShouldContain("stages:");
}
}public class WriterTests
{
[Fact]
public void Serialize_stages()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build", "test", "deploy" }
};
var yaml = GitLabCiYamlWriter.Serialize(file);
yaml.ShouldContain("stages:");
yaml.ShouldContain("- build");
yaml.ShouldContain("- test");
yaml.ShouldContain("- deploy");
}
[Fact]
public void Serialize_jobs_at_root_level()
{
var file = new GitLabCiFile
{
Jobs = new Dictionary<string, GitLabCiJob>
{
["build"] = new GitLabCiJob
{
Script = new List<string> { "npm ci", "npm run build" }
}
}
};
var yaml = GitLabCiYamlWriter.Serialize(file);
// Jobs at root level, NOT nested under "jobs:"
yaml.ShouldContain("build:");
yaml.ShouldNotContain("jobs:");
yaml.ShouldContain("npm ci");
}
[Fact]
public void Serialize_omits_null_properties()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build" }
};
var yaml = GitLabCiYamlWriter.Serialize(file);
yaml.ShouldNotContain("variables:");
yaml.ShouldNotContain("include:");
}
[Fact]
public void Serialize_to_writer()
{
var file = new GitLabCiFile
{
Stages = new List<object> { "build" }
};
using var sw = new StringWriter();
GitLabCiYamlWriter.Serialize(file, sw);
var yaml = sw.ToString();
yaml.ShouldContain("stages:");
}
}ReaderTests
public class ReaderTests
{
[Fact]
public void Deserialize_simple_pipeline()
{
var yaml = File.ReadAllText("Fixtures/simple-pipeline.yml");
var file = GitLabCiYamlReader.Deserialize(yaml);
file.ShouldNotBeNull();
file.Stages.ShouldNotBeNull();
file.Stages!.Count.ShouldBe(3);
}
[Fact]
public void Deserialize_extracts_jobs()
{
var yaml = "build_job:\n stage: build\n script:\n " +
"- echo building\ntest_job:\n stage: test\n " +
"script:\n - echo testing\n";
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
file.Jobs!.ShouldContainKey("build_job");
file.Jobs!.ShouldContainKey("test_job");
}
[Fact]
public void Deserialize_job_script_as_list()
{
var yaml = "build:\n script:\n - npm ci\n - npm run build\n";
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
var job = file.Jobs!["build"];
job.Script.ShouldNotBeNull();
job.Script!.Count.ShouldBe(2);
job.Script[0].ShouldBe("npm ci");
job.Script[1].ShouldBe("npm run build");
}
[Fact]
public void Deserialize_job_script_as_single_string()
{
var yaml = "build:\n script: echo hello\n";
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
var job = file.Jobs!["build"];
job.Script.ShouldNotBeNull();
job.Script!.Count.ShouldBe(1);
job.Script[0].ShouldBe("echo hello");
}
[Fact]
public void Deserialize_from_reader()
{
using var reader = new StreamReader("Fixtures/simple-pipeline.yml");
var file = GitLabCiYamlReader.Deserialize(reader);
file.ShouldNotBeNull();
file.Stages.ShouldNotBeNull();
}
[Fact]
public void Deserialize_empty_yaml_returns_empty_file()
{
var file = GitLabCiYamlReader.Deserialize("{}");
file.ShouldNotBeNull();
}
[Fact]
public void Deserialize_full_pipeline_from_fixture()
{
var yaml = File.ReadAllText("Fixtures/simple-pipeline.yml");
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
file.Jobs!.ShouldContainKey("build");
file.Jobs!.ShouldContainKey("test");
file.Jobs!.ShouldContainKey("deploy");
file.Jobs["build"].Script.ShouldNotBeNull();
file.Jobs["build"].Script!.ShouldContain("npm ci");
}
}public class ReaderTests
{
[Fact]
public void Deserialize_simple_pipeline()
{
var yaml = File.ReadAllText("Fixtures/simple-pipeline.yml");
var file = GitLabCiYamlReader.Deserialize(yaml);
file.ShouldNotBeNull();
file.Stages.ShouldNotBeNull();
file.Stages!.Count.ShouldBe(3);
}
[Fact]
public void Deserialize_extracts_jobs()
{
var yaml = "build_job:\n stage: build\n script:\n " +
"- echo building\ntest_job:\n stage: test\n " +
"script:\n - echo testing\n";
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
file.Jobs!.ShouldContainKey("build_job");
file.Jobs!.ShouldContainKey("test_job");
}
[Fact]
public void Deserialize_job_script_as_list()
{
var yaml = "build:\n script:\n - npm ci\n - npm run build\n";
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
var job = file.Jobs!["build"];
job.Script.ShouldNotBeNull();
job.Script!.Count.ShouldBe(2);
job.Script[0].ShouldBe("npm ci");
job.Script[1].ShouldBe("npm run build");
}
[Fact]
public void Deserialize_job_script_as_single_string()
{
var yaml = "build:\n script: echo hello\n";
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
var job = file.Jobs!["build"];
job.Script.ShouldNotBeNull();
job.Script!.Count.ShouldBe(1);
job.Script[0].ShouldBe("echo hello");
}
[Fact]
public void Deserialize_from_reader()
{
using var reader = new StreamReader("Fixtures/simple-pipeline.yml");
var file = GitLabCiYamlReader.Deserialize(reader);
file.ShouldNotBeNull();
file.Stages.ShouldNotBeNull();
}
[Fact]
public void Deserialize_empty_yaml_returns_empty_file()
{
var file = GitLabCiYamlReader.Deserialize("{}");
file.ShouldNotBeNull();
}
[Fact]
public void Deserialize_full_pipeline_from_fixture()
{
var yaml = File.ReadAllText("Fixtures/simple-pipeline.yml");
var file = GitLabCiYamlReader.Deserialize(yaml);
file.Jobs.ShouldNotBeNull();
file.Jobs!.ShouldContainKey("build");
file.Jobs!.ShouldContainKey("test");
file.Jobs!.ShouldContainKey("deploy");
file.Jobs["build"].Script.ShouldNotBeNull();
file.Jobs["build"].Script!.ShouldContain("npm ci");
}
}VersionTests
public class VersionTests
{
[Fact]
public void Parse_valid_version()
{
var v = GitLabCiVersion.Parse("17.6.4");
v.Major.ShouldBe(17);
v.Minor.ShouldBe(6);
v.Patch.ShouldBe(4);
}
[Fact]
public void Parse_with_v_prefix()
{
var v = GitLabCiVersion.Parse("v17.6.4");
v.Major.ShouldBe(17);
}
[Fact]
public void CompareTo_returns_correct_order()
{
var v1 = GitLabCiVersion.Parse("16.0.0");
var v2 = GitLabCiVersion.Parse("17.6.4");
(v1 < v2).ShouldBeTrue();
(v2 > v1).ShouldBeTrue();
}
[Fact]
public void ToString_formats_correctly()
{
var v = new GitLabCiVersion(17, 6, 4);
v.ToString().ShouldBe("17.6.4");
}
[Fact]
public void TryParse_returns_false_for_invalid()
{
GitLabCiVersion.TryParse("invalid", out var result).ShouldBeFalse();
result.ShouldBeNull();
}
}public class VersionTests
{
[Fact]
public void Parse_valid_version()
{
var v = GitLabCiVersion.Parse("17.6.4");
v.Major.ShouldBe(17);
v.Minor.ShouldBe(6);
v.Patch.ShouldBe(4);
}
[Fact]
public void Parse_with_v_prefix()
{
var v = GitLabCiVersion.Parse("v17.6.4");
v.Major.ShouldBe(17);
}
[Fact]
public void CompareTo_returns_correct_order()
{
var v1 = GitLabCiVersion.Parse("16.0.0");
var v2 = GitLabCiVersion.Parse("17.6.4");
(v1 < v2).ShouldBeTrue();
(v2 > v1).ShouldBeTrue();
}
[Fact]
public void ToString_formats_correctly()
{
var v = new GitLabCiVersion(17, 6, 4);
v.ToString().ShouldBe("17.6.4");
}
[Fact]
public void TryParse_returns_false_for_invalid()
{
GitLabCiVersion.TryParse("invalid", out var result).ShouldBeFalse();
result.ShouldBeNull();
}
}Test Fixture
The test fixture simple-pipeline.yml used by several tests:
stages:
- build
- test
- deploy
variables:
NODE_VERSION: "20"
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
test:
stage: test
image: node:20
script:
- npm test
deploy:
stage: deploy
script:
- echo "Deploying..."
only:
- mainstages:
- build
- test
- deploy
variables:
NODE_VERSION: "20"
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
test:
stage: test
image: node:20
script:
- npm test
deploy:
stage: deploy
script:
- echo "Deploying..."
only:
- mainEnd-to-End Walkthrough
Let's walk through a complete scenario: building a real-world .NET pipeline from scratch, serializing it, reading it back, modifying it, and verifying version compatibility.
Step 1: Build a Pipeline
using FrenchExDev.Net.GitLab.Ci.Yaml;
using FrenchExDev.Net.GitLab.Ci.Yaml.Serialization;
// Build a .NET pipeline with build, test, and deploy stages
var result = await new GitLabCiFileBuilder()
.WithStages(new List<object> { "build", "test", "deploy" })
.WithVariables(vars => vars
.With("DOTNET_VERSION", "9.0")
.With("CONFIGURATION", "Release")
.With("NUGET_PACKAGES_DIRECTORY", ".nuget"))
.WithDefault(new GitLabCiDefault
{
Image = "mcr.microsoft.com/dotnet/sdk:9.0",
Tags = new List<object> { "docker", "linux" }
})
.WithJob("restore", job => job
.WithScript(new List<string>
{
"dotnet restore --packages $NUGET_PACKAGES_DIRECTORY"
})
.WithArtifacts(new GitLabCiArtifacts
{
Paths = new List<string> { "$NUGET_PACKAGES_DIRECTORY/" },
ExpireIn = "1 hour"
}))
.WithJob("build", job => job
.WithScript(new List<string>
{
"dotnet build -c $CONFIGURATION --no-restore"
})
.WithArtifacts(new GitLabCiArtifacts
{
Paths = new List<string> { "bin/", "obj/" },
ExpireIn = "1 day"
}))
.WithJob("test", job => job
.WithScript(new List<string>
{
"dotnet test -c $CONFIGURATION --no-build --logger trx " +
"--collect:\"XPlat Code Coverage\""
})
.WithArtifacts(new GitLabCiArtifacts
{
Reports = new GitLabCiArtifactsReports
{
Junit = new List<string> { "**/*.trx" },
CoverageReport = new List<string>
{
"**/coverage.cobertura.xml"
}
}
}))
.WithJob("deploy", job => job
.WithScript(new List<string>
{
"dotnet nuget push **/*.nupkg --source $NUGET_SOURCE"
})
.WithWhen("manual")
.WithEnvironment(new GitLabCiJobTemplateEnvironmentConfig
{
Name = "production",
Url = "https://nuget.example.com"
}))
.BuildAsync();
var ciFile = result.ValueOrThrow().Resolved();using FrenchExDev.Net.GitLab.Ci.Yaml;
using FrenchExDev.Net.GitLab.Ci.Yaml.Serialization;
// Build a .NET pipeline with build, test, and deploy stages
var result = await new GitLabCiFileBuilder()
.WithStages(new List<object> { "build", "test", "deploy" })
.WithVariables(vars => vars
.With("DOTNET_VERSION", "9.0")
.With("CONFIGURATION", "Release")
.With("NUGET_PACKAGES_DIRECTORY", ".nuget"))
.WithDefault(new GitLabCiDefault
{
Image = "mcr.microsoft.com/dotnet/sdk:9.0",
Tags = new List<object> { "docker", "linux" }
})
.WithJob("restore", job => job
.WithScript(new List<string>
{
"dotnet restore --packages $NUGET_PACKAGES_DIRECTORY"
})
.WithArtifacts(new GitLabCiArtifacts
{
Paths = new List<string> { "$NUGET_PACKAGES_DIRECTORY/" },
ExpireIn = "1 hour"
}))
.WithJob("build", job => job
.WithScript(new List<string>
{
"dotnet build -c $CONFIGURATION --no-restore"
})
.WithArtifacts(new GitLabCiArtifacts
{
Paths = new List<string> { "bin/", "obj/" },
ExpireIn = "1 day"
}))
.WithJob("test", job => job
.WithScript(new List<string>
{
"dotnet test -c $CONFIGURATION --no-build --logger trx " +
"--collect:\"XPlat Code Coverage\""
})
.WithArtifacts(new GitLabCiArtifacts
{
Reports = new GitLabCiArtifactsReports
{
Junit = new List<string> { "**/*.trx" },
CoverageReport = new List<string>
{
"**/coverage.cobertura.xml"
}
}
}))
.WithJob("deploy", job => job
.WithScript(new List<string>
{
"dotnet nuget push **/*.nupkg --source $NUGET_SOURCE"
})
.WithWhen("manual")
.WithEnvironment(new GitLabCiJobTemplateEnvironmentConfig
{
Name = "production",
Url = "https://nuget.example.com"
}))
.BuildAsync();
var ciFile = result.ValueOrThrow().Resolved();Step 2: Serialize to YAML
var yaml = GitLabCiYamlWriter.Serialize(ciFile);
Console.WriteLine(yaml);var yaml = GitLabCiYamlWriter.Serialize(ciFile);
Console.WriteLine(yaml);Output:
stages:
- build
- test
- deploy
variables:
DOTNET_VERSION: "9.0"
CONFIGURATION: Release
NUGET_PACKAGES_DIRECTORY: .nuget
default:
image: mcr.microsoft.com/dotnet/sdk:9.0
tags:
- docker
- linux
restore:
script:
- dotnet restore --packages $NUGET_PACKAGES_DIRECTORY
artifacts:
paths:
- $NUGET_PACKAGES_DIRECTORY/
expire_in: 1 hour
build:
script:
- dotnet build -c $CONFIGURATION --no-restore
artifacts:
paths:
- bin/
- obj/
expire_in: 1 day
test:
script:
- dotnet test -c $CONFIGURATION --no-build --logger trx --collect:"XPlat Code Coverage"
artifacts:
reports:
junit:
- "**/*.trx"
coverage_report:
- "**/coverage.cobertura.xml"
deploy:
script:
- dotnet nuget push **/*.nupkg --source $NUGET_SOURCE
when: manual
environment:
name: production
url: https://nuget.example.comstages:
- build
- test
- deploy
variables:
DOTNET_VERSION: "9.0"
CONFIGURATION: Release
NUGET_PACKAGES_DIRECTORY: .nuget
default:
image: mcr.microsoft.com/dotnet/sdk:9.0
tags:
- docker
- linux
restore:
script:
- dotnet restore --packages $NUGET_PACKAGES_DIRECTORY
artifacts:
paths:
- $NUGET_PACKAGES_DIRECTORY/
expire_in: 1 hour
build:
script:
- dotnet build -c $CONFIGURATION --no-restore
artifacts:
paths:
- bin/
- obj/
expire_in: 1 day
test:
script:
- dotnet test -c $CONFIGURATION --no-build --logger trx --collect:"XPlat Code Coverage"
artifacts:
reports:
junit:
- "**/*.trx"
coverage_report:
- "**/coverage.cobertura.xml"
deploy:
script:
- dotnet nuget push **/*.nupkg --source $NUGET_SOURCE
when: manual
environment:
name: production
url: https://nuget.example.comStep 3: Read Back and Modify (Round-Trip)
// Read the YAML back
var parsed = GitLabCiYamlReader.Deserialize(yaml);
// Verify round-trip
Console.WriteLine($"Stages: {parsed.Stages?.Count}"); // 3
Console.WriteLine($"Jobs: {parsed.Jobs?.Count}"); // 4
Console.WriteLine($"Build script: {parsed.Jobs!["build"].Script![0]}");
// dotnet build -c $CONFIGURATION --no-restore
// Modify: add a lint job
parsed.Jobs["lint"] = new GitLabCiJob
{
Script = new List<string> { "dotnet format --verify-no-changes" }
};
// Re-serialize
var updatedYaml = GitLabCiYamlWriter.Serialize(parsed);
File.WriteAllText(".gitlab-ci.yml", updatedYaml);// Read the YAML back
var parsed = GitLabCiYamlReader.Deserialize(yaml);
// Verify round-trip
Console.WriteLine($"Stages: {parsed.Stages?.Count}"); // 3
Console.WriteLine($"Jobs: {parsed.Jobs?.Count}"); // 4
Console.WriteLine($"Build script: {parsed.Jobs!["build"].Script![0]}");
// dotnet build -c $CONFIGURATION --no-restore
// Modify: add a lint job
parsed.Jobs["lint"] = new GitLabCiJob
{
Script = new List<string> { "dotnet format --verify-no-changes" }
};
// Re-serialize
var updatedYaml = GitLabCiYamlWriter.Serialize(parsed);
File.WriteAllText(".gitlab-ci.yml", updatedYaml);Step 4: Modular Composition with Contributors
// Build the same pipeline using contributors
var modularFile = new GitLabCiFile()
.Apply(new DotNetBuildContributor())
.Apply(new DotNetTestContributor())
.Apply(new DockerBuildContributor("registry.example.com/app:latest"));
var modularYaml = GitLabCiYamlWriter.Serialize(modularFile);// Build the same pipeline using contributors
var modularFile = new GitLabCiFile()
.Apply(new DotNetBuildContributor())
.Apply(new DotNetTestContributor())
.Apply(new DockerBuildContributor("registry.example.com/app:latest"));
var modularYaml = GitLabCiYamlWriter.Serialize(modularFile);Step 5: Version Checking
// Check if our target GitLab version supports all features
var targetVersion = GitLabCiVersion.Parse("18.3.0");
// Check the 'run' keyword (requires 18.5+)
var runProp = typeof(GitLabCiJob).GetProperty("Run");
var runSince = runProp?.GetCustomAttribute<SinceVersionAttribute>();
if (runSince is not null)
{
var requiredVersion = GitLabCiVersion.Parse(runSince.Version);
if (targetVersion < requiredVersion)
Console.WriteLine($"Warning: 'run' requires GitLab {runSince.Version}+, " +
$"but target is {targetVersion}");
}
// Check the 'inputs' keyword (requires 18.6+)
var inputsProp = typeof(GitLabCiJob).GetProperty("Inputs");
var inputsSince = inputsProp?.GetCustomAttribute<SinceVersionAttribute>();
if (inputsSince is not null)
{
var requiredVersion = GitLabCiVersion.Parse(inputsSince.Version);
if (targetVersion < requiredVersion)
Console.WriteLine($"Warning: 'inputs' requires GitLab {inputsSince.Version}+, " +
$"but target is {targetVersion}");
}
// List all supported versions
Console.WriteLine($"Supported: {GitLabCiSchemaVersions.Oldest} " +
$"to {GitLabCiSchemaVersions.Latest}");
Console.WriteLine($"Total versions: {GitLabCiSchemaVersions.Available.Count}");// Check if our target GitLab version supports all features
var targetVersion = GitLabCiVersion.Parse("18.3.0");
// Check the 'run' keyword (requires 18.5+)
var runProp = typeof(GitLabCiJob).GetProperty("Run");
var runSince = runProp?.GetCustomAttribute<SinceVersionAttribute>();
if (runSince is not null)
{
var requiredVersion = GitLabCiVersion.Parse(runSince.Version);
if (targetVersion < requiredVersion)
Console.WriteLine($"Warning: 'run' requires GitLab {runSince.Version}+, " +
$"but target is {targetVersion}");
}
// Check the 'inputs' keyword (requires 18.6+)
var inputsProp = typeof(GitLabCiJob).GetProperty("Inputs");
var inputsSince = inputsProp?.GetCustomAttribute<SinceVersionAttribute>();
if (inputsSince is not null)
{
var requiredVersion = GitLabCiVersion.Parse(inputsSince.Version);
if (targetVersion < requiredVersion)
Console.WriteLine($"Warning: 'inputs' requires GitLab {inputsSince.Version}+, " +
$"but target is {targetVersion}");
}
// List all supported versions
Console.WriteLine($"Supported: {GitLabCiSchemaVersions.Oldest} " +
$"to {GitLabCiSchemaVersions.Latest}");
Console.WriteLine($"Total versions: {GitLabCiSchemaVersions.Available.Count}");