Why Schema-Driven Code Generation?
.gitlab-ci.yml has 50+ definition types, union types, version-dependent properties, and a flat root structure. Writing and maintaining typed models by hand would be:
- Error-prone — miss a property, get it wrong, fall behind on new versions
- Time-consuming — 30+ classes with 200+ properties across 11 versions
- Unmaintainable — every GitLab release could add properties
By generating from GitLab's own schema, models are always correct by construction. Adding a new version is one CLI run + rebuild.
Why Follow the Four-Project Pattern?
| Concern | Project | Benefit |
|---|---|---|
| Triggering | Attributes | Minimal dependency, netstandard2.0 compatible |
| Schema acquisition | Design | Separate executable, no build slowdown |
| Code generation | SourceGenerator | Compile-time only, zero runtime cost |
| Consumer API | Main Library | Clean public surface |
This means no runtime schema parsing, no build-time downloads, and testability in isolation.
Why Merge Multiple Schema Versions?
GitLab evolves monthly. Rather than targeting a single version:
- Unified API — one set of models covers all supported versions
- Version metadata — each property carries
[SinceVersion]/[UntilVersion] - Latest types — property definitions use the latest schema (most complete)
- No breaking changes — adding a schema only adds properties, never removes
Why Type Aliases Mapped to Primitives?
Many definitions (like script = oneOf[string, array]) are type aliases. Rather than generating wrapper classes, they map to C# primitives (List<string>?). This keeps the API clean at the cost of losing some schema-level type distinction.
Why List<object> for Stages?
The schema defines stages as type: array without items type constraints. It maps to List<object>?. In practice, stages are always strings, but the schema doesn't enforce this.
Why Hand-Written Reader/Writer?
Unlike models and builders (which are generated), serialization is hand-written because:
- Flat root merging — jobs at root level requires custom dictionary building
- Union type handling —
string | List<string>needsIYamlTypeConverter - YamlDotNet configuration — naming conventions and null handling are runtime concerns
- Forward compatibility —
IgnoreUnmatchedPropertieshandles unknown YAML keys
Why No YAML Anchor/Alias Support?
YAML anchors (&anchor) and aliases (*alias) are syntax features, not GitLab CI concepts. The reader processes resolved YAML (YamlDotNet expands anchors), and the writer always emits explicit values. This keeps the C# model clean.
Why Catch-All Job Deserialization?
The reader wraps job deserialization in try/catch. When typed deserialization fails (e.g., a union type that doesn't map cleanly), it creates a minimal GitLabCiJob. Partial parsing is more useful than complete failure.
Main Library (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>FrenchExDev.Net.GitLab.Ci.Yaml</RootNamespace>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>
$(BaseIntermediateOutputPath)/Generated
</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Result\...\FrenchExDev.Net.Result.csproj" />
<ProjectReference Include="..\..\Builder\...\FrenchExDev.Net.Builder.csproj" />
<ProjectReference Include="..\...Attributes\...Attributes.csproj" />
<ProjectReference Include="..\...SourceGenerator\...SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\Builder\...SourceGenerator.Lib\...csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="schemas\gitlab-ci-*.json" />
<EmbeddedResource Include="schemas\gitlab-ci-*.json" />
</ItemGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>FrenchExDev.Net.GitLab.Ci.Yaml</RootNamespace>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>
$(BaseIntermediateOutputPath)/Generated
</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Result\...\FrenchExDev.Net.Result.csproj" />
<ProjectReference Include="..\..\Builder\...\FrenchExDev.Net.Builder.csproj" />
<ProjectReference Include="..\...Attributes\...Attributes.csproj" />
<ProjectReference Include="..\...SourceGenerator\...SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\Builder\...SourceGenerator.Lib\...csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="schemas\gitlab-ci-*.json" />
<EmbeddedResource Include="schemas\gitlab-ci-*.json" />
</ItemGroup>
</Project>Key configuration:
EmitCompilerGeneratedFiles— writes generated.g.csfiles toobj/Generated/for inspection and debuggingOutputItemType="Analyzer"— tells MSBuild that the SourceGenerator project is an analyzer, not a runtime dependencyReferenceOutputAssembly="false"— the generator DLL is loaded by the compiler, not referenced at runtimeAdditionalFiles— makes JSON schemas visible to the source generatorEmbeddedResource— also embeds schemas as resources (optional, for runtime access)
Source Generator (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers"
PrivateAssets="all" />
<PackageReference Include="System.Text.Json" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Builder\...SourceGenerator.Lib\...csproj" />
</ItemGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers"
PrivateAssets="all" />
<PackageReference Include="System.Text.Json" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Builder\...SourceGenerator.Lib\...csproj" />
</ItemGroup>
</Project>Key configuration:
netstandard2.0— required by Roslyn (source generators must target ns2.0)IsRoslynComponent— identifies this project as a Roslyn analyzer/generatorEnforceExtendedAnalyzerRules— enforces stricter analyzer rules for source generatorsPrivateAssets="all"— prevents transitive dependency leakingSystem.Text.Json— used for parsing JSON schemas (available in ns2.0 as a NuGet package)
Attributes (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>Minimal — zero dependencies, multi-target for maximum compatibility.
Design CLI (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Wrapper.Versioning\...csproj" />
</ItemGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Wrapper.Versioning\...csproj" />
</ItemGroup>
</Project>A simple CLI executable that depends only on Wrapper.Versioning for version collection and download pipeline.
Comparison with Other Approaches
| Approach | Type Safety | IntelliSense | Version Awareness | Modular Composition | Round-Trip | Maintenance |
|---|---|---|---|---|---|---|
| Hand-written YAML | None | IDE schema plugin (partial) | Manual | Copy-paste | N/A | High |
| YAML templating (anchors/includes) | None | None | None | Via includes | N/A | High |
| GitLab CI Lint API | Runtime only | None | Server-side | None | N/A | None |
| Third-party YAML libraries | Basic | Basic | None | None | Partial | Low |
| GitLab.Ci.Yaml | Full compile-time | Full | Automatic (11 versions) | Contributors | Full | Automatic |
Why Schema-Driven Generation Wins
Correctness by construction — Models are generated from the official schema. If it compiles, the property name is valid.
Zero maintenance burden — When GitLab releases 18.11, run the Design CLI, rebuild, and all new properties appear automatically with
[SinceVersion("18.11.0")].Composability — The contributor pattern enables reusable pipeline fragments. Build a library of contributors for your organization's standard patterns.
Round-trip fidelity — Read an existing
.gitlab-ci.yml, modify it programmatically, write it back. The YAML stays clean.Version intelligence — Know exactly when each property was introduced or removed. Target a specific GitLab version with confidence.
Architecture at a Glance
GitLab.Ci.Yaml/
├── doc/
│ ├── ARCHITECTURE.md Design documentation
│ ├── HOW-TO.md Usage guide
│ └── PHILOSOPHY.md Design rationale
├── src/
│ ├── FrenchExDev.Net.GitLab.Ci.Yaml/ Main library (net10.0)
│ │ ├── GitLabCiBundleDescriptor.cs [GitLabCiBundle] trigger
│ │ ├── GitLabCiVersion.cs Version record + comparison
│ │ ├── IGitLabCiContributor.cs Contributor interface
│ │ ├── GitLabCiFileExtensions.cs Fluent .Apply() extensions
│ │ ├── Serialization/
│ │ │ ├── GitLabCiYamlWriter.cs Model → YAML
│ │ │ ├── GitLabCiYamlReader.cs YAML → Model
│ │ │ ├── StringOrListConverter.cs string|list handler
│ │ │ └── GitLabCiNamingConvention.cs PascalCase ↔ snake_case
│ │ ├── schemas/ 11 versioned JSON schemas
│ │ │ ├── gitlab-ci-v18.0.0.json
│ │ │ ├── ...
│ │ │ └── gitlab-ci-v18.10.0.json
│ │ └── obj/Generated/ 61 auto-generated .g.cs files
│ │
│ ├── FrenchExDev.Net.GitLab.Ci.Yaml.Attributes/ Marker attribute (ns2.0+net10)
│ │ └── GitLabCiBundleAttribute.cs
│ │
│ ├── FrenchExDev.Net.GitLab.Ci.Yaml.SourceGenerator/ Roslyn SG (ns2.0)
│ │ ├── GitLabCiBundleGenerator.cs IIncrementalGenerator
│ │ ├── SchemaReader.cs JSON Schema → SchemaModel
│ │ ├── SchemaVersionMerger.cs Multi-version merging
│ │ ├── SchemaModels.cs Internal data models
│ │ ├── NamingHelper.cs Name conversion
│ │ ├── ModelClassEmitter.cs Schema → C# models
│ │ ├── BuilderHelper.cs Schema → builder models
│ │ └── VersionMetadataEmitter.cs Version constants + attributes
│ │
│ └── FrenchExDev.Net.GitLab.Ci.Yaml.Design/ Schema downloader (net10.0 Exe)
│ └── Program.cs GitLab API → JSON schemas
│
└── test/
└── FrenchExDev.Net.GitLab.Ci.Yaml.Tests/ 20 xUnit tests (net10.0)
├── ModelTests.cs Model + contributor tests
├── WriterTests.cs YAML serialization tests
├── ReaderTests.cs YAML deserialization tests
├── VersionTests.cs Version parsing tests
└── Fixtures/
└── simple-pipeline.yml Test dataGitLab.Ci.Yaml/
├── doc/
│ ├── ARCHITECTURE.md Design documentation
│ ├── HOW-TO.md Usage guide
│ └── PHILOSOPHY.md Design rationale
├── src/
│ ├── FrenchExDev.Net.GitLab.Ci.Yaml/ Main library (net10.0)
│ │ ├── GitLabCiBundleDescriptor.cs [GitLabCiBundle] trigger
│ │ ├── GitLabCiVersion.cs Version record + comparison
│ │ ├── IGitLabCiContributor.cs Contributor interface
│ │ ├── GitLabCiFileExtensions.cs Fluent .Apply() extensions
│ │ ├── Serialization/
│ │ │ ├── GitLabCiYamlWriter.cs Model → YAML
│ │ │ ├── GitLabCiYamlReader.cs YAML → Model
│ │ │ ├── StringOrListConverter.cs string|list handler
│ │ │ └── GitLabCiNamingConvention.cs PascalCase ↔ snake_case
│ │ ├── schemas/ 11 versioned JSON schemas
│ │ │ ├── gitlab-ci-v18.0.0.json
│ │ │ ├── ...
│ │ │ └── gitlab-ci-v18.10.0.json
│ │ └── obj/Generated/ 61 auto-generated .g.cs files
│ │
│ ├── FrenchExDev.Net.GitLab.Ci.Yaml.Attributes/ Marker attribute (ns2.0+net10)
│ │ └── GitLabCiBundleAttribute.cs
│ │
│ ├── FrenchExDev.Net.GitLab.Ci.Yaml.SourceGenerator/ Roslyn SG (ns2.0)
│ │ ├── GitLabCiBundleGenerator.cs IIncrementalGenerator
│ │ ├── SchemaReader.cs JSON Schema → SchemaModel
│ │ ├── SchemaVersionMerger.cs Multi-version merging
│ │ ├── SchemaModels.cs Internal data models
│ │ ├── NamingHelper.cs Name conversion
│ │ ├── ModelClassEmitter.cs Schema → C# models
│ │ ├── BuilderHelper.cs Schema → builder models
│ │ └── VersionMetadataEmitter.cs Version constants + attributes
│ │
│ └── FrenchExDev.Net.GitLab.Ci.Yaml.Design/ Schema downloader (net10.0 Exe)
│ └── Program.cs GitLab API → JSON schemas
│
└── test/
└── FrenchExDev.Net.GitLab.Ci.Yaml.Tests/ 20 xUnit tests (net10.0)
├── ModelTests.cs Model + contributor tests
├── WriterTests.cs YAML serialization tests
├── ReaderTests.cs YAML deserialization tests
├── VersionTests.cs Version parsing tests
└── Fixtures/
└── simple-pipeline.yml Test data