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

Emitting the Root Model

public static string EmitGitLabCiFile(string ns, UnifiedSchema schema)
{
    var sb = new StringBuilder();
    sb.AppendLine("// <auto-generated/>");
    sb.AppendLine("#nullable enable");
    sb.AppendLine();
    sb.AppendLine($"namespace {ns};");
    sb.AppendLine();

    sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
    sb.AppendLine("public partial class GitLabCiFile");
    sb.AppendLine("{");

    foreach (var up in schema.RootProperties)
    {
        var prop = up.Property;
        var type = MapRootPropertyType(prop);
        EmitVersionAttributes(sb, up);
        if (prop.IsDeprecated)
            sb.AppendLine("    [global::System.Obsolete(\"Deprecated.\")]");
        if (prop.Description is not null)
        {
            sb.AppendLine("    /// <summary>");
            sb.AppendLine($"    /// {EscapeXml(prop.Description)}");
            sb.AppendLine("    /// </summary>");
        }
        sb.AppendLine($"    public {type} {prop.CSharpName} {{ get; set; }}");
        sb.AppendLine();
    }

    // Jobs dictionary — arbitrary job names at root level
    sb.AppendLine("    /// <summary>");
    sb.AppendLine("    /// Named jobs. In YAML these appear as top-level keys " +
                  "(not under a 'jobs' key).");
    sb.AppendLine("    /// </summary>");
    sb.AppendLine("    public global::System.Collections.Generic" +
                  ".Dictionary<string, GitLabCiJob>? Jobs { get; set; }");
    sb.AppendLine();

    sb.AppendLine("    public global::System.Collections.Generic" +
                  ".Dictionary<string, object?>? Extensions { get; set; }");
    sb.AppendLine("}");

    return sb.ToString();
}

Key design decisions:

  • partial class — allows consumers to extend the generated class with additional methods
  • ExcludeFromCodeCoverage — generated code shouldn't affect coverage metrics
  • #nullable enable — all properties are nullable (YAML properties are optional)
  • Global type qualifiersglobal::System.Collections.Generic.List<> avoids namespace conflicts
  • Jobs as explicit property — not generated from the schema's additionalProperties, but hand-added to the emitter
  • Extensions dictionary — forward-compatibility for unknown root-level properties

Generated Root Model Output

Here's the actual generated GitLabCiFile.g.cs:

// <auto-generated/>
#nullable enable

namespace FrenchExDev.Net.GitLab.Ci.Yaml;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class GitLabCiFile
{
    /// <summary>
    /// Specification for pipeline configuration. Must be declared at the top of
    /// a configuration file, in a header section separated from the rest of the
    /// configuration with `---`.
    /// </summary>
    public GitLabCiSpec? Spec { get; set; }

    /// <summary>
    /// Defining `image` globally is deprecated. Use `default` instead.
    /// </summary>
    public string? Image { get; set; }

    /// <summary>
    /// Defining `services` globally is deprecated. Use `default` instead.
    /// </summary>
    public global::System.Collections.Generic.List<object>? Services { get; set; }

    /// <summary>
    /// Defining `before_script` globally is deprecated. Use `default` instead.
    /// </summary>
    public global::System.Collections.Generic.List<string>? BeforeScript { get; set; }

    /// <summary>
    /// Defining `after_script` globally is deprecated. Use `default` instead.
    /// </summary>
    public global::System.Collections.Generic.List<string>? AfterScript { get; set; }

    public global::System.Collections.Generic.Dictionary<string, object?>?
        Variables { get; set; }

    /// <summary>
    /// Defining `cache` globally is deprecated. Use `default` instead.
    /// </summary>
    public global::System.Collections.Generic.List<object>? Cache { get; set; }

    public GitLabCiDefault? Default { get; set; }

    /// <summary>
    /// Groups jobs into stages. All jobs in one stage must complete before next
    /// stage is executed. Defaults to ['build', 'test', 'deploy'].
    /// </summary>
    public global::System.Collections.Generic.List<object>? Stages { get; set; }

    /// <summary>
    /// Can be IncludeItem or IncludeItem[]. Each IncludeItem will be a string,
    /// or an object with properties for the method if including external YAML file.
    /// </summary>
    public object? Include { get; set; }

    /// <summary>
    /// A special job used to upload static sites to GitLab pages.
    /// </summary>
    public GitLabCiJob? Pages { get; set; }

    public GitLabCiWorkflow? Workflow { get; set; }

    /// <summary>
    /// Named jobs. In YAML these appear as top-level keys (not under a 'jobs' key).
    /// </summary>
    public global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?
        Jobs { get; set; }

    public global::System.Collections.Generic.Dictionary<string, object?>?
        Extensions { get; set; }
}

Emitting Definition Classes

public static IEnumerable<(string FileName, string Source)> EmitDefinitions(
    string ns, UnifiedSchema schema)
{
    foreach (var kvp in schema.Definitions)
    {
        var defName = kvp.Key;
        var def = kvp.Value;

        // Skip type aliases — they don't need classes
        if (HelperDefinitions.Contains(defName))
            continue;
        // Skip definitions with no properties
        if (def.Properties.Count == 0)
            continue;

        var className = NamingHelper.DefinitionToClassName(defName);
        var source = EmitClass(ns, className, def.Description, def.Properties,
            def.SinceVersion, def.UntilVersion);
        yield return ($"{className}.g.cs", source);

        // Emit inline object classes found in properties
        foreach (var inline in CollectInlineClasses(ns, def.Properties))
            yield return inline;
    }

    // Also collect inline classes from root properties
    foreach (var inline in CollectInlineClasses(ns, schema.RootProperties))
        yield return inline;
}

The HelperDefinitions set contains 40+ definition names that are type aliases:

private static readonly HashSet<string> HelperDefinitions = new()
{
    "string_or_list", "stringOrList",
    "string_file_list", "include_item", "!reference",
    "image", "services", "identity",
    "script", "steps", "optional_script",
    "before_script", "after_script",
    "rules", "includeRules",
    "workflowName", "if", "changes", "exists",
    "timeout", "start_in", "rulesNeeds",
    "allow_failure", "parallel", "parallel_matrix",
    "when", "cache", "filter_refs", "filter",
    "retry", "retry_max", "retry_errors",
    "interruptible",
    "tags", "step", "stepName", "stepFuncReference",
    "configInputs", "jobInputs", "inputs",
    "globalVariables", "jobVariables", "rulesVariables",
    "id_tokens", "secrets",
    "stepNamedStrings", "stepNamedValues",
};

Collecting Inline Classes

Inline classes arise when a property is defined as type: object with inline properties rather than using a $ref. The emitter recursively discovers these:

private static IEnumerable<(string FileName, string Source)> CollectInlineClasses(
    string ns, List<UnifiedProperty> properties)
{
    foreach (var up in properties)
    {
        var prop = up.Property;

        // Inline object property → generate class
        if (prop.Type == PropertyType.InlineObject &&
            prop.InlineClassName is not null &&
            prop.InlineObjectProperties is not null)
        {
            var unifiedProps = prop.InlineObjectProperties
                .ConvertAll(p => new UnifiedProperty { Property = p });
            var source = EmitClass(ns, prop.InlineClassName, null,
                unifiedProps, null, null);
            yield return ($"{prop.InlineClassName}.g.cs", source);

            // Recurse into nested inline objects
            foreach (var nested in CollectInlineClasses(ns, unifiedProps))
                yield return nested;
        }

        // oneOf[string, object] with inline object
        if (prop.Type == PropertyType.StringOrObject &&
            prop.OneOfObjectClassName is not null &&
            prop.OneOfObjectProperties is not null)
        {
            var unifiedProps = prop.OneOfObjectProperties
                .ConvertAll(p => new UnifiedProperty { Property = p });
            var source = EmitClass(ns, prop.OneOfObjectClassName, null,
                unifiedProps, null, null);
            yield return ($"{prop.OneOfObjectClassName}.g.cs", source);

            foreach (var nested in CollectInlineClasses(ns, unifiedProps))
                yield return nested;
        }

        // Array of inline objects
        if (prop.Type == PropertyType.Array &&
            prop.Items?.Type == PropertyType.InlineObject &&
            prop.Items.InlineClassName is not null &&
            prop.Items.InlineObjectProperties is not null)
        {
            // ... same pattern for array items
        }

        // Array of oneOf items with inline object
        if (prop.Type == PropertyType.Array &&
            prop.Items?.Type == PropertyType.StringOrObject &&
            prop.Items.OneOfObjectClassName is not null &&
            prop.Items.OneOfObjectProperties is not null)
        {
            // ... same pattern for oneOf array items
        }
    }
}

This recursion produces deeply nested class names like GitLabCiJobTemplateEnvironmentConfigKubernetesManagedResources — verbose, but unambiguous and fully IntelliSense-friendly.

Generated Job Model

Here's the actual generated GitLabCiJob.g.cs (showing the 30+ properties):

// <auto-generated/>
#nullable enable

namespace FrenchExDev.Net.GitLab.Ci.Yaml;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class GitLabCiJob
{
    public string? Image { get; set; }
    public global::System.Collections.Generic.List<object>? Services { get; set; }
    public global::System.Collections.Generic.List<string>? BeforeScript { get; set; }
    public global::System.Collections.Generic.List<string>? AfterScript { get; set; }
    public GitLabCiHooks? Hooks { get; set; }
    public global::System.Collections.Generic.List<object>? Rules { get; set; }
    public global::System.Collections.Generic.Dictionary<string, object?>?
        Variables { get; set; }
    public global::System.Collections.Generic.List<object>? Cache { get; set; }
    public global::System.Collections.Generic.Dictionary<string, object?>?
        IdTokens { get; set; }
    public string? Identity { get; set; }
    public global::System.Collections.Generic.Dictionary<string, object?>?
        Secrets { get; set; }

    /// <summary>
    /// Shell scripts executed by the Runner. The only required property of jobs.
    /// </summary>
    public global::System.Collections.Generic.List<string>? Script { get; set; }

    /// <summary>
    /// Specifies a list of steps to execute in the job. The `run` keyword is an
    /// alternative to `script` and allows for more advanced job configuration.
    /// </summary>
    public global::System.Collections.Generic.List<object>? Run { get; set; }

    /// <summary>Define what stage the job will run in.</summary>
    public global::System.Collections.Generic.List<string>? Stage { get; set; }

    /// <summary>Job will run *only* when these filtering options match.</summary>
    public global::System.Collections.Generic.Dictionary<string, object?>?
        Only { get; set; }

    /// <summary>
    /// The name of one or more jobs to inherit configuration from.
    /// </summary>
    public global::System.Collections.Generic.List<string>? Extends { get; set; }

    /// <summary>
    /// The list of jobs in previous stages whose sole completion is needed
    /// to start the current job.
    /// </summary>
    public global::System.Collections.Generic.List<GitLabCiJobTemplateNeedsConfig>?
        Needs { get; set; }

    /// <summary>
    /// Job will run *except* for when these filtering options match.
    /// </summary>
    public global::System.Collections.Generic.Dictionary<string, object?>?
        Except { get; set; }

    public global::System.Collections.Generic.List<object>? Tags { get; set; }
    public bool? AllowFailure { get; set; }
    public string? Timeout { get; set; }
    public string? When { get; set; }
    public string? StartIn { get; set; }

    /// <summary>
    /// Describes the Custom confirmation message for a manual job.
    /// </summary>
    public string? ManualConfirmation { get; set; }

    /// <summary>
    /// Specify a list of job names from earlier stages from which artifacts
    /// should be loaded.
    /// </summary>
    public global::System.Collections.Generic.List<string>?
        Dependencies { get; set; }

    public GitLabCiArtifacts? Artifacts { get; set; }

    /// <summary>
    /// Used to associate environment metadata with a deploy.
    /// </summary>
    public GitLabCiJobTemplateEnvironmentConfig? Environment { get; set; }

    /// <summary>Indicates that the job creates a Release.</summary>
    public GitLabCiJobTemplateRelease? Release { get; set; }

    /// <summary>
    /// Must be a regular expression, optionally but recommended to be quoted.
    /// </summary>
    public string? Coverage { get; set; }

    public global::System.Collections.Generic.Dictionary<string, object?>?
        Retry { get; set; }
    public global::System.Collections.Generic.Dictionary<string, object?>?
        Parallel { get; set; }
    public bool? Interruptible { get; set; }

    /// <summary>
    /// Limit job concurrency. Can be used to ensure that the Runner will
    /// not run certain jobs simultaneously.
    /// </summary>
    public string? ResourceGroup { get; set; }

    /// <summary>
    /// Trigger allows you to define downstream pipeline trigger.
    /// </summary>
    public GitLabCiJobTemplateTriggerConfig? Trigger { get; set; }

    /// <summary>
    /// Controls inheritance of globally-defined defaults and variables.
    /// </summary>
    public GitLabCiJobTemplateInherit? Inherit { get; set; }

    /// <summary>
    /// Deprecated. Use `pages.publish` instead.
    /// </summary>
    public string? Publish { get; set; }

    public string? Pages { get; set; }

    [SinceVersion("18.6.0")]
    public global::System.Collections.Generic.Dictionary<string, object?>?
        Inputs { get; set; }

    public global::System.Collections.Generic.Dictionary<string, object?>?
        Extensions { get; set; }
}

Notice the [SinceVersion("18.6.0")] on Inputs — this property was introduced in GitLab 18.6 and the version merger tracked it automatically.


Code Emission: BuilderHelper and BuilderEmitter

Every model class gets a corresponding builder class. The BuilderHelper bridges the gap between the schema-derived property models and the BuilderEmitter (from FrenchExDev.Net.Builder.SourceGenerator.Lib).

Creating Builder Models

internal static class BuilderHelper
{
    public static BuilderEmitModel CreateBuilderModel(
        string ns, string className, List<UnifiedProperty> properties)
    {
        var builderProps = new List<BuilderPropertyModel>();
        foreach (var up in properties)
        {
            var prop = up.Property;
            var csharpType = NamingHelper.MapCSharpType(prop);
            var (isCollection, itemType) = DetectCollection(csharpType);
            var (isDict, dictKey, dictValue) = DetectDictionary(csharpType);
            var dictValueBuilder = isDict
                ? ResolveValueBuilderClassName(dictValue) : null;

            builderProps.Add(new BuilderPropertyModel(
                prop.CSharpName,
                csharpType,
                csharpType,
                isCollection,
                itemType,
                withMethodAttributes: BuildVersionAttributes(up),
                isDictionary: isDict,
                dictKeyTypeFull: dictKey,
                dictValueTypeFull: dictValue,
                dictValueBuilderClassName: dictValueBuilder,
                dictSingularName: dictValueBuilder is not null
                    ? Singularize(prop.CSharpName) : null));
        }

        // Every model has an Extensions property
        builderProps.Add(CreateExtensionsProperty());

        return new BuilderEmitModel(
            ns, className, className + "Builder", builderProps);
    }
}

Collection and Dictionary Detection

The helper parses C# type strings to detect collections and dictionaries:

private static (bool IsCollection, string? ItemType) DetectCollection(
    string csharpType)
{
    const string listPrefix = "global::System.Collections.Generic.List<";
    if (csharpType.StartsWith(listPrefix))
    {
        var inner = csharpType.Substring(listPrefix.Length);
        var closingBracket = inner.LastIndexOf('>');
        if (closingBracket > 0)
        {
            var itemType = inner.Substring(0, closingBracket);
            return (true, itemType);
        }
    }
    return (false, null);
}

private static (bool IsDictionary, string? KeyType, string? ValueType)
    DetectDictionary(string csharpType)
{
    const string dictPrefix = "global::System.Collections.Generic.Dictionary<";
    if (!csharpType.StartsWith(dictPrefix))
        return (false, null, null);

    var inner = csharpType.Substring(dictPrefix.Length);
    var closingBracket = inner.LastIndexOf('>');
    if (closingBracket <= 0)
        return (false, null, null);

    var keyValue = inner.Substring(0, closingBracket);
    var commaIdx = keyValue.IndexOf(", ");
    if (commaIdx <= 0)
        return (false, null, null);

    var keyType = keyValue.Substring(0, commaIdx);
    var valueType = keyValue.Substring(commaIdx + 2);
    return (true, keyType, valueType);
}

Nested Builder Resolution

When a dictionary's value type is a known model class (not object or string), the builder generates a typed builder callback:

private static string? ResolveValueBuilderClassName(string? valueType)
{
    if (valueType is null) return null;
    var clean = valueType.TrimEnd('?').Trim();
    if (clean == "object" || clean == "string" ||
        clean.StartsWith("global::System"))
        return null;
    return clean + "Builder";
}

For example, Dictionary<string, GitLabCiJob> gets GitLabCiJobBuilder as its value builder, enabling:

builder.WithJob("build", job => job
    .WithImage("node:20")
    .WithScript(new List<string> { "npm ci", "npm run build" }))

Root Builder Special Handling

The root GitLabCiFileBuilder gets special treatment because the Jobs dictionary isn't in the schema — it's the hand-added property from EmitGitLabCiFile:

public static BuilderEmitModel CreateRootBuilderModel(
    string ns, string className, List<UnifiedProperty> rootProperties)
{
    var builderProps = new List<BuilderPropertyModel>();

    // Standard root properties from schema
    foreach (var up in rootProperties)
    {
        // ... same as CreateBuilderModel
    }

    // Jobs dictionary — hand-added
    const string jobsDictType =
        "global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?";
    builderProps.Add(new BuilderPropertyModel(
        "Jobs", jobsDictType, jobsDictType,
        isDictionary: true,
        dictKeyTypeFull: "string",
        dictValueTypeFull: "GitLabCiJob",
        dictValueBuilderClassName: "GitLabCiJobBuilder",
        dictSingularName: "Job"));

    builderProps.Add(CreateExtensionsProperty());

    return new BuilderEmitModel(
        ns, className, className + "Builder", builderProps);
}

The dictSingularName: "Job" parameter generates the WithJob(string key, Action<GitLabCiJobBuilder> configure) convenience method.

Generated Builder Output

The generated GitLabCiFileBuilder.g.cs includes:

public partial class GitLabCiFileBuilder
    : global::FrenchExDev.Net.Builder.AbstractBuilder<GitLabCiFile>
{
    // ── Input properties ─────────────────────────────────────────
    protected GitLabCiSpec? Spec { get; private set; }
    public GitLabCiFileBuilder WithSpec(GitLabCiSpec? value)
        { Spec = value; return this; }

    protected string? Image { get; private set; }
    public GitLabCiFileBuilder WithImage(string? value)
        { Image = value; return this; }

    protected global::System.Collections.Generic.List<object>? Stages
        { get; private set; }
    public GitLabCiFileBuilder WithStages(
        global::System.Collections.Generic.List<object>? value)
        { Stages = value; return this; }

    // Dictionary with inline builder
    protected global::System.Collections.Generic.Dictionary<string, object?>?
        Variables { get; private set; }
    public GitLabCiFileBuilder WithVariables(
        global::System.Collections.Generic.Dictionary<string, object?>? value)
        { Variables = value; return this; }
    public GitLabCiFileBuilder WithVariables(
        global::System.Action<global::FrenchExDev.Net.Builder
            .DictionaryBuilder<string, object?>> configure)
    {
        var __b = new global::FrenchExDev.Net.Builder
            .DictionaryBuilder<string, object?>();
        configure(__b);
        Variables = __b.Build();
        return this;
    }

    // Jobs dictionary with typed builder
    protected global::System.Collections.Generic.Dictionary<string, GitLabCiJob>?
        Jobs { get; private set; }
    public GitLabCiFileBuilder WithJobs(
        global::System.Collections.Generic.Dictionary<string, GitLabCiJob>? value)
        { Jobs = value; return this; }
    private global::FrenchExDev.Net.Builder
        .DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>? __JobsBuilder;
    public GitLabCiFileBuilder WithJobs(
        global::System.Action<global::FrenchExDev.Net.Builder
            .DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>> configure)
    {
        __JobsBuilder = new global::FrenchExDev.Net.Builder
            .DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>();
        configure(__JobsBuilder);
        return this;
    }
    public GitLabCiFileBuilder WithJob(string key,
        global::System.Action<GitLabCiJobBuilder> configure)
    {
        __JobsBuilder ??= new global::FrenchExDev.Net.Builder
            .DictionaryBuilder<string, GitLabCiJob, GitLabCiJobBuilder>();
        var __b = new GitLabCiJobBuilder();
        configure(__b);
        __JobsBuilder.With(key, __b);
        return this;
    }

    // ── Per-property validation (virtual, overridable) ───────────
    protected virtual IEnumerable<Exception>? ValidateSpec(GitLabCiSpec? value)
        => null;
    protected virtual IEnumerable<Exception>? ValidateImage(string? value)
        => null;
    protected virtual IEnumerable<Exception>? ValidateStages(
        List<object>? value) => null;
    protected virtual IEnumerable<Exception>? ValidateStagesItem(
        object item, int index) => null;
    // ... validators for every property ...

    // ── ValidateAsync ────────────────────────────────────────────
    protected override Task<Result<ValidationResult>> ValidateAsync(
        CancellationToken cancellationToken = default)
    {
        var __result = new ValidationResult();
        var __type = typeof(GitLabCiFileBuilder);

        foreach (var __err in ValidateSpec(Spec)
            ?? Array.Empty<Exception>())
            __result.AddError(
                new MemberName(nameof(Spec), __type), __err);

        // ... validation for every property ...

        return Task.FromResult(
            Result<ValidationResult>.Success(__result));
    }

    // ── CreateInstance ───────────────────────────────────────────
    protected virtual GitLabCiFile CreateInstance()
    {
        return new GitLabCiFile
        {
            Spec = Spec,
            Image = Image,
            Services = Services,
            BeforeScript = BeforeScript,
            AfterScript = AfterScript,
            Variables = Variables,
            Cache = Cache,
            Default = Default,
            Stages = Stages,
            Include = Include,
            Pages = Pages,
            Workflow = Workflow,
            Jobs = Jobs,
            Extensions = Extensions,
        };
    }
}
Diagram
Every generated builder descends from AbstractBuilder and automatically gains fluent With methods, dictionary helpers, validation hooks, async Result return, and per-method version attributes.

NamingHelper: The Snake_Case Bridge

YAML uses snake_case, C# uses PascalCase. The NamingHelper bridges this gap at multiple levels.

PascalCase Conversion

internal static class NamingHelper
{
    public static string ToPascalCase(string snakeCase)
    {
        var sb = new StringBuilder();
        var capitalizeNext = true;
        foreach (var c in snakeCase)
        {
            if (c == '_' || c == '-' || c == '.' ||
                c == '$' || c == '!' || c == '@')
            {
                capitalizeNext = true;
                continue;
            }
            if (!char.IsLetterOrDigit(c))
                continue;
            sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : c);
            capitalizeNext = false;
        }
        return sb.ToString();
    }
}

This handles GitLab's naming conventions:

  • before_scriptBeforeScript
  • after_scriptAfterScript
  • allow_failureAllowFailure
  • id_tokensIdTokens
  • start_inStartIn
  • $schemaSchema (the $ is stripped)
  • !referenceReference (the ! is stripped)

Definition to Class Name Mapping

Schema definition names don't always map cleanly to class names. The DefinitionToClassName method provides an explicit mapping for common definitions and a fallback for unknown ones:

public static string DefinitionToClassName(string definitionName)
{
    var pascal = ToPascalCase(definitionName);
    return pascal switch
    {
        "Job" => "GitLabCiJob",
        "Image" => "GitLabCiImage",
        "Service" => "GitLabCiService",
        "Services" => "GitLabCiServices",
        "Cache" => "GitLabCiCache",
        "Artifacts" => "GitLabCiArtifacts",
        "Rules" or "Rule" => "GitLabCiRule",
        "Script" => "GitLabCiScript",
        "Variables" or "Variable" => "GitLabCiVariables",
        "Needs" or "Need" => "GitLabCiNeed",
        "Include" => "GitLabCiInclude",
        "Secret" or "Secrets" => "GitLabCiSecrets",
        "Workflow" => "GitLabCiWorkflow",
        "Default" => "GitLabCiDefault",
        "Retry" => "GitLabCiRetry",
        "Release" => "GitLabCiRelease",
        "Environment" => "GitLabCiEnvironment",
        "Trigger" => "GitLabCiTrigger",
        "Pages" => "GitLabCiPages",
        "Inherit" => "GitLabCiInherit",
        "IdTokens" or "IdToken" => "GitLabCiIdToken",
        "Parallel" => "GitLabCiParallel",
        _ => "GitLabCi" + pascal  // Fallback: prefix with GitLabCi
    };
}

The GitLabCi prefix prevents name collisions with common types (Default, Cache, Environment, Parallel would all collide with .NET framework types).

Type Mapping

The final piece maps PropertyType values to C# type strings:

public static string MapCSharpType(PropertyModel prop)
{
    return prop.Type switch
    {
        PropertyType.String => "string?",
        PropertyType.Integer => "int?",
        PropertyType.Number => "double?",
        PropertyType.Boolean => "bool?",
        PropertyType.StringOrBoolean => "bool?",
        PropertyType.StringOrInteger => "int?",
        PropertyType.InlineObject when prop.InlineClassName is not null =>
            $"{prop.InlineClassName}?",
        PropertyType.StringOrObject when prop.OneOfObjectClassName is not null =>
            $"{prop.OneOfObjectClassName}?",
        PropertyType.Array when prop.Items is not null =>
            MapArrayItemType(prop.Items),
        PropertyType.Array =>
            "global::System.Collections.Generic.List<object>?",
        PropertyType.Object =>
            "global::System.Collections.Generic.Dictionary<string, object?>?",
        PropertyType.Ref when prop.Ref is not null =>
            $"{DefinitionToClassName(prop.Ref)}?",
        PropertyType.StringOrList =>
            "global::System.Collections.Generic.List<string>?",
        PropertyType.StringOrObject => "object?",
        _ => "object?"
    };
}

private static string MapArrayItemType(PropertyModel items)
{
    if (items.Type == PropertyType.Ref && items.Ref is not null)
        return $"global::System.Collections.Generic" +
               $".List<{DefinitionToClassName(items.Ref)}>?";
    if (items.Type == PropertyType.InlineObject &&
        items.InlineClassName is not null)
        return $"global::System.Collections.Generic" +
               $".List<{items.InlineClassName}>?";
    if (items.Type == PropertyType.String)
        return "global::System.Collections.Generic.List<string>?";
    if (items.Type == PropertyType.Integer ||
        items.Type == PropertyType.StringOrInteger)
        return "global::System.Collections.Generic.List<int>?";
    if (items.Type == PropertyType.StringOrObject &&
        items.OneOfObjectClassName is not null)
        return $"global::System.Collections.Generic" +
               $".List<{items.OneOfObjectClassName}>?";

    return "global::System.Collections.Generic.List<object>?";
}

VersionMetadataEmitter

The final emitter generates a static class with all supported versions and the attribute types used for version annotations:

internal static class VersionMetadataEmitter
{
    public static string Emit(string ns, UnifiedSchema schema)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine("#nullable enable");
        sb.AppendLine();
        sb.AppendLine($"namespace {ns};");
        sb.AppendLine();
        sb.AppendLine("[global::System.Diagnostics.CodeAnalysis" +
                      ".ExcludeFromCodeCoverage]");
        sb.AppendLine("public static class GitLabCiSchemaVersions");
        sb.AppendLine("{");

        // Version list
        sb.AppendLine("    private static readonly global::System" +
            ".Collections.Generic.List<string> _versions = new()");
        sb.AppendLine("    {");
        foreach (var v in schema.Versions)
            sb.AppendLine($"        \"{v}\",");
        sb.AppendLine("    };");
        sb.AppendLine();
        sb.AppendLine("    public static global::System.Collections" +
            ".Generic.IReadOnlyList<string> Available => _versions;");
        sb.AppendLine();

        if (schema.Versions.Count > 0)
        {
            sb.AppendLine($"    public static string Latest => " +
                $"\"{schema.Versions[schema.Versions.Count - 1]}\";");
            sb.AppendLine($"    public static string Oldest => " +
                $"\"{schema.Versions[0]}\";");
        }

        sb.AppendLine("}");
        sb.AppendLine();

        // SinceVersionAttribute
        sb.AppendLine("[global::System.AttributeUsage(" +
            "global::System.AttributeTargets.Property | " +
            "global::System.AttributeTargets.Method | " +
            "global::System.AttributeTargets.Class, AllowMultiple = false)]");
        sb.AppendLine("public sealed class SinceVersionAttribute " +
            ": global::System.Attribute");
        sb.AppendLine("{");
        sb.AppendLine("    public string Version { get; }");
        sb.AppendLine("    public SinceVersionAttribute(string version) " +
            "=> Version = version;");
        sb.AppendLine("}");
        sb.AppendLine();

        // UntilVersionAttribute
        sb.AppendLine("[global::System.AttributeUsage(" +
            "global::System.AttributeTargets.Property | " +
            "global::System.AttributeTargets.Method | " +
            "global::System.AttributeTargets.Class, AllowMultiple = false)]");
        sb.AppendLine("public sealed class UntilVersionAttribute " +
            ": global::System.Attribute");
        sb.AppendLine("{");
        sb.AppendLine("    public string Version { get; }");
        sb.AppendLine("    public UntilVersionAttribute(string version) " +
            "=> Version = version;");
        sb.AppendLine("}");

        return sb.ToString();
    }
}

Generated Output

// <auto-generated/>
#nullable enable

namespace FrenchExDev.Net.GitLab.Ci.Yaml;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class GitLabCiSchemaVersions
{
    private static readonly global::System.Collections.Generic.List<string>
        _versions = new()
    {
        "18.0.0",
        "18.1.0",
        "18.2.0",
        "18.3.0",
        "18.4.0",
        "18.5.0",
        "18.6.0",
        "18.7.0",
        "18.8.0",
        "18.9.0",
        "18.10.0",
    };

    public static global::System.Collections.Generic.IReadOnlyList<string>
        Available => _versions;

    public static string Latest => "18.10.0";
    public static string Oldest => "18.0.0";
}

⬇ Download