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 first — stages, 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 omission — ConfigureDefaultValuesHandling(OmitNull | OmitEmptyCollections) ensures clean output without null or [] noise.

  5. No aliases — DisableAliases() 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