Thank you for your interest in contributing! This document outlines the coding conventions, style rules, and best practices you should follow when working on this project.
- Prerequisites
- Project Structure
- Branching Model
- Naming Conventions
- Code Style
- Component Design
- Testing
- Package Management
- Versioning & Changelog
- CI/CD Workflows
- Build Commands
- .NET SDK 10.0 (also targets .NET 8.0)
- C# latest language version
- An editor that respects
.editorconfig(VS Code, Visual Studio, Rider…)
src/
Candoumbe.Pipelines/ # Core library (NuGet package)
Components/ # All component interfaces
Docker/ # Docker-related components
Formatting/ # Code formatting components
GitHub/ # GitHub integration
NuGet/ # NuGet publishing
Workflows/ # Git branching strategies
Candoumbe.Pipelines.Components.AzureDevOps/ # Azure DevOps extension (separate package)
build/
Pipeline.cs # Self-hosting build pipeline
test/
Candoumbe.Pipelines.Tests/ # Unit tests
Rules:
- New components go in the appropriate subdirectory under
Components/. - New provider-specific integrations go in a separate project (like
Candoumbe.Pipelines.Components.AzureDevOps). - 1 file per type, 1 type per file.
- Namespace must match folder structure.
This project uses GitFlow. Branch prefixes:
| Branch type | Prefix | Source branch |
|---|---|---|
| Feature | feature/ |
develop |
| Hotfix | hotfix/ |
main |
| Coldfix | coldfix/ |
develop |
| Release | release/ |
develop |
| Chore | chore/ |
develop |
Never commit directly to
mainordevelop.
| Category | Prefix | Purpose | Examples |
|---|---|---|---|
| Infrastructure | IHave |
Expose paths, settings, resources | IHaveArtifacts, IHaveSolution |
| Action | I + Verb |
Define executable build targets | IClean, ICompile, IPack, IRestore |
| Workflow | IDo |
Define branching strategies | IDoFeatureWorkflow, IDoChoreWorkflow |
| Visibility | Style | Example |
|---|---|---|
| Private / Protected | _camelCase |
_outputDirectory |
| Private static readonly | s_camelCase |
s_defaultConfig |
| Public / Internal | PascalCase |
OutputDirectory |
| Constants | PascalCase |
DevelopBranchName |
- Classes, structs, enums, delegates:
PascalCase - Interfaces:
IPascalCase(prefix withI) - Parameters:
camelCase - Methods, properties, events:
PascalCase
This is enforced as an error in the .editorconfig.
// ✅ DO — use explicit types
AbsolutePath directory = RootDirectory / "src";
string name = "example";
int count = 42;
IEnumerable<AbsolutePath> paths = Enumerable.Empty<AbsolutePath>();
// ❌ DON'T — never use var
var directory = RootDirectory / "src";
var name = "example";
var count = 42;
var paths = Enumerable.Empty<AbsolutePath>();- 4 spaces for indentation (no tabs).
- 2 spaces for
.csproj,.json,.props,.xml,.yaml,.ymlfiles.
Always use braces, even for single-line blocks (enforced as warning).
// ✅ DO
if (condition)
{
DoSomething();
}
// ❌ DON'T
if (condition)
DoSomething();Allman style — opening braces on a new line.
// ✅ DO
public class MyClass
{
public void MyMethod()
{
// ...
}
}
// ❌ DON'T
public class MyClass {
public void MyMethod() {
// ...
}
}Use switch expressions over switch statements (enforced as error).
// ✅ DO
string result = status switch
{
Status.Active => "active",
Status.Inactive => "inactive",
_ => "unknown"
};
// ❌ DON'T
string result;
switch (status)
{
case Status.Active:
result = "active";
break;
case Status.Inactive:
result = "inactive";
break;
default:
result = "unknown";
break;
}- Place
usingdirectives outside the namespace (enforced as error). - Sort
Systemdirectives first.
// ✅ DO
using System;
using System.Collections.Generic;
using Nuke.Common;
namespace Candoumbe.Pipelines.Components;
// ❌ DON'T
namespace Candoumbe.Pipelines.Components
{
using System;
using Nuke.Common;
}Prefer file-scoped namespace declarations.
// ✅ DO
namespace Candoumbe.Pipelines.Components;
public interface IHaveArtifacts : IHaveOutputDirectory
{
// ...
}
// ❌ DON'T (unless existing code uses block-scoped)
namespace Candoumbe.Pipelines.Components
{
public interface IHaveArtifacts : IHaveOutputDirectory
{
// ...
}
}- Use null propagation (
?.) — enforced as warning. - Use null coalescing (
??) — enforced as warning. - Prefer
is null/is not nullover== null/!= null.
// ✅ DO
string value = input ?? "default";
int? length = text?.Length;
if (obj is not null) { ... }
// ❌ DON'T
string value = input != null ? input : "default";
int? length = text != null ? text.Length : null;
if (obj != null) { ... }Prefer object initializers and collection expressions (enforced as warning).
// ✅ DO
Person person = new() { Name = "Alice", Age = 30 };
IEnumerable<Project> projects = [];
// ❌ DON'T
Person person = new Person();
person.Name = "Alice";
person.Age = 30;
List<Project> projects = new List<Project>();Use predefined types (string, int, bool) instead of framework names (enforced as error for locals/parameters).
// ✅ DO
string name = "example";
int count = 0;
// ❌ DON'T
String name = "example";
Int32 count = 0;Infrastructure interfaces expose paths, settings, or external resources. They inherit from INukeBuild and provide default implementations.
// ✅ DO — simple, composable infrastructure interface
using Nuke.Common;
using Nuke.Common.IO;
namespace Candoumbe.Pipelines.Components;
/// <summary>
/// Marks a pipeline that can specify a folder for source files
/// </summary>
public interface IHaveSourceDirectory : INukeBuild
{
/// <summary>
/// Directory of source code projects
/// </summary>
AbsolutePath SourceDirectory => RootDirectory / "src";
}Action interfaces define executable build targets. They inherit from the infrastructure interfaces they need.
// ✅ DO — action interface with default target implementation
using System.Collections.Generic;
using System.Linq;
using Nuke.Common;
using Nuke.Common.IO;
namespace Candoumbe.Pipelines.Components;
/// <summary>
/// Marks a pipeline that supports cleaning workflow.
/// </summary>
public interface IClean : INukeBuild
{
IEnumerable<AbsolutePath> DirectoriesToDelete => Enumerable.Empty<AbsolutePath>();
IEnumerable<AbsolutePath> DirectoriesToClean => Enumerable.Empty<AbsolutePath>();
IEnumerable<AbsolutePath> DirectoriesToEnsureExistence => Enumerable.Empty<AbsolutePath>();
public Target Clean => _ => _
.TryBefore<IRestore>(x => x.Restore)
.OnlyWhenDynamic(() => DirectoriesToClean.AtLeastOnce()
|| DirectoriesToDelete.AtLeastOnce()
|| DirectoriesToEnsureExistence.AtLeastOnce())
.Executes(() =>
{
DirectoriesToDelete.ForEach(directory => directory.DeleteDirectory());
DirectoriesToClean.ForEach(directory => directory.CreateOrCleanDirectory());
DirectoriesToEnsureExistence.ForEach(directory => directory.CreateDirectory());
});
}// ✅ DO — compose interfaces to build capabilities
public interface ICompile : IHaveSolution, IHaveConfiguration { ... }
// ✅ DO — provide sealed base settings and overridable settings
public sealed Configure<DotNetBuildSettings> CompileSettingsBase => _ => _
.SetProjectFile(Solution)
.SetConfiguration(Configuration);
public Configure<DotNetBuildSettings> CompileSettings => _ => _;
// ✅ DO — use TryDependsOn / TryBefore for optional dependencies
public Target Compile => _ => _
.TryDependsOn<IRestore>()
.TryDependsOn<IFormat>()
.Executes(() => { ... });
// ❌ DON'T — tightly couple components
public interface ICompile : IRestore, IFormat, IClean { ... } // Too many hard dependencies- xUnit for test framework
- FluentAssertions for assertions
- NSubstitute for mocking
Use descriptive method names following the pattern:
Given_<precondition>_When_<action>_Then_<expected result>
// ✅ DO
[Fact]
public void Given_DefaultImplementation_When_AccessingDirectoriesToDelete_Then_ReturnsEmptyCollection()
{
// Arrange
IClean sut = new CleanBuild();
// Act
IEnumerable<AbsolutePath> actual = sut.DirectoriesToDelete;
// Assert
actual.Should().BeEmpty();
}
// ❌ DON'T — vague or non-descriptive names
[Fact]
public void Test1() { ... }
[Fact]
public void CleanWorks() { ... }Use the Arrange / Act / Assert pattern with comments.
// ✅ DO
[Fact]
public void Given_DefaultImplementation_When_AccessingSourceDirectory_Then_ReturnsExpectedPath()
{
// Arrange
IHaveSourceDirectory sut = new SourceDirectoryBuild();
// Act
AbsolutePath actual = sut.SourceDirectory;
// Assert
actual.Should().NotBeNull();
actual.ToString().Should().EndWith("src");
}Create minimal stubs in TestBuild.cs for testing interface default implementations.
// ✅ DO — minimal test stubs
internal class SourceDirectoryBuild : NukeBuild, IHaveSourceDirectory;
internal class CleanBuild : NukeBuild, IClean;
// Stubs that require non-default members implement only what's needed
internal class PackBuild : NukeBuild, IPack
{
public IEnumerable<AbsolutePath> PackableProjects => [];
}Test files are named after the component they test: I{Component}Tests.cs
| Component file | Test file |
|---|---|
IClean.cs |
ICleanTests.cs |
IHaveArtifacts.cs |
IHaveArtifactsTests.cs |
Configuration.cs |
ConfigurationTests.cs |
This project uses Central Package Management. All package versions are defined in Directory.Packages.props.
<!-- ✅ DO — add version in Directory.Packages.props -->
<PackageVersion Include="FluentAssertions" Version="8.3.0"/>
<!-- ✅ DO — reference without version in .csproj -->
<PackageReference Include="FluentAssertions"/>
<!-- ❌ DON'T — specify version in .csproj files -->
<PackageReference Include="FluentAssertions" Version="8.3.0"/>- Uses GitVersion for automatic semantic versioning.
- Never set versions manually in
.csprojfiles or elsewhere.
- Follows the Keep a Changelog format.
- Update
CHANGELOG.mdwith every user-facing change under the[Unreleased]section. - Use the standard section headers:
| Section | When to use |
|---|---|
### 💥 Breaking changes |
Backward-incompatible API changes |
### 🚀 New features |
New functionality |
### 🚨 Fixes |
Bug fixes |
### 🧹 Housekeeping |
Internal changes, dependency updates, tooling |
- GitHub workflow files (
.github/workflows/*.yml) are auto-generated frombuild/Pipeline.csviaICanRegenerateGitHubWorkflows. - Do NOT edit workflow YAML files manually. They will be overwritten.
- To change CI behavior, modify
Pipeline.csand regenerate.
# Build the solution
./build.sh compile
# Create NuGet packages
./build.sh pack
# Run all default targets
./build.sh
# Run all unit tests
./build.sh unit-tests| ✅ Do | ❌ Don't |
|---|---|
| Use explicit types everywhere | Use var |
| Use 4-space indentation | Use tabs |
| Place braces on their own line (Allman style) | Use K&R brace style |
| Always use braces for control flow | Omit braces for single-line blocks |
Use is null / is not null |
Use == null / != null |
| Use switch expressions | Use switch statements |
| Use file-scoped namespaces | Use block-scoped namespaces (unless existing) |
Use predefined types (string, int) |
Use framework types (String, Int32) |
Follow IHave* / I{Verb} / IDo* naming |
Invent new interface prefix patterns |
Put using directives outside namespace |
Put using inside namespace |
Sort System usings first |
Use unsorted usings |
Add package versions in Directory.Packages.props |
Add versions in .csproj files |
Update CHANGELOG.md for every change |
Skip changelog updates |
Name tests Given_When_Then |
Use vague test names |
| Use Arrange/Act/Assert pattern | Mix test phases |
Create branches from develop |
Commit directly to main or develop |
| Let GitVersion manage versions | Set versions manually |
Modify Pipeline.cs for CI changes |
Edit .github/workflows/*.yml manually |
| 1 file per type | Multiple types in one file |
| Match namespace to folder structure | Use arbitrary namespace names |