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

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);
}

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;
    }
}

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

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

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

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);
Diagram
Contributors mirror DockerCompose.Bundle's composition pattern — each one adds its own stages and jobs, and chained Apply calls assemble a full pipeline without any shared mutable state.

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;
}

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}");

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

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:");
    }
}

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");
    }
}

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();
    }
}

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:
    - main

End-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();

Step 2: Serialize to 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.com

Step 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);

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);

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}");
Diagram
The full runtime loop — builders produce a typed file, the writer emits YAML, the reader parses it back, modifications mutate the typed graph, and contributors offer a composable alternative to direct construction.

⬇ Download