Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ generated_code = true

[**/*Tests.cs]
dotnet_diagnostic.CA1707.severity = none
# Test methods must not call ConfigureAwait (xUnit1030), so CA2007's "call
# ConfigureAwait" would conflict — test projects conventionally disable CA2007.
dotnet_diagnostic.CA2007.severity = none
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
## Repository structure

- `Example.slnx` — XML-based solution referencing the `src/` and `tests/` projects.
- `src/Example/` — the library project (`Example.csproj`) with `ExampleClass.cs`.
- `src/Example/` — the library project (`Example.csproj`) with `ExampleClass.cs` and `FeatureFlags.cs` (the OpenFeature feature-flag scaffold; see *Feature flags*).
- `tests/Example.Tests/` — xUnit test project (`Example.Tests.csproj`, `ExampleClassTests.cs`) using `Microsoft.NET.Test.Sdk`, `coverlet.collector`, and `xunit.runner.visualstudio`.
- `.editorconfig` — formatting and analyzer rules enforced at build.
- `.github/workflows/` — `ci.yaml` (required-checks aggregation on PRs/merge queue), `validate-scaffold.yaml` (template-repo-only gate that exercises the scaffold-rename script — no-ops downstream), `publish.yaml` (publishes the NuGet library on `v*` tags via the reusable `publish-dotnet-library` workflow), `release.yaml`, `sync-labels.yaml`, `todos.yaml`, and `copilot-setup-steps.yml`.
Expand All @@ -24,6 +24,15 @@ dotnet test

Workflow YAML changes should pass `actionlint`.

## Feature flags

New features land **behind a flag, default-off**, are tested in **both** states, and are switched on only after validation — so deploy is decoupled from release and a rollback is a flag flip, not a redeploy. This is the portfolio-wide *feature-flag-first* standard ([devantler-tech/monorepo#2059](https://github.com/devantler-tech/monorepo/issues/2059)).

- **Call sites use [OpenFeature](https://openfeature.dev/)** — the vendor-neutral flag API — via the `FeatureFlags` helper in `src/Example/FeatureFlags.cs`. Code paths evaluate flags through an `IFeatureClient`, never a provider directly, so the backing provider can change without touching them.
- **The scaffold ships OpenFeature's built-in in-memory provider** (`FeatureFlags.CreateInMemoryProvider`) so the example evaluates with no external backend. Swap it for a real provider when you adopt the template — **[flagd](https://flagd.dev/)** for a GitOps/self-hosted definition source, a hosted service, or the `Microsoft.FeatureManagement` OpenFeature bridge once it ships **GA** (it is preview-only today, so the scaffold does not depend on it — the template avoids preview packages).
- **Both states are tested.** `ExampleClassTests` exercises the example flag on, off, and unset (defaults off); mirror this for every flag. Give every flag-exercising test class the same `[Collection("FeatureFlags")]` attribute so xUnit runs them sequentially — otherwise `CreateClientAsync`'s process-wide provider registration races across xUnit's default cross-class parallelism.
- **Flag lifecycle is mandatory.** Short-lived *release* flags are **removed after rollout** (flag debt is the #1 failure mode); long-lived *ops/permission* flags are the exception. Trivial/mechanical changes are exempt from flagging.

CI **does** verify the scaffold: a repository ruleset (*"Require workflows to pass before merging for .NET"*) injects the shared `run-dotnet-tests.yaml` reusable workflow on every PR and merge-queue entry, which builds and tests the solution across `ubuntu`/`windows`/`macos` (with the GitHub Code Quality coverage upload). The repo's own `ci.yaml` `CI - Required Checks` aggregator (empty `job-results`) is a *separate, trivially-passing* status check — **not** the .NET gate; publishing the NuGet library is handled separately by the reusable publish pipeline on `v*` tags. **Don't wire `run-dotnet-tests` into `ci.yaml`** — it already runs via the ruleset, so adding it would double-run every job. The local commands above are fast feedback, not a substitute for a check CI skips.

## Maintenance (autonomous AI assistant)
Expand All @@ -50,6 +59,7 @@ must update **every** copy in the same PR, with no straggler left to drift.
- **Triage** new issues/PRs (label; one insightful comment on the oldest un-commented item).
- **Dependency/toolchain hygiene:** curate Dependabot PRs; keep the toolchain version (.NET SDK) and pinned action versions current and aligned with the house workflows; flag majors.
- **CI/workflow health:** keep CI green and tidy (pin/align actions, fix broken/flaky steps, remove dead workflows); red on `main` is top priority.
- **Scaffold freshness:** the generated project builds & tests on the current toolchain; README/badges accurate; example code idiomatic and minimal. The onboarding rename (`scripts/rename-placeholders.sh`) is pinned by `scripts/rename-placeholders.test.sh` (the `🧱 Validate Scaffold` gate) — keep them in lockstep when either changes.
- **Scaffold freshness:** the generated project builds & tests on the current toolchain; README/badges accurate; example code idiomatic and minimal. The onboarding rename (`scripts/rename-placeholders.sh`) is pinned by `scripts/rename-placeholders.test.sh` (the `🧱 Validate Scaffold` gate) — keep them in lockstep when either changes (adding a placeholder `.cs` file means adding it to the rename script's substitutions).
- **Feature-flag hygiene:** keep the OpenFeature scaffold current (see *Feature flags*) — new behaviour gated default-off + tested in both states; when the `Microsoft.FeatureManagement` OpenFeature bridge reaches GA, revisit adopting it as the provider.
- **Toolchain-floor freshness:** on any toolchain bump (or a new .NET GA), re-confirm the `global.json` SDK floor and the `net10.0` TFM still match the *Toolchain-floor policy* above — advance them in lockstep, only when forced — and that every copy of the framework/SDK version (both `*.csproj` and `global.json`) moved together with none left to drift.
- **Maintain your own PRs:** fix CI you caused, resolve conflicts.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A minimal, batteries-included .NET template for new projects and libraries. Skip

- **Idiomatic scaffold** — an [`Example.slnx`](Example.slnx) solution wiring a library project ([`src/Example`](src/Example)) to a matching xUnit test project ([`tests/Example.Tests`](tests/Example.Tests)). A single documented member with tests shows the house testing pattern; [`scripts/rename-placeholders.sh`](scripts/rename-placeholders.sh) repoints the `Example` scaffold to your own project name in one shot.
- **House defaults** — every project builds with `Nullable`, `ImplicitUsings`, `AnalysisMode=All`, `EnforceCodeStyleInBuild`, XML documentation generation, and `TreatWarningsAsErrors` enabled, so code-style and analyzer findings are build errors, not warnings. Editor and analyzer rules live in [`.editorconfig`](.editorconfig).
- **Feature-flag ready** — a portable [OpenFeature](https://openfeature.dev/) scaffold ([`src/Example/FeatureFlags.cs`](src/Example/FeatureFlags.cs)) so new features land **behind a flag, default-off**, are tested in both states, and are switched on only after validation. Ships an in-memory provider (no backend needed); swap in flagd or another provider without touching call sites. See [`AGENTS.md`](AGENTS.md#feature-flags).
- **CI/CD** — a required-checks workflow on pull requests and the merge queue ([`ci.yaml`](.github/workflows/ci.yaml)); the .NET build/test validation runs via an org-required reusable workflow enforced by branch rules (run `dotnet build` / `dotnet test` locally before a PR).
- **Releases & publishing** — merge [Conventional Commits](https://www.conventionalcommits.org/) to `main` and [semantic-release](https://github.com/semantic-release/semantic-release) cuts a `v*` tag and GitHub release ([`release.yaml`](.github/workflows/release.yaml)); that tag then publishes the library to NuGet via the shared [`publish-dotnet-library`](https://github.com/devantler-tech/reusable-workflows) workflow ([`publish.yaml`](.github/workflows/publish.yaml)).
- **Dependency management** — [Dependabot](https://docs.github.com/code-security/dependabot) keeps dependencies and pinned GitHub Actions current.
Expand Down
3 changes: 3 additions & 0 deletions scripts/rename-placeholders.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ subst() {
subst "$new_name.slnx"
subst "src/$new_name/$new_name.csproj"
subst "src/$new_name/${type_token}Class.cs"
# FeatureFlags.cs has no `Example` in its filename, so the directory move above
# carries it — but its contents (namespace, flag references) still need repointing.
subst "src/$new_name/FeatureFlags.cs"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
subst "tests/$new_name.Tests/$new_name.Tests.csproj"
subst "tests/$new_name.Tests/${type_token}ClassTests.cs"
subst "README.md"
Expand Down
3 changes: 3 additions & 0 deletions scripts/rename-placeholders.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ for p in \
"Widget.slnx" \
"src/Widget/Widget.csproj" \
"src/Widget/WidgetClass.cs" \
"src/Widget/FeatureFlags.cs" \
"tests/Widget.Tests/Widget.Tests.csproj" \
"tests/Widget.Tests/WidgetClassTests.cs"; do
[ -f "$p" ] || fail "expected renamed file missing: $p"
Expand All @@ -98,6 +99,8 @@ done
# both checked separately below, so scope this to *.slnx/*.csproj/*.cs).
grep -q "^namespace Widget;" "src/Widget/WidgetClass.cs" ||
fail "namespace not repointed in WidgetClass.cs"
grep -q "^namespace Widget;" "src/Widget/FeatureFlags.cs" ||
fail "namespace not repointed in FeatureFlags.cs"
grep -qF 'Project Path="src/Widget/Widget.csproj"' "Widget.slnx" ||
fail "solution Project Path not repointed in Widget.slnx"
grep -qF '..\src\Widget\Widget.csproj' "tests/Widget.Tests/Widget.Tests.csproj" ||
Expand Down
11 changes: 11 additions & 0 deletions src/Example/Example.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,15 @@
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
</PropertyGroup>

<!--
Feature-flag scaffolding (see AGENTS.md § Feature flags). Call sites use the
portable, vendor-neutral OpenFeature API. The scaffold ships OpenFeature's
built-in in-memory provider so the example evaluates with no backend; swap it
for a real provider (flagd for GitOps, the Microsoft.FeatureManagement bridge
once it ships GA, …) without touching call sites.
-->
<ItemGroup>
<PackageReference Include="OpenFeature" Version="2.14.0" />
</ItemGroup>

</Project>
84 changes: 84 additions & 0 deletions src/Example/FeatureFlags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using OpenFeature;
using OpenFeature.Providers.Memory;

namespace Example;

/// <summary>
/// Feature-flag scaffolding for the template. New features land <em>behind a flag,
/// default-off</em>, are tested in both states, and are switched on only after
/// validation (see <c>AGENTS.md</c> § Feature flags). Call sites use the portable,
/// vendor-neutral <see href="https://openfeature.dev/">OpenFeature</see> API, so the
/// backing provider can change without touching them. The scaffold ships OpenFeature's
/// built-in in-memory provider so the example evaluates with no external backend —
/// replace it with a real provider (flagd for GitOps, a hosted service, …) when you
/// adopt the template.
/// </summary>
public static class FeatureFlags
{
/// <summary>
/// Key of the example feature flag. It is registered <em>default-off</em> and gates
/// <see cref="DescribeAsync(IFeatureClient)"/>; replace it with your own flags.
/// </summary>
public const string ExampleFeature = "example-feature";

/// <summary>Variant key the in-memory provider resolves to <see langword="true"/> (flag on).</summary>
public const string EnabledVariant = "on";

/// <summary>Variant key the in-memory provider resolves to <see langword="false"/> (flag off).</summary>
public const string DisabledVariant = "off";

/// <summary>
/// Builds an OpenFeature in-memory provider seeding <see cref="ExampleFeature"/>
/// default-off, so the scaffold evaluates flags with no external backend. Swap it for
/// a real provider (flagd, …) without changing any call site.
/// </summary>
/// <returns>An in-memory provider with the example flag registered default-off.</returns>
public static FeatureProvider CreateInMemoryProvider()
{
return new InMemoryProvider(new Dictionary<string, Flag>(StringComparer.Ordinal)
{
[ExampleFeature] = new Flag<bool>(
new Dictionary<string, bool>(StringComparer.Ordinal)
{
[EnabledVariant] = true,
[DisabledVariant] = false,
},
DisabledVariant),
});
}

/// <summary>
/// Registers <paramref name="provider"/> as the OpenFeature provider and returns a
/// client that call sites evaluate flags against.
/// </summary>
/// <param name="provider">The provider to register (e.g. from <see cref="CreateInMemoryProvider"/>).</param>
/// <returns>An OpenFeature client bound to the registered provider.</returns>
/// <remarks>
/// Call this once during application startup: it re-registers OpenFeature's process-wide
/// default provider, and because clients resolve the <em>current</em> provider dynamically,
/// a later call silently redirects clients obtained earlier. If you need several independent
/// registrations, reach for OpenFeature's domain/named-client API instead of repeated calls.
/// </remarks>
public static async Task<IFeatureClient> CreateClientAsync(FeatureProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);
await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false);
return Api.Instance.GetClient();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// <summary>
/// Example of gating behaviour on <see cref="ExampleFeature"/>: returns the enhanced
/// message when the flag resolves on, and the default message when it is off or unset.
/// Replace it with your own flag-gated code path.
/// </summary>
/// <param name="featureClient">The OpenFeature client to evaluate the flag with.</param>
/// <returns>The flag-gated message.</returns>
public static async Task<string> DescribeAsync(IFeatureClient featureClient)
{
ArgumentNullException.ThrowIfNull(featureClient);
bool enabled = await featureClient
.GetBooleanValueAsync(ExampleFeature, false)
.ConfigureAwait(false);
return enabled ? "Example feature is on." : "Example feature is off.";
}
}
85 changes: 85 additions & 0 deletions tests/Example.Tests/ExampleClassTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
using OpenFeature;
using OpenFeature.Providers.Memory;

namespace Example.Tests;

/// <summary>
/// Tests for the <see cref="ExampleClass"/> class.
/// </summary>
/// <remarks>
/// Joins the shared <c>"FeatureFlags"</c> xUnit collection.
/// <see cref="FeatureFlags.CreateClientAsync(FeatureProvider)"/> re-registers the
/// process-wide <c>Api.Instance</c> provider, and xUnit runs different test classes in
/// parallel by default — so give every flag-exercising test class the same
/// <c>[Collection("FeatureFlags")]</c> attribute and xUnit runs them sequentially,
/// keeping their provider registrations from interleaving.
/// </remarks>
[Collection("FeatureFlags")]
public class ExampleClassTests
{
/// <summary>
Expand Down Expand Up @@ -38,4 +50,77 @@ public void Add_NegativeOperand_ReturnsTheirSum()
// Assert
Assert.Equal(6, sum);
}

/// <summary>
/// Verifies <see cref="FeatureFlags.DescribeAsync(IFeatureClient)"/> takes the OFF
/// branch when the example flag is registered default-off (the scaffold default).
/// </summary>
[Fact]
public async Task DescribeAsync_FlagDefaultOff_ReturnsOffMessage()
{
// Arrange
var client = await FeatureFlags.CreateClientAsync(FeatureFlags.CreateInMemoryProvider());

// Act
string result = await FeatureFlags.DescribeAsync(client);

// Assert
Assert.Contains("is off", result, StringComparison.Ordinal);
}

/// <summary>
/// Verifies <see cref="FeatureFlags.DescribeAsync(IFeatureClient)"/> takes the ON
/// branch when the example flag resolves on.
/// </summary>
[Fact]
public async Task DescribeAsync_FlagOn_ReturnsOnMessage()
{
// Arrange
var client = await FeatureFlags.CreateClientAsync(BuildExampleProvider(enabled: true));

// Act
string result = await FeatureFlags.DescribeAsync(client);

// Assert
Assert.Contains("is on", result, StringComparison.Ordinal);
}

/// <summary>
/// Verifies an unset flag falls back to the OFF default, so a missing flag definition
/// never silently turns a feature on.
/// </summary>
[Fact]
public async Task DescribeAsync_FlagUnset_DefaultsToOff()
{
// Arrange
var provider = new InMemoryProvider(new Dictionary<string, Flag>(StringComparer.Ordinal));
var client = await FeatureFlags.CreateClientAsync(provider);

// Act
string result = await FeatureFlags.DescribeAsync(client);

// Assert
Assert.Contains("is off", result, StringComparison.Ordinal);
}

/// <summary>
/// Builds an in-memory provider that resolves <see cref="FeatureFlags.ExampleFeature"/>
/// to <paramref name="enabled"/>, so both flag states can be exercised.
/// </summary>
/// <param name="enabled">Whether the example flag should resolve on.</param>
/// <returns>An in-memory provider resolving the example flag to <paramref name="enabled"/>.</returns>
static InMemoryProvider BuildExampleProvider(bool enabled)
{
string variant = enabled ? FeatureFlags.EnabledVariant : FeatureFlags.DisabledVariant;
return new InMemoryProvider(new Dictionary<string, Flag>(StringComparer.Ordinal)
{
[FeatureFlags.ExampleFeature] = new Flag<bool>(
new Dictionary<string, bool>(StringComparer.Ordinal)
{
[FeatureFlags.EnabledVariant] = true,
[FeatureFlags.DisabledVariant] = false,
},
variant),
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}