From c34610638381b5164ec9d3b63f903188c7d22c72 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 6 Jul 2026 21:39:26 +0200 Subject: [PATCH 1/3] feat: scaffold OpenFeature feature-flag support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a portable OpenFeature feature-flag scaffold (FeatureFlags.cs) with an example flag default-off, gated example path, and both-state tests, so features generated from the template land behind a flag by default. Ships OpenFeature's built-in in-memory provider (evaluates with no backend); the Microsoft.FeatureManagement OpenFeature bridge is preview-only, so the scaffold does not depend on it (the template avoids preview packages). Fixes #262 > ๐Ÿค– Generated by the Daily AI Assistant Co-Authored-By: Claude Opus 4.8 (1M context) --- .editorconfig | 3 + AGENTS.md | 14 ++++- README.md | 1 + scripts/rename-placeholders.sh | 3 + src/Example/Example.csproj | 11 ++++ src/Example/FeatureFlags.cs | 75 ++++++++++++++++++++++++ tests/Example.Tests/ExampleClassTests.cs | 72 +++++++++++++++++++++++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/Example/FeatureFlags.cs diff --git a/.editorconfig b/.editorconfig index 6db227b..109d27d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/AGENTS.md b/AGENTS.md index b2ed2ae..f5bcf9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. @@ -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. +- **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) @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 52e7f61..3cb8709 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scripts/rename-placeholders.sh b/scripts/rename-placeholders.sh index c5b78b9..9dd8e9a 100755 --- a/scripts/rename-placeholders.sh +++ b/scripts/rename-placeholders.sh @@ -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" subst "tests/$new_name.Tests/$new_name.Tests.csproj" subst "tests/$new_name.Tests/${type_token}ClassTests.cs" subst "README.md" diff --git a/src/Example/Example.csproj b/src/Example/Example.csproj index 28fd760..33d4285 100644 --- a/src/Example/Example.csproj +++ b/src/Example/Example.csproj @@ -11,4 +11,15 @@ true + + + + + diff --git a/src/Example/FeatureFlags.cs b/src/Example/FeatureFlags.cs new file mode 100644 index 0000000..f4f0afe --- /dev/null +++ b/src/Example/FeatureFlags.cs @@ -0,0 +1,75 @@ +using OpenFeature; +using OpenFeature.Providers.Memory; + +namespace Example; + +/// +/// Feature-flag scaffolding for the template. New features land behind a flag, +/// default-off, are tested in both states, and are switched on only after +/// validation (see AGENTS.md ยง Feature flags). Call sites use the portable, +/// vendor-neutral OpenFeature 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. +/// +public static class FeatureFlags +{ + /// + /// Key of the example feature flag. It is registered default-off and gates + /// ; replace it with your own flags. + /// + public const string ExampleFeature = "example-feature"; + + const string EnabledVariant = "on"; + const string DisabledVariant = "off"; + + /// + /// Builds an OpenFeature in-memory provider seeding + /// default-off, so the scaffold evaluates flags with no external backend. Swap it for + /// a real provider (flagd, โ€ฆ) without changing any call site. + /// + /// An in-memory provider with the example flag registered default-off. + public static FeatureProvider CreateInMemoryProvider() + { + return new InMemoryProvider(new Dictionary(StringComparer.Ordinal) + { + [ExampleFeature] = new Flag( + new Dictionary(StringComparer.Ordinal) + { + [EnabledVariant] = true, + [DisabledVariant] = false, + }, + DisabledVariant), + }); + } + + /// + /// Registers as the OpenFeature provider and returns a + /// client that call sites evaluate flags against. + /// + /// The provider to register (e.g. from ). + /// An OpenFeature client bound to the registered provider. + public static async Task CreateClientAsync(FeatureProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + return Api.Instance.GetClient(); + } + + /// + /// Example of gating behaviour on : 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. + /// + /// The OpenFeature client to evaluate the flag with. + /// The flag-gated message. + public static async Task 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."; + } +} diff --git a/tests/Example.Tests/ExampleClassTests.cs b/tests/Example.Tests/ExampleClassTests.cs index d6ac287..8477f90 100644 --- a/tests/Example.Tests/ExampleClassTests.cs +++ b/tests/Example.Tests/ExampleClassTests.cs @@ -1,3 +1,6 @@ +using OpenFeature; +using OpenFeature.Providers.Memory; + namespace Example.Tests; /// @@ -38,4 +41,73 @@ public void Add_NegativeOperand_ReturnsTheirSum() // Assert Assert.Equal(6, sum); } + + /// + /// Verifies takes the OFF + /// branch when the example flag is registered default-off (the scaffold default). + /// + [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); + } + + /// + /// Verifies takes the ON + /// branch when the example flag resolves on. + /// + [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); + } + + /// + /// Verifies an unset flag falls back to the OFF default, so a missing flag definition + /// never silently turns a feature on. + /// + [Fact] + public async Task DescribeAsync_FlagUnset_DefaultsToOff() + { + // Arrange + var provider = new InMemoryProvider(new Dictionary(StringComparer.Ordinal)); + var client = await FeatureFlags.CreateClientAsync(provider); + + // Act + string result = await FeatureFlags.DescribeAsync(client); + + // Assert + Assert.Contains("is off", result, StringComparison.Ordinal); + } + + /// + /// Builds an in-memory provider that resolves + /// to , so both flag states can be exercised. + /// + /// Whether the example flag should resolve on. + /// An in-memory provider resolving the example flag to . + static InMemoryProvider BuildExampleProvider(bool enabled) + { + string variant = enabled ? "on" : "off"; + return new InMemoryProvider(new Dictionary(StringComparer.Ordinal) + { + [FeatureFlags.ExampleFeature] = new Flag( + new Dictionary(StringComparer.Ordinal) { ["on"] = true, ["off"] = false }, + variant), + }); + } } From a152d0497311621e2e700238ba4820992d45d8e3 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 6 Jul 2026 22:21:16 +0200 Subject: [PATCH 2/3] refactor: address CodeRabbit review on OpenFeature scaffold - Expose EnabledVariant/DisabledVariant as public consts and reference them from the test helper (no more hardcoded on/off drift). - Document that CreateClientAsync re-registers the process-wide provider and should be called once at startup. - Cover FeatureFlags.cs rename+repoint in rename-placeholders.test.sh. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/rename-placeholders.test.sh | 3 +++ src/Example/FeatureFlags.cs | 13 +++++++++++-- tests/Example.Tests/ExampleClassTests.cs | 8 ++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/rename-placeholders.test.sh b/scripts/rename-placeholders.test.sh index ac005c8..461ef25 100755 --- a/scripts/rename-placeholders.test.sh +++ b/scripts/rename-placeholders.test.sh @@ -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" @@ -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" || diff --git a/src/Example/FeatureFlags.cs b/src/Example/FeatureFlags.cs index f4f0afe..de890c8 100644 --- a/src/Example/FeatureFlags.cs +++ b/src/Example/FeatureFlags.cs @@ -21,8 +21,11 @@ public static class FeatureFlags /// public const string ExampleFeature = "example-feature"; - const string EnabledVariant = "on"; - const string DisabledVariant = "off"; + /// Variant key the in-memory provider resolves to (flag on). + public const string EnabledVariant = "on"; + + /// Variant key the in-memory provider resolves to (flag off). + public const string DisabledVariant = "off"; /// /// Builds an OpenFeature in-memory provider seeding @@ -50,6 +53,12 @@ public static FeatureProvider CreateInMemoryProvider() /// /// The provider to register (e.g. from ). /// An OpenFeature client bound to the registered provider. + /// + /// Call this once during application startup: it re-registers OpenFeature's process-wide + /// default provider, and because clients resolve the current 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. + /// public static async Task CreateClientAsync(FeatureProvider provider) { ArgumentNullException.ThrowIfNull(provider); diff --git a/tests/Example.Tests/ExampleClassTests.cs b/tests/Example.Tests/ExampleClassTests.cs index 8477f90..8619ca6 100644 --- a/tests/Example.Tests/ExampleClassTests.cs +++ b/tests/Example.Tests/ExampleClassTests.cs @@ -102,11 +102,15 @@ public async Task DescribeAsync_FlagUnset_DefaultsToOff() /// An in-memory provider resolving the example flag to . static InMemoryProvider BuildExampleProvider(bool enabled) { - string variant = enabled ? "on" : "off"; + string variant = enabled ? FeatureFlags.EnabledVariant : FeatureFlags.DisabledVariant; return new InMemoryProvider(new Dictionary(StringComparer.Ordinal) { [FeatureFlags.ExampleFeature] = new Flag( - new Dictionary(StringComparer.Ordinal) { ["on"] = true, ["off"] = false }, + new Dictionary(StringComparer.Ordinal) + { + [FeatureFlags.EnabledVariant] = true, + [FeatureFlags.DisabledVariant] = false, + }, variant), }); } From ae3a2e0c1d7056746a929cc5bca4ca2994c9d39c Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 6 Jul 2026 23:19:51 +0200 Subject: [PATCH 3/3] test: serialize flag-exercising test classes via shared xUnit collection CreateClientAsync re-registers the process-wide OpenFeature Api.Instance provider; xUnit parallelizes across test classes by default, so the 'one test class per flag' convention would race. Put flag-exercising classes in a shared [Collection("FeatureFlags")] so xUnit runs them sequentially, and document the convention in AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- tests/Example.Tests/ExampleClassTests.cs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f5bcf9f..ed75505 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ New features land **behind a flag, default-off**, are tested in **both** states, - **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. +- **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. diff --git a/tests/Example.Tests/ExampleClassTests.cs b/tests/Example.Tests/ExampleClassTests.cs index 8619ca6..9615287 100644 --- a/tests/Example.Tests/ExampleClassTests.cs +++ b/tests/Example.Tests/ExampleClassTests.cs @@ -6,6 +6,15 @@ namespace Example.Tests; /// /// Tests for the class. /// +/// +/// Joins the shared "FeatureFlags" xUnit collection. +/// re-registers the +/// process-wide Api.Instance provider, and xUnit runs different test classes in +/// parallel by default โ€” so give every flag-exercising test class the same +/// [Collection("FeatureFlags")] attribute and xUnit runs them sequentially, +/// keeping their provider registrations from interleaving. +/// +[Collection("FeatureFlags")] public class ExampleClassTests { ///