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;
}
}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
Flat dictionary — The writer builds a flat
Dictionary<string, object?>and serializes it. This naturally produces the flat root structure GitLab expects.Reserved keys first —
stages,variables,include,default,workfloware added before jobs. This ensures conventional YAML ordering.No
jobs:wrapper — Jobs are merged directly into the root dictionary. The YAML output hasbuild:, notjobs: { build: }.Null omission —
ConfigureDefaultValuesHandling(OmitNull | OmitEmptyCollections)ensures clean output withoutnullor[]noise.No aliases —
DisableAliases()prevents YamlDotNet from using YAML anchors (&) and aliases (*). Every value is explicit.UnderscoredNamingConvention — PascalCase C# properties become snake_case YAML keys automatically.
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);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: manualstages:
- 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: manualNotice: 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());
}
}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.
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());
}
}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 helloscript: echo helloand
script:
- echo helloscript:
- echo helloDeserialize 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();
}
}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.