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

Implementation

public static class GitLabCiYamlWriter
{
    private static readonly ISerializer Serializer = new SerializerBuilder()
        .WithNamingConvention(UnderscoredNamingConvention.Instance)
        .ConfigureDefaultValuesHandling(
            DefaultValuesHandling.OmitNull |
            DefaultValuesHandling.OmitEmptyCollections)
        .DisableAliases()
        .Build();

    public static string Serialize(GitLabCiFile ciFile)
    {
        var dict = BuildRootDictionary(ciFile);
        return Serializer.Serialize(dict);
    }

    public static void Serialize(GitLabCiFile ciFile, TextWriter writer)
    {
        var dict = BuildRootDictionary(ciFile);
        Serializer.Serialize(writer, dict);
    }

    private static Dictionary<string, object?> BuildRootDictionary(
        GitLabCiFile ciFile)
    {
        var dict = new Dictionary<string, object?>();

        // Reserved root-level keys first
        if (ciFile.Stages is not null)
            dict["stages"] = ciFile.Stages;
        if (ciFile.Variables is not null)
            dict["variables"] = ciFile.Variables;
        if (ciFile.Include is not null)
            dict["include"] = ciFile.Include;
        if (ciFile.Default is not null)
            dict["default"] = ciFile.Default;
        if (ciFile.Workflow is not null)
            dict["workflow"] = ciFile.Workflow;

        // Extensions (arbitrary root properties)
        if (ciFile.Extensions is not null)
        {
            foreach (var kvp in ciFile.Extensions)
                dict[kvp.Key] = kvp.Value;
        }

        // Jobs merged at root level (NOT nested under "jobs:")
        if (ciFile.Jobs is not null)
        {
            foreach (var kvp in ciFile.Jobs)
                dict[kvp.Key] = kvp.Value;
        }

        return dict;
    }
}

Writer Design Decisions

  1. Flat dictionary — The writer builds a flat Dictionary<string, object?> and serializes it. This naturally produces the flat root structure GitLab expects.

  2. Reserved keys firststages, variables, include, default, workflow are added before jobs. This ensures conventional YAML ordering.

  3. No jobs: wrapper — Jobs are merged directly into the root dictionary. The YAML output has build:, not jobs: { build: }.

  4. Null omissionConfigureDefaultValuesHandling(OmitNull | OmitEmptyCollections) ensures clean output without null or [] noise.

  5. No aliasesDisableAliases() prevents YamlDotNet from using YAML anchors (&) and aliases (*). Every value is explicit.

  6. UnderscoredNamingConvention — PascalCase C# properties become snake_case YAML keys automatically.

Diagram
BuildRootDictionary flattens stages, variables, and every job into a single root dictionary so YamlDotNet emits the flat structure GitLab expects, with no jobs wrapper.

Example Output

Given this C# model:

var file = new GitLabCiFile
{
    Stages = new List<object> { "build", "test", "deploy" },
    Variables = new Dictionary<string, object?>
    {
        ["NODE_VERSION"] = "20"
    },
    Jobs = new Dictionary<string, GitLabCiJob>
    {
        ["build"] = new GitLabCiJob
        {
            Image = "node:20",
            Script = new List<string> { "npm ci", "npm run build" }
        },
        ["test"] = new GitLabCiJob
        {
            Image = "node:20",
            Script = new List<string> { "npm test" }
        },
        ["deploy"] = new GitLabCiJob
        {
            Script = new List<string> { "echo \"Deploying...\"" },
            When = "manual"
        }
    }
};

var yaml = GitLabCiYamlWriter.Serialize(file);

Produces:

stages:
- build
- test
- deploy
variables:
  NODE_VERSION: "20"
build:
  image: node:20
  script:
  - npm ci
  - npm run build
test:
  image: node:20
  script:
  - npm test
deploy:
  script:
  - echo "Deploying..."
  when: manual

Notice: no jobs: wrapper, no null properties, snake_case keys.


YAML Deserialization: The Reader

The GitLabCiYamlReader parses a .gitlab-ci.yml file into a GitLabCiFile model. This is the inverse of the writer, but more complex because YAML is more permissive than the typed model.

Implementation

public static class GitLabCiYamlReader
{
    private static readonly HashSet<string> ReservedKeys =
        new(StringComparer.OrdinalIgnoreCase)
    {
        "stages", "variables", "include", "default", "workflow",
        "image", "services", "before_script", "after_script", "cache",
        "spec", "pages",
    };

    private static readonly IDeserializer JobDeserializer =
        new DeserializerBuilder()
            .WithNamingConvention(UnderscoredNamingConvention.Instance)
            .WithTypeConverter(new StringOrListConverter())
            .IgnoreUnmatchedProperties()
            .Build();

    public static GitLabCiFile Deserialize(string yaml)
    {
        // Phase 1: Raw deserialization (untyped)
        var rawDeserializer = new DeserializerBuilder().Build();
        var raw = rawDeserializer
            .Deserialize<Dictionary<string, object?>>(yaml);

        if (raw is null)
            return new GitLabCiFile();

        var ciFile = new GitLabCiFile();
        var serializer = new SerializerBuilder().Build();

        // Phase 2: Populate known root properties
        if (raw.TryGetValue("stages", out var stages) &&
            stages is IList<object?> stageList)
            ciFile.Stages = stageList.Cast<object>().ToList();

        if (raw.TryGetValue("variables", out var vars) &&
            vars is IDictionary<object, object?> varDict)
            ciFile.Variables = varDict
                .ToDictionary(k => k.Key?.ToString() ?? "", k => k.Value);

        // Phase 3: Extract job entries
        var jobs = new Dictionary<string, GitLabCiJob>();
        foreach (var kvp in raw)
        {
            // Skip reserved keys
            if (ReservedKeys.Contains(kvp.Key))
                continue;
            // Skip templates (start with .)
            if (kvp.Key.StartsWith("."))
                continue;

            // Only process mappings (not strings or lists)
            if (kvp.Value is not null and not string and not IList<object?>)
            {
                try
                {
                    // Re-serialize the untyped value back to YAML
                    var jobYaml = serializer.Serialize(kvp.Value);
                    // Then deserialize it as a typed GitLabCiJob
                    var job = JobDeserializer
                        .Deserialize<GitLabCiJob>(jobYaml);
                    if (job is not null)
                        jobs[kvp.Key] = job;
                }
                catch
                {
                    // Incompatible structure → minimal job
                    jobs[kvp.Key] = new GitLabCiJob();
                }
            }
        }

        if (jobs.Count > 0)
            ciFile.Jobs = jobs;

        return ciFile;
    }

    public static GitLabCiFile Deserialize(TextReader reader)
    {
        return Deserialize(reader.ReadToEnd());
    }
}

Two-Phase Deserialization

The reader uses a clever two-phase approach:

Phase 1: Raw deserialization — Parse the entire YAML into an untyped Dictionary<string, object?>. This handles the flat root structure naturally — every key (reserved or job) becomes a dictionary entry.

Phase 2: Typed extraction — For each non-reserved, non-template key, re-serialize the value back to YAML, then deserialize it as a typed GitLabCiJob. This round-trip through YAML ensures the JobDeserializer's type converters and naming conventions are applied correctly.

Diagram
Phase 1 parses the flat root into an untyped dictionary; Phase 2 round-trips each non-reserved, non-template entry through YAML so the typed job deserializer applies its converters and naming conventions.

The StringOrListConverter

GitLab CI allows script: to be either a string or a list. The StringOrListConverter handles this during typed deserialization:

public sealed class StringOrListConverter : IYamlTypeConverter
{
    public bool Accepts(Type type) => type == typeof(List<string>);

    public object? ReadYaml(IParser parser, Type type,
        ObjectDeserializer rootDeserializer)
    {
        // Single string → wrap in list
        if (parser.TryConsume<Scalar>(out var scalar))
            return new List<string> { scalar.Value };

        // Sequence → read all items
        if (parser.TryConsume<SequenceStart>(out _))
        {
            var list = new List<string>();
            while (!parser.TryConsume<SequenceEnd>(out _))
            {
                if (parser.TryConsume<Scalar>(out var item))
                    list.Add(item.Value);
                else
                    parser.SkipThisAndNestedEvents();
            }
            return list;
        }

        if (parser.TryConsume<NodeEvent>(out _))
            return null;

        return null;
    }

    public void WriteYaml(IEmitter emitter, object? value,
        Type type, ObjectSerializer serializer)
    {
        if (value is not List<string> list)
        {
            emitter.Emit(new Scalar(null, null, "",
                ScalarStyle.Plain, true, false));
            return;
        }

        // Always emit as list (never as scalar)
        emitter.Emit(new SequenceStart(null, null, false,
            SequenceStyle.Block));
        foreach (var item in list)
            emitter.Emit(new Scalar(null, null, item,
                ScalarStyle.Any, true, false));
        emitter.Emit(new SequenceEnd());
    }
}

This means both of these YAML forms:

script: echo hello

and

script:
  - echo hello

Deserialize to the same List<string> { "echo hello" }.

Error Resilience

The reader uses IgnoreUnmatchedProperties() and wraps job deserialization in try/catch. This means:

  • Unknown YAML keys in jobs are silently ignored (forward compatibility)
  • Jobs with incompatible structures get a minimal GitLabCiJob() instead of throwing
  • The reader never fails — it always returns a GitLabCiFile, even if partially populated

GitLabCiNamingConvention

The naming convention handles PascalCase to snake_case conversion for YAML serialization:

public sealed class GitLabCiNamingConvention : INamingConvention
{
    public static readonly GitLabCiNamingConvention Instance = new();

    public string Apply(string value)
    {
        if (string.IsNullOrEmpty(value)) return value;

        var sb = new StringBuilder();
        for (var i = 0; i < value.Length; i++)
        {
            var c = value[i];
            if (char.IsUpper(c) && i > 0)
            {
                sb.Append('_');
                sb.Append(char.ToLowerInvariant(c));
            }
            else
            {
                sb.Append(char.ToLowerInvariant(c));
            }
        }
        return sb.ToString();
    }

    public string Reverse(string value)
    {
        if (string.IsNullOrEmpty(value)) return value;

        var sb = new StringBuilder();
        var capitalizeNext = true;
        foreach (var c in value)
        {
            if (c == '_')
            {
                capitalizeNext = true;
                continue;
            }
            sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : c);
            capitalizeNext = false;
        }
        return sb.ToString();
    }
}
C# Property YAML Key
BeforeScript before_script
AfterScript after_script
AllowFailure allow_failure
IdTokens id_tokens
ResourceGroup resource_group
ManualConfirmation manual_confirmation
StartIn start_in

The writer uses UnderscoredNamingConvention.Instance (from YamlDotNet) rather than this custom convention, but the reverse method is available for custom deserialization scenarios.


⬇ Download