GitLab's Release Tag Convention
GitLab uses tags like v18.10.0-ee (Enterprise Edition). The Design project strips the v prefix and -ee suffix to get clean semantic versions:
v18.10.0-ee → 18.10.0
v18.0.0-ee → 18.0.0v18.10.0-ee → 18.10.0
v18.0.0-ee → 18.0.0Each tag points to a specific version of the CI schema at:
https://gitlab.com/gitlab-org/gitlab/-/raw/v{version}-ee/app/assets/javascripts/editor/schema/ci.jsonhttps://gitlab.com/gitlab-org/gitlab/-/raw/v{version}-ee/app/assets/javascripts/editor/schema/ci.jsonThe Design Pipeline
The Design project is a simple CLI that uses the Wrapper.Versioning framework (the same framework used by BinaryWrapper for downloading CLI help trees):
using FrenchExDev.Net.Wrapper.Versioning;
var outputDir = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..",
"FrenchExDev.Net.GitLab.Ci.Yaml", "schemas"));
var pipeline = new DesignPipeline<string>()
.UseHttpDownload(v =>
$"https://gitlab.com/gitlab-org/gitlab/-/raw/v{v}-ee/" +
$"app/assets/javascripts/editor/schema/ci.json")
.UseSave()
.Build();
return await new DesignPipelineRunner<string>
{
ItemCollector = new GitLabReleasesVersionCollector(
"gitlab-org%2Fgitlab",
tagToVersion: tag =>
{
// Tags are "v18.10.0-ee" → strip v prefix and -ee suffix
var v = tag.StartsWith('v') ? tag[1..] : tag;
var eeIdx = v.IndexOf("-ee", StringComparison.Ordinal);
return eeIdx >= 0 ? v[..eeIdx] : v;
}),
Pipeline = pipeline,
KeySelector = v => v,
ItemFilter = VersionFilters.LatestPatchPerMinor,
OutputDir = outputDir,
OutputFilePattern = "gitlab-ci-v{key}.json",
DefaultMinVersion = "18.0.0",
}.RunAsync(args);using FrenchExDev.Net.Wrapper.Versioning;
var outputDir = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..",
"FrenchExDev.Net.GitLab.Ci.Yaml", "schemas"));
var pipeline = new DesignPipeline<string>()
.UseHttpDownload(v =>
$"https://gitlab.com/gitlab-org/gitlab/-/raw/v{v}-ee/" +
$"app/assets/javascripts/editor/schema/ci.json")
.UseSave()
.Build();
return await new DesignPipelineRunner<string>
{
ItemCollector = new GitLabReleasesVersionCollector(
"gitlab-org%2Fgitlab",
tagToVersion: tag =>
{
// Tags are "v18.10.0-ee" → strip v prefix and -ee suffix
var v = tag.StartsWith('v') ? tag[1..] : tag;
var eeIdx = v.IndexOf("-ee", StringComparison.Ordinal);
return eeIdx >= 0 ? v[..eeIdx] : v;
}),
Pipeline = pipeline,
KeySelector = v => v,
ItemFilter = VersionFilters.LatestPatchPerMinor,
OutputDir = outputDir,
OutputFilePattern = "gitlab-ci-v{key}.json",
DefaultMinVersion = "18.0.0",
}.RunAsync(args);Let's break this down:
GitLabReleasesVersionCollector— queries the GitLab API for release tags matching thegitlab-org/gitlabproject. ThetagToVersionlambda strips thevprefix and-eesuffix from each tag.VersionFilters.LatestPatchPerMinor— from all available versions, selects only the latest patch for each minor version. This means we get18.0.x,18.1.x, ...,18.10.x— one schema per minor release, not dozens of patch versions.UseHttpDownload— constructs the download URL for each version and fetches the JSON schema.UseSave— writes the downloaded schema to disk using theOutputFilePattern.DefaultMinVersion = "18.0.0"— ignores GitLab releases before 18.0.
The output lands directly in the main library's schemas/ directory:
src/FrenchExDev.Net.GitLab.Ci.Yaml/schemas/
gitlab-ci-v18.0.0.json
gitlab-ci-v18.1.0.json
gitlab-ci-v18.2.0.json
gitlab-ci-v18.3.0.json
gitlab-ci-v18.4.0.json
gitlab-ci-v18.5.0.json
gitlab-ci-v18.6.0.json
gitlab-ci-v18.7.0.json
gitlab-ci-v18.8.0.json
gitlab-ci-v18.9.0.json
gitlab-ci-v18.10.0.jsonsrc/FrenchExDev.Net.GitLab.Ci.Yaml/schemas/
gitlab-ci-v18.0.0.json
gitlab-ci-v18.1.0.json
gitlab-ci-v18.2.0.json
gitlab-ci-v18.3.0.json
gitlab-ci-v18.4.0.json
gitlab-ci-v18.5.0.json
gitlab-ci-v18.6.0.json
gitlab-ci-v18.7.0.json
gitlab-ci-v18.8.0.json
gitlab-ci-v18.9.0.json
gitlab-ci-v18.10.0.jsonThese JSON files are committed to source control. The Design project runs on-demand — only when you want to pick up new GitLab versions. Normal builds never hit the network.
Running the Design CLI
# Download all schemas (min version 18.0)
dotnet run --project src/FrenchExDev.Net.GitLab.Ci.Yaml.Design
# List available versions without downloading
dotnet run --project src/FrenchExDev.Net.GitLab.Ci.Yaml.Design -- --list# Download all schemas (min version 18.0)
dotnet run --project src/FrenchExDev.Net.GitLab.Ci.Yaml.Design
# List available versions without downloading
dotnet run --project src/FrenchExDev.Net.GitLab.Ci.Yaml.Design -- --listAfter running, rebuild the main library. The source generator automatically picks up the new JSON files and regenerates all models and builders.
JSON Schema Deep Dive
To understand what the source generator does, you need to understand what it's working with. GitLab's CI schema is a JSON Schema draft-07 document with some unusual characteristics.
Schema Format
The schema declares itself as http://json-schema.org/draft-07/schema# and uses definitions (not the newer $defs from draft 2020-12, though the parser supports both). It has about 54 definitions in the definitions block.
Root Structure
The root properties are the reserved keywords:
{
"properties": {
"spec": { ... },
"image": { "$ref": "#/definitions/image" },
"services": { "$ref": "#/definitions/services" },
"before_script": { "$ref": "#/definitions/before_script" },
"after_script": { "$ref": "#/definitions/after_script" },
"variables": { "$ref": "#/definitions/globalVariables" },
"cache": { "$ref": "#/definitions/cache" },
"default": { ... },
"stages": { ... },
"include": { ... },
"pages": { ... },
"workflow": { ... }
},
"additionalProperties": {
"$ref": "#/definitions/job"
}
}{
"properties": {
"spec": { ... },
"image": { "$ref": "#/definitions/image" },
"services": { "$ref": "#/definitions/services" },
"before_script": { "$ref": "#/definitions/before_script" },
"after_script": { "$ref": "#/definitions/after_script" },
"variables": { "$ref": "#/definitions/globalVariables" },
"cache": { "$ref": "#/definitions/cache" },
"default": { ... },
"stages": { ... },
"include": { ... },
"pages": { ... },
"workflow": { ... }
},
"additionalProperties": {
"$ref": "#/definitions/job"
}
}The additionalProperties key is crucial: it says "any key not listed in properties is a job definition." This is how GitLab knows that build:, test:, and deploy: are jobs, not configuration keywords. In the C# model, this becomes a Dictionary<string, GitLabCiJob> Jobs property.
Definition Categories
The 54 definitions fall into three categories:
1. Full Object Definitions (14) — These have properties and generate C# classes:
| Definition | Generated Class | Purpose |
|---|---|---|
job |
GitLabCiJob |
Concrete job (allOf → job_template) |
job_template |
GitLabCiJobTemplate |
Base job definition with 30+ properties |
artifacts |
GitLabCiArtifacts |
Build output configuration |
artifacts_reports |
GitLabCiArtifactsReports |
JUnit, coverage, SAST reports |
default |
GitLabCiDefault |
Default job configuration |
workflow |
GitLabCiWorkflow |
Pipeline-level rules |
cache_item |
GitLabCiCacheItem |
Cache layer configuration |
needs_config |
GitLabCiJobTemplateNeedsConfig |
Job dependency (DAG) |
environment_config |
GitLabCiJobTemplateEnvironmentConfig |
Deployment target |
release |
GitLabCiJobTemplateRelease |
Release creation |
trigger_config |
GitLabCiJobTemplateTriggerConfig |
Downstream pipeline |
inherit |
GitLabCiJobTemplateInherit |
Config inheritance control |
step_exec |
GitLabCiStepExec |
Step-based execution |
base_input |
GitLabCiBaseInput |
Pipeline input specification |
2. Type Aliases (~35) — These are $ref targets that map to primitives:
| Definition | Mapped C# Type | Schema Pattern |
|---|---|---|
script |
List<string>? |
oneOf[string, array] |
before_script |
List<string>? |
oneOf[string, array] |
after_script |
List<string>? |
oneOf[string, array] |
image |
string? |
String type |
when |
string? |
String enum |
tags |
List<object>? |
Array type |
services |
List<object>? |
Array type |
rules |
List<object>? |
Array type |
allow_failure |
bool? |
Boolean/object |
interruptible |
bool? |
Boolean type |
retry_max |
int? |
Integer type |
globalVariables |
Dictionary<string, object?>? |
Object type |
jobVariables |
Dictionary<string, object?>? |
Object type |
id_tokens |
Dictionary<string, object?>? |
Object type |
secrets |
Dictionary<string, object?>? |
Object type |
3. Special Definitions (~5) — Skipped during generation:
| Definition | Reason for Skipping |
|---|---|
$schema |
Meta-schema reference, not a CI property |
!reference |
GitLab-specific YAML extension, not modelable in C# |
string_or_list |
Type alias, already handled by MapRefToType |
string_file_list |
Type alias |
include_item |
Mapped to StringOrObject type |
The allOf Pattern
GitLab's schema uses allOf for job inheritance. The job definition is defined as:
{
"job": {
"allOf": [
{ "$ref": "#/definitions/job_template" }
]
}
}{
"job": {
"allOf": [
{ "$ref": "#/definitions/job_template" }
]
}
}This means job inherits all properties from job_template. The SchemaReader's second pass resolves these references by copying properties from the referenced definition:
private static void ResolveAllOf(string defName, JsonElement allOf,
Dictionary<string, DefinitionModel> definitions)
{
if (!definitions.TryGetValue(defName, out var target))
return;
// If the definition has no properties of its own, merge from allOf refs
if (target.Properties.Count > 0)
return;
foreach (var item in allOf.EnumerateArray())
{
if (item.TryGetProperty("$ref", out var refProp))
{
var refName = ExtractRef(refProp.GetString()!);
if (definitions.TryGetValue(refName, out var source))
{
foreach (var prop in source.Properties)
{
if (!target.Properties.Exists(p => p.JsonName == prop.JsonName))
target.Properties.Add(prop);
}
target.Description ??= source.Description;
}
}
}
}private static void ResolveAllOf(string defName, JsonElement allOf,
Dictionary<string, DefinitionModel> definitions)
{
if (!definitions.TryGetValue(defName, out var target))
return;
// If the definition has no properties of its own, merge from allOf refs
if (target.Properties.Count > 0)
return;
foreach (var item in allOf.EnumerateArray())
{
if (item.TryGetProperty("$ref", out var refProp))
{
var refName = ExtractRef(refProp.GetString()!);
if (definitions.TryGetValue(refName, out var source))
{
foreach (var prop in source.Properties)
{
if (!target.Properties.Exists(p => p.JsonName == prop.JsonName))
target.Properties.Add(prop);
}
target.Description ??= source.Description;
}
}
}
}After resolution, GitLabCiJob has the same 30+ properties as GitLabCiJobTemplate.
Union Types in the Schema
The schema uses oneOf and anyOf extensively for union types. The source generator must decide how to represent each union in C#:
The resolution priority in ParseOneOf():
string + objectwith properties → generates an inline config class (e.g.,GitLabCiJobTemplateEnvironmentConfig) and maps toStringOrObjectstring + integer→ maps toint?(loses the string variant)string + boolean→ maps tobool?(loses the string variant)string + array→ maps toList<string>?viaStringOrListnull + $ref→ maps to the nullable ref type- Refs only → maps to the first ref type
null + another type→ resolves the non-null type- Fallback →
string?
This is a pragmatic trade-off: some type fidelity is lost (e.g., string | boolean becomes just bool?), but the API stays clean and usable.
Schema Adaptations Summary
| Schema Pattern | Handling | Example |
|---|---|---|
allOf with $ref |
Resolve by copying properties from referenced definition | job → copies from job_template |
anyOf |
Treated same as oneOf for code generation |
Various union types |
markdownDescription |
Used as fallback when description absent |
Most schema elements |
$schema, !reference |
Skipped (not useful as C# properties) | Meta and extension keys |
| Special characters in names | $, !, @ stripped by ToPascalCase |
$schema → skipped |
| Type-alias definitions | Mapped to primitives via MapRefToType |
script → List<string>? |
| Duplicate inline classes | Deduplicated during emission | Same class from allOf-resolved definitions |
deprecated: true |
Emits [Obsolete] attribute |
image at root level |