Overview
The SDLC DSL declares everything that lives between a developer's keystroke and a versioned artifact: build pipelines, ordered stages, quality gates, branch policies, release channels and reproducible local environments. None of it is YAML. None of it lives in a SaaS CI configuration UI. It is C# code in a *.Sdlc project, attributed with [Pipeline], [Stage], [QualityGate] and friends.
The source generator produces:
- A typed
SdlcRegistryof const strings (Sdlc.PIPELINE_*,Sdlc.STAGE_*,Sdlc.BUILDTARGET_*) - Local wrapper scripts (
build.ps1/build.sh) that any developer or any other DSL (notably PLM) can invoke deterministically - An
IPipelineRunnerwhose method signature mirrors the declared stage graph - A Mermaid diagram of the pipeline (
pipeline-graph.mmd) for documentation - Roslyn analyzers
SDLC001-SDLC002that catch broken stage graphs and unreachable gates at compile time
The whole DSL is local-first. The repository's build is reproducible on the developer's laptop and on whatever publishes the static assets. There is no cloud CI to configure, no second source of truth to drift.
Pipeline
namespace Cmf.Sdlc.Lib;
/// <summary>
/// Declares a named build/test/release pipeline. A pipeline is an
/// ordered, partially-parallel graph of [Stage] attributes targeting
/// a [ReleaseChannel]. The compiler validates that the graph is acyclic
/// and that every stage's DependsOn references a declared stage.
/// </summary>
[MetaConcept("Pipeline")]
[MetaConstraint("MustHaveAtLeastOneStage",
"Stages.Count >= 1",
Message = "Pipeline must declare at least one stage")]
[MetaConstraint("StageGraphMustBeAcyclic",
"Stages.Topological() != null",
Message = "Pipeline stages form a cycle")]
public sealed class PipelineAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Channel", "ReleaseChannel")]
public ReleaseChannel Channel { get; set; } = ReleaseChannel.Dev;
[MetaProperty("Description", "string")]
public string? Description { get; set; }
public PipelineAttribute(string name) => Name = name;
}namespace Cmf.Sdlc.Lib;
/// <summary>
/// Declares a named build/test/release pipeline. A pipeline is an
/// ordered, partially-parallel graph of [Stage] attributes targeting
/// a [ReleaseChannel]. The compiler validates that the graph is acyclic
/// and that every stage's DependsOn references a declared stage.
/// </summary>
[MetaConcept("Pipeline")]
[MetaConstraint("MustHaveAtLeastOneStage",
"Stages.Count >= 1",
Message = "Pipeline must declare at least one stage")]
[MetaConstraint("StageGraphMustBeAcyclic",
"Stages.Topological() != null",
Message = "Pipeline stages form a cycle")]
public sealed class PipelineAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Channel", "ReleaseChannel")]
public ReleaseChannel Channel { get; set; } = ReleaseChannel.Dev;
[MetaProperty("Description", "string")]
public string? Description { get; set; }
public PipelineAttribute(string name) => Name = name;
}Stage
/// <summary>
/// A node in a pipeline. Stages are ordered by Order and may declare
/// DependsOn for explicit data-flow dependencies. The generated runner
/// schedules independent stages in parallel where possible.
/// </summary>
[MetaConcept("Stage")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class StageAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Order", "int", Required = true)]
public int Order { get; set; }
[MetaProperty("DependsOn", "string")]
public string? DependsOn { get; set; }
[MetaProperty("AllowFailure", "bool")]
public bool AllowFailure { get; set; } = false;
public StageAttribute(string name) => Name = name;
}/// <summary>
/// A node in a pipeline. Stages are ordered by Order and may declare
/// DependsOn for explicit data-flow dependencies. The generated runner
/// schedules independent stages in parallel where possible.
/// </summary>
[MetaConcept("Stage")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class StageAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Order", "int", Required = true)]
public int Order { get; set; }
[MetaProperty("DependsOn", "string")]
public string? DependsOn { get; set; }
[MetaProperty("AllowFailure", "bool")]
public bool AllowFailure { get; set; } = false;
public StageAttribute(string name) => Name = name;
}BuildTarget
/// <summary>
/// A buildable artifact. Each BuildTarget points to a project file,
/// a container image definition or a static bundle. The ALM DSL
/// references build targets via [Service(Build = Sdlc.BUILDTARGET_*)]
/// to wire deployments to the artifact they ship.
/// </summary>
[MetaConcept("BuildTarget")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class BuildTargetAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Kind", "BuildKind", Required = true)]
public BuildKind Kind { get; set; }
[MetaProperty("ProjectPath", "string")]
public string? ProjectPath { get; set; }
[MetaProperty("OutputPath", "string")]
public string? OutputPath { get; set; }
public BuildTargetAttribute(string name) => Name = name;
}
public enum BuildKind { DotnetProject, ContainerImage, StaticBundle, NuGetPackage }/// <summary>
/// A buildable artifact. Each BuildTarget points to a project file,
/// a container image definition or a static bundle. The ALM DSL
/// references build targets via [Service(Build = Sdlc.BUILDTARGET_*)]
/// to wire deployments to the artifact they ship.
/// </summary>
[MetaConcept("BuildTarget")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class BuildTargetAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Kind", "BuildKind", Required = true)]
public BuildKind Kind { get; set; }
[MetaProperty("ProjectPath", "string")]
public string? ProjectPath { get; set; }
[MetaProperty("OutputPath", "string")]
public string? OutputPath { get; set; }
public BuildTargetAttribute(string name) => Name = name;
}
public enum BuildKind { DotnetProject, ContainerImage, StaticBundle, NuGetPackage }QualityGate
/// <summary>
/// A bool-returning constraint evaluated against pipeline metrics
/// after a stage runs. Failing gates abort the pipeline. Severity
/// controls whether the gate is a hard error or a warning that
/// surfaces in the report but does not break the build.
/// </summary>
[MetaConcept("QualityGate")]
[MetaInherits("MetaConstraint")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class QualityGateAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Expression", "lambda<PipelineMetrics, bool>", Required = true)]
public Expression<Func<PipelineMetrics, bool>> Expression { get; }
[MetaProperty("Severity", "GateSeverity")]
public GateSeverity Severity { get; set; } = GateSeverity.Error;
[MetaProperty("AppliesTo", "string")]
public string? AppliesTo { get; set; }
public QualityGateAttribute(string name, Expression<Func<PipelineMetrics, bool>> expression)
{
Name = name;
Expression = expression;
}
}
public enum GateSeverity { Error, Warning, Info }/// <summary>
/// A bool-returning constraint evaluated against pipeline metrics
/// after a stage runs. Failing gates abort the pipeline. Severity
/// controls whether the gate is a hard error or a warning that
/// surfaces in the report but does not break the build.
/// </summary>
[MetaConcept("QualityGate")]
[MetaInherits("MetaConstraint")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class QualityGateAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("Expression", "lambda<PipelineMetrics, bool>", Required = true)]
public Expression<Func<PipelineMetrics, bool>> Expression { get; }
[MetaProperty("Severity", "GateSeverity")]
public GateSeverity Severity { get; set; } = GateSeverity.Error;
[MetaProperty("AppliesTo", "string")]
public string? AppliesTo { get; set; }
public QualityGateAttribute(string name, Expression<Func<PipelineMetrics, bool>> expression)
{
Name = name;
Expression = expression;
}
}
public enum GateSeverity { Error, Warning, Info }Branch & ReleaseChannel
/// <summary>
/// Declares a branch policy. Branches map to release channels and
/// may forbid or require specific pipelines. The compiler refuses
/// to ship a [Release] (PLM DSL) on a branch that does not allow
/// the channel it targets.
/// </summary>
[MetaConcept("Branch")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class BranchAttribute : Attribute
{
[MetaProperty("Pattern", "string", Required = true)]
public string Pattern { get; }
[MetaProperty("Channel", "ReleaseChannel", Required = true)]
public ReleaseChannel Channel { get; set; }
[MetaProperty("Protected", "bool")]
public bool Protected { get; set; } = false;
public BranchAttribute(string pattern) => Pattern = pattern;
}
public enum ReleaseChannel { Dev, Preview, Stable, Hotfix }/// <summary>
/// Declares a branch policy. Branches map to release channels and
/// may forbid or require specific pipelines. The compiler refuses
/// to ship a [Release] (PLM DSL) on a branch that does not allow
/// the channel it targets.
/// </summary>
[MetaConcept("Branch")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class BranchAttribute : Attribute
{
[MetaProperty("Pattern", "string", Required = true)]
public string Pattern { get; }
[MetaProperty("Channel", "ReleaseChannel", Required = true)]
public ReleaseChannel Channel { get; set; }
[MetaProperty("Protected", "bool")]
public bool Protected { get; set; } = false;
public BranchAttribute(string pattern) => Pattern = pattern;
}
public enum ReleaseChannel { Dev, Preview, Stable, Hotfix }DevEnvironment
/// <summary>
/// Declares a reproducible local development environment: required
/// SDK versions, services to spin up via docker compose or Aspire,
/// and environment variables. Generated as a doctor script that
/// every contributor runs once.
/// </summary>
[MetaConcept("DevEnvironment")]
public sealed class DevEnvironmentAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("DotnetSdk", "string")]
public string? DotnetSdk { get; set; }
[MetaProperty("NodeVersion", "string")]
public string? NodeVersion { get; set; }
public DevEnvironmentAttribute(string name) => Name = name;
}/// <summary>
/// Declares a reproducible local development environment: required
/// SDK versions, services to spin up via docker compose or Aspire,
/// and environment variables. Generated as a doctor script that
/// every contributor runs once.
/// </summary>
[MetaConcept("DevEnvironment")]
public sealed class DevEnvironmentAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; }
[MetaProperty("DotnetSdk", "string")]
public string? DotnetSdk { get; set; }
[MetaProperty("NodeVersion", "string")]
public string? NodeVersion { get; set; }
public DevEnvironmentAttribute(string name) => Name = name;
}What the developer writes
namespace AcmeStore.Sdlc;
[BuildTarget("Api", Kind = BuildKind.DotnetProject,
ProjectPath = "src/AcmeStore.Api/AcmeStore.Api.csproj")]
[BuildTarget("Worker", Kind = BuildKind.DotnetProject,
ProjectPath = "src/AcmeStore.Worker/AcmeStore.Worker.csproj")]
[BuildTarget("Web", Kind = BuildKind.StaticBundle,
ProjectPath = "src/AcmeStore.Web/AcmeStore.Web.csproj",
OutputPath = "public/")]
public partial class AcmeBuildTargets { }
[Pipeline("MainBuild",
Channel = ReleaseChannel.Stable,
Description = "Local-first build that produces deployable artifacts")]
[Stage("Restore", Order = 0)]
[Stage("Build", Order = 1, DependsOn = "Restore")]
[Stage("Test", Order = 2, DependsOn = "Build")]
[Stage("Pack", Order = 3, DependsOn = "Test")]
[Stage("Publish", Order = 4, DependsOn = "Pack")]
[QualityGate("Coverage",
m => m.LineCoverage >= 0.85,
AppliesTo = "Test",
Severity = GateSeverity.Error)]
[QualityGate("NoSecrets",
m => !m.HasSecrets,
AppliesTo = "Build",
Severity = GateSeverity.Error)]
[QualityGate("BundleSize",
m => m.BundleBytes < 2_000_000,
AppliesTo = "Publish",
Severity = GateSeverity.Warning)]
public partial class MainBuildPipeline { }
[Branch("main", Channel = ReleaseChannel.Stable, Protected = true)]
[Branch("release/*", Channel = ReleaseChannel.Preview, Protected = true)]
[Branch("hotfix/*", Channel = ReleaseChannel.Hotfix, Protected = true)]
[Branch("feature/*", Channel = ReleaseChannel.Dev)]
public partial class AcmeBranchPolicy { }
[DevEnvironment("AcmeStore",
DotnetSdk = "10.0.100",
NodeVersion = "20.11.0")]
public partial class AcmeDevEnvironment { }namespace AcmeStore.Sdlc;
[BuildTarget("Api", Kind = BuildKind.DotnetProject,
ProjectPath = "src/AcmeStore.Api/AcmeStore.Api.csproj")]
[BuildTarget("Worker", Kind = BuildKind.DotnetProject,
ProjectPath = "src/AcmeStore.Worker/AcmeStore.Worker.csproj")]
[BuildTarget("Web", Kind = BuildKind.StaticBundle,
ProjectPath = "src/AcmeStore.Web/AcmeStore.Web.csproj",
OutputPath = "public/")]
public partial class AcmeBuildTargets { }
[Pipeline("MainBuild",
Channel = ReleaseChannel.Stable,
Description = "Local-first build that produces deployable artifacts")]
[Stage("Restore", Order = 0)]
[Stage("Build", Order = 1, DependsOn = "Restore")]
[Stage("Test", Order = 2, DependsOn = "Build")]
[Stage("Pack", Order = 3, DependsOn = "Test")]
[Stage("Publish", Order = 4, DependsOn = "Pack")]
[QualityGate("Coverage",
m => m.LineCoverage >= 0.85,
AppliesTo = "Test",
Severity = GateSeverity.Error)]
[QualityGate("NoSecrets",
m => !m.HasSecrets,
AppliesTo = "Build",
Severity = GateSeverity.Error)]
[QualityGate("BundleSize",
m => m.BundleBytes < 2_000_000,
AppliesTo = "Publish",
Severity = GateSeverity.Warning)]
public partial class MainBuildPipeline { }
[Branch("main", Channel = ReleaseChannel.Stable, Protected = true)]
[Branch("release/*", Channel = ReleaseChannel.Preview, Protected = true)]
[Branch("hotfix/*", Channel = ReleaseChannel.Hotfix, Protected = true)]
[Branch("feature/*", Channel = ReleaseChannel.Dev)]
public partial class AcmeBranchPolicy { }
[DevEnvironment("AcmeStore",
DotnetSdk = "10.0.100",
NodeVersion = "20.11.0")]
public partial class AcmeDevEnvironment { }Pipeline graph (generated)
1. SdlcRegistry
// Generated: SdlcRegistry.g.cs
namespace AcmeStore.Sdlc;
public static class Sdlc
{
// Pipelines
public const string PIPELINE_MAIN = "AcmeStore.Sdlc.MainBuildPipeline";
// Stages
public const string STAGE_RESTORE = "MainBuild::Restore";
public const string STAGE_BUILD = "MainBuild::Build";
public const string STAGE_TEST = "MainBuild::Test";
public const string STAGE_PACK = "MainBuild::Pack";
public const string STAGE_PUBLISH = "MainBuild::Publish";
// Build targets (referenced by ALM DSL)
public const string BUILDTARGET_API = "AcmeStore.Sdlc.AcmeBuildTargets::Api";
public const string BUILDTARGET_WORKER = "AcmeStore.Sdlc.AcmeBuildTargets::Worker";
public const string BUILDTARGET_WEB = "AcmeStore.Sdlc.AcmeBuildTargets::Web";
// Channels
public const string CHANNEL_DEV = nameof(ReleaseChannel.Dev);
public const string CHANNEL_PREVIEW = nameof(ReleaseChannel.Preview);
public const string CHANNEL_STABLE = nameof(ReleaseChannel.Stable);
public const string CHANNEL_HOTFIX = nameof(ReleaseChannel.Hotfix);
}// Generated: SdlcRegistry.g.cs
namespace AcmeStore.Sdlc;
public static class Sdlc
{
// Pipelines
public const string PIPELINE_MAIN = "AcmeStore.Sdlc.MainBuildPipeline";
// Stages
public const string STAGE_RESTORE = "MainBuild::Restore";
public const string STAGE_BUILD = "MainBuild::Build";
public const string STAGE_TEST = "MainBuild::Test";
public const string STAGE_PACK = "MainBuild::Pack";
public const string STAGE_PUBLISH = "MainBuild::Publish";
// Build targets (referenced by ALM DSL)
public const string BUILDTARGET_API = "AcmeStore.Sdlc.AcmeBuildTargets::Api";
public const string BUILDTARGET_WORKER = "AcmeStore.Sdlc.AcmeBuildTargets::Worker";
public const string BUILDTARGET_WEB = "AcmeStore.Sdlc.AcmeBuildTargets::Web";
// Channels
public const string CHANNEL_DEV = nameof(ReleaseChannel.Dev);
public const string CHANNEL_PREVIEW = nameof(ReleaseChannel.Preview);
public const string CHANNEL_STABLE = nameof(ReleaseChannel.Stable);
public const string CHANNEL_HOTFIX = nameof(ReleaseChannel.Hotfix);
}2. Typed pipeline runner
// Generated: MainBuildPipelineRunner.g.cs
public sealed class MainBuildPipelineRunner : IPipelineRunner
{
public string PipelineName => "MainBuild";
public async Task<PipelineRunResult> RunAsync(
PipelineContext ctx, CancellationToken ct = default)
{
var metrics = new PipelineMetrics();
await RunStageAsync("Restore", ctx, metrics, ct);
await RunStageAsync("Build", ctx, metrics, ct);
// Build-stage gate: NoSecrets
if (metrics.HasSecrets)
return PipelineRunResult.GateFailed("NoSecrets", "secrets detected in build output");
await RunStageAsync("Test", ctx, metrics, ct);
// Test-stage gate: Coverage >= 85%
if (metrics.LineCoverage < 0.85)
return PipelineRunResult.GateFailed(
"Coverage", $"line coverage {metrics.LineCoverage:P0} < 85%");
await RunStageAsync("Pack", ctx, metrics, ct);
await RunStageAsync("Publish", ctx, metrics, ct);
// Publish-stage gate: BundleSize warning only
if (metrics.BundleBytes >= 2_000_000)
ctx.Report.Warn("BundleSize", $"{metrics.BundleBytes:N0} bytes exceeds 2MB");
return PipelineRunResult.Ok(metrics);
}
}// Generated: MainBuildPipelineRunner.g.cs
public sealed class MainBuildPipelineRunner : IPipelineRunner
{
public string PipelineName => "MainBuild";
public async Task<PipelineRunResult> RunAsync(
PipelineContext ctx, CancellationToken ct = default)
{
var metrics = new PipelineMetrics();
await RunStageAsync("Restore", ctx, metrics, ct);
await RunStageAsync("Build", ctx, metrics, ct);
// Build-stage gate: NoSecrets
if (metrics.HasSecrets)
return PipelineRunResult.GateFailed("NoSecrets", "secrets detected in build output");
await RunStageAsync("Test", ctx, metrics, ct);
// Test-stage gate: Coverage >= 85%
if (metrics.LineCoverage < 0.85)
return PipelineRunResult.GateFailed(
"Coverage", $"line coverage {metrics.LineCoverage:P0} < 85%");
await RunStageAsync("Pack", ctx, metrics, ct);
await RunStageAsync("Publish", ctx, metrics, ct);
// Publish-stage gate: BundleSize warning only
if (metrics.BundleBytes >= 2_000_000)
ctx.Report.Warn("BundleSize", $"{metrics.BundleBytes:N0} bytes exceeds 2MB");
return PipelineRunResult.Ok(metrics);
}
}3. Local wrapper script
#!/usr/bin/env bash
# Generated: build.sh -- DO NOT EDIT
# Source: AcmeStore.Sdlc/MainBuildPipeline.cs
set -euo pipefail
dotnet run --project tools/AcmeStore.Sdlc.Runner -- \
--pipeline MainBuild \
"$@"#!/usr/bin/env bash
# Generated: build.sh -- DO NOT EDIT
# Source: AcmeStore.Sdlc/MainBuildPipeline.cs
set -euo pipefail
dotnet run --project tools/AcmeStore.Sdlc.Runner -- \
--pipeline MainBuild \
"$@"# Generated: build.ps1 -- DO NOT EDIT
# Source: AcmeStore.Sdlc/MainBuildPipeline.cs
$ErrorActionPreference = 'Stop'
dotnet run --project tools/AcmeStore.Sdlc.Runner -- `
--pipeline MainBuild `
@args# Generated: build.ps1 -- DO NOT EDIT
# Source: AcmeStore.Sdlc/MainBuildPipeline.cs
$ErrorActionPreference = 'Stop'
dotnet run --project tools/AcmeStore.Sdlc.Runner -- `
--pipeline MainBuild `
@argsThe wrappers are intentionally trivial. They exist so that other tools (PLM release CLI, contributor doctor, the developer's own muscle memory) can call ./build.sh test without knowing anything about the underlying graph.
4. Generation pipeline
Cross-DSL references
The SDLC DSL is at the bottom of the lifecycle stack. It depends on nothing from ALM or PLM, but everything depends on it:
- ALM
[Service(Build = Sdlc.BUILDTARGET_API)]-- a service ships an SDLC build target - PLM
[Release(Pipeline = Sdlc.PIPELINE_MAIN)]-- a release is cut by an SDLC pipeline run - PLM
[Release(Channel = ReleaseChannel.Stable)]-- a release targets an SDLC channel
Because the registry is generated as const string, every cross-DSL reference is checked by the C# compiler. Renaming MainBuildPipeline to MainBuild_v2 is a refactor, not a string-replace expedition.
Analyzers & quality gates
| ID | Severity | Message |
|---|---|---|
SDLC001 |
Error | Stage {X} references unknown DependsOn {Y} |
SDLC002 |
Error | QualityGate {N} AppliesTo unknown stage {X} |
SDLC003 |
Error | Pipeline {P} stage graph contains a cycle: {path} |
SDLC004 |
Warning | BuildTarget {B} is not referenced by any ALM Service |
SDLC005 |
Error | Branch pattern {P} collides with {Q} |
The first three run in the generator's Stage 1 (model validation). The last two are post-link analyzers that need access to the ALM and PLM compilations.
Why this is not just YAML in C# clothing
Three things would be lost if the same definitions lived in azure-pipelines.yml or .github/workflows/main.yml:
- Type safety across DSLs.
Sdlc.BUILDTARGET_APIis aconst stringthe C# compiler tracks. A YAML key is a string the compiler ignores. - Local reproducibility. The generated
build.shruns the exact same code on a contributor's laptop and on the deployment box. There is no SaaS runner whose behaviour drifts from local. - Refactoring. Rename a stage in C# and every reference (gate, runner, registry, ALM service, PLM release) updates atomically. Rename a stage in YAML and you grep.
The SDLC DSL is the foundation of the lifecycle stack. The next chapter (Part XVI -- ALM DSL) builds on top of it: services, feature flags, SLOs and Aspire wiring -- all referencing the SDLC build targets defined here.