diff --git a/.github/workflows/dummies.yml b/.github/workflows/dummies.yml
new file mode 100644
index 00000000..d5a1e072
--- /dev/null
+++ b/.github/workflows/dummies.yml
@@ -0,0 +1,92 @@
+name: dummies
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+
+# Cancel superseded runs on the same branch / PR.
+concurrency:
+ group: dummies-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+# Least privilege: this workflow only checks out, packs and runs, so the token
+# needs nothing beyond read access to the repository contents.
+permissions:
+ contents: read
+
+env:
+ DOTNET_NOLOGO: 'true'
+ DOTNET_CLI_TELEMETRY_OPTOUT: 'true'
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true'
+
+jobs:
+ # The ci workflow builds Dummies for both TFMs and runs Dummies.UnitTests on net10.0 — which resolves the
+ # NEAREST compatible asset (net8.0). So the netstandard2.0 asset that ships in the package is compiled but
+ # never EXECUTED by a test. This job closes that gap: it packs the real .nupkg and consumes it from an
+ # isolated project (tools/dummies-check) once per consumer TFM, each forcing a different packaged asset,
+ # then proves by reflection which asset loaded and exercises the common smoke surface on it.
+ packaged-assets:
+ name: Dummies packaged-asset compatibility
+ runs-on: ubuntu-latest
+ # Pack (~10s) plus two short consumer runs; cap a hung run like the other workflows.
+ timeout-minutes: 15
+ env:
+ # Single source of truth for the throwaway package version, passed to the pack (-p:Version) and BOTH
+ # consume (-p:DummiesCheckVersion) steps. The run-number.attempt suffix makes every run produce a version
+ # NuGet has never cached, so the consume steps always restore the freshly packed .nupkg instead of a
+ # stale copy; run_attempt (which changes on a re-run) closes the door should a ~/.nuget cache ever be
+ # added. DummiesCheck.csproj pins this EXACT version, and nuget.config maps the id to the local feed only,
+ # so it can never resolve a future stable Dummies from nuget.org.
+ DUMMIESCHECK_VERSION: 1.0.0-dummiescheck.${{ github.run_number }}.${{ github.run_attempt }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+
+ # Three SDKs on purpose: 10.0.x is the SDK release.yml packs with, so the pack step produces the exact
+ # artifact consumers receive; 8.0.x and 6.0.x bring the .NET 8 and .NET 6 RUNTIMES so each consumer leg
+ # executes on its own advertised runtime (default roll-forward stays within a major, so a net6.0 consumer
+ # binds .NET 6 and can never roll onto net8/net10).
+ - name: Setup .NET (pack SDK + downlevel runtimes)
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ dotnet-version: |
+ 6.0.x
+ 8.0.x
+ 10.0.x
+
+ - name: Pack Dummies under the release SDK
+ # Pack from the repo root so the root global.json selects the .NET 10 SDK — the one release.yml packs
+ # with — producing the exact multi-asset artifact a consumer restores (lib/netstandard2.0 + lib/net8.0).
+ # GenerateSBOM is off here: the SBOM is a release-pipeline concern (release.yml), and this job only needs
+ # the lib/ assets. The feed and the project-local package cache are wiped first so the consume steps can
+ # only see this run's package — a no-op on a fresh runner, but it keeps a reused workspace idempotent.
+ run: |
+ rm -rf tools/dummies-check/local-feed tools/dummies-check/packages
+ dotnet pack Dummies/Dummies.csproj -c Release -p:Version="$DUMMIESCHECK_VERSION" -p:GenerateSBOM=false -o tools/dummies-check/local-feed
+
+ - name: Validate the net8.0 asset (net8.0 consumer)
+ # A net8.0 consumer resolves lib/net8.0: the modern generators (DateOnly/Int128/Half/...) must be
+ # PRESENT. The program self-checks and exits non-zero on any mismatch; the greps are positive proof that
+ # it ran AND loaded the intended asset (a program that silently no-oped would exit 0 with no banner).
+ working-directory: tools/dummies-check
+ run: |
+ dotnet run -c Release --framework net8.0 -p:DummiesCheckVersion="$DUMMIESCHECK_VERSION" > net8.log 2>&1 || { cat net8.log; exit 1; }
+ cat net8.log
+ grep -q 'ASSET=.NETCoreApp,Version=v8.0' net8.log
+ grep -q 'RESULT=PASS' net8.log
+
+ - name: Validate the netstandard2.0 asset (net6.0 consumer)
+ # A net6.0 consumer resolves lib/netstandard2.0: the modern generators must be ABSENT, while the common
+ # surface (scalars, constraints, composition, collections, seeded reproducibility) still works. This is
+ # the leg the net10.0 test project can never exercise — the whole reason this workflow exists.
+ working-directory: tools/dummies-check
+ run: |
+ dotnet run -c Release --framework net6.0 -p:DummiesCheckVersion="$DUMMIESCHECK_VERSION" > netstandard.log 2>&1 || { cat netstandard.log; exit 1; }
+ cat netstandard.log
+ grep -q 'ASSET=.NETStandard,Version=v2.0' netstandard.log
+ grep -q 'RESULT=PASS' netstandard.log
diff --git a/.gitignore b/.gitignore
index ab96db46..eb9a84fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -293,3 +293,9 @@ __pycache__/
tools/floor-check/local-feed/
tools/floor-check/packages/
tools/floor-check/build.log
+# Dummies packaged-asset compatibility check (tools/dummies-check): CI-generated artifacts, never committed.
+# local-feed holds the packed .nupkg the dummies job produces; packages/ is the project-local NuGet
+# extraction folder (RestorePackagesPath); *.log are the job's per-asset run outputs.
+tools/dummies-check/local-feed/
+tools/dummies-check/packages/
+tools/dummies-check/*.log
diff --git a/tools/dummies-check/DummiesCheck.csproj b/tools/dummies-check/DummiesCheck.csproj
new file mode 100644
index 00000000..9dc0c96b
--- /dev/null
+++ b/tools/dummies-check/DummiesCheck.csproj
@@ -0,0 +1,81 @@
+
+
+
+
+
+ net8.0;net6.0
+ Exe
+ enable
+ enable
+ false
+
+
+ false
+ false
+
+ false
+
+ false
+
+
+
+
+ 1.0.0-dummiescheck.dev
+
+ $(MSBuildThisFileDirectory)packages
+
+ $(DefaultItemExcludes);packages/**
+
+
+
+
+
+
+
+
diff --git a/tools/dummies-check/Program.cs b/tools/dummies-check/Program.cs
new file mode 100644
index 00000000..aa4fd628
--- /dev/null
+++ b/tools/dummies-check/Program.cs
@@ -0,0 +1,149 @@
+#region Usings declarations
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+using Dummies;
+
+#endregion
+
+namespace DummiesCheck;
+
+// Packaged-asset compatibility consumer. Run once per consumer TFM by .github/workflows/dummies.yml.
+// The consumer's compile target dictates which packaged asset NuGet restored, and therefore what this
+// program must observe:
+// net8.0 consumer -> lib/net8.0 asset -> modern generators present
+// net6.0 consumer -> lib/netstandard2.0 asset -> modern generators absent
+// Any mismatch is a regression in packaging or conditional compilation; the program prints the offending
+// asset moniker and exits non-zero so the workflow step turns red against the right asset.
+internal static class Program {
+
+#if NET8_0_OR_GREATER
+ private const bool ExpectModernTypes = true;
+ private const string ConsumerTfm = "net8.0";
+ private const string ExpectedFamily = ".NETCoreApp";
+#else
+ private const bool ExpectModernTypes = false;
+ private const string ConsumerTfm = "net6.0";
+ private const string ExpectedFamily = ".NETStandard";
+#endif
+
+ // The net8.0-only generators, guarded by #if NET8_0_OR_GREATER in Dummies (Any.cs). Present on the net8.0
+ // asset, absent on the netstandard2.0 asset — the exact conditional surface the acceptance criteria name.
+ private static readonly string[] ModernEntryPoints = { "DateOnly", "TimeOnly", "Int128", "UInt128", "Half" };
+
+ private static int Main() {
+ Assembly dummies = typeof(Any).Assembly;
+ string assetMoniker = dummies.GetCustomAttribute()?.FrameworkName ?? "(none)";
+
+ // Machine-readable banner the workflow greps to prove which asset actually loaded — a program that
+ // silently did nothing would otherwise exit 0. RESULT= is emitted last, once the checks have run.
+ Console.WriteLine($"CONSUMER_TFM={ConsumerTfm}");
+ Console.WriteLine($"ASSET={assetMoniker}");
+ Console.WriteLine($"RUNTIME={RuntimeInformation.FrameworkDescription}");
+
+ List failures = new();
+
+ // 1. The restored asset is the one this consumer TFM is meant to force.
+ bool assetIsNetStandard = assetMoniker.IndexOf("NETStandard", StringComparison.OrdinalIgnoreCase) >= 0;
+ if (assetIsNetStandard == ExpectModernTypes) {
+ failures.Add($"wrong asset: consumer {ConsumerTfm} loaded '{assetMoniker}', expected a {ExpectedFamily} asset");
+ }
+
+ // 2. The conditional net8.0-only surface is present exactly on the net8.0 asset and absent otherwise.
+ foreach (string name in ModernEntryPoints) {
+ bool present = typeof(Any).GetMethod(name, BindingFlags.Public | BindingFlags.Static, binder: null, types: Type.EmptyTypes, modifiers: null) is not null;
+ if (present != ExpectModernTypes) {
+ failures.Add($"Any.{name}() is {(present ? "present" : "absent")} on '{assetMoniker}', expected {(ExpectModernTypes ? "present" : "absent")}");
+ }
+ }
+
+ // 3. Smoke: the common public surface actually works when consumed from the package.
+ RunSmoke(failures);
+
+ if (failures.Count == 0) {
+ Console.WriteLine($"RESULT=PASS asset={assetMoniker}");
+
+ return 0;
+ }
+
+ Console.Error.WriteLine($"RESULT=FAIL asset={assetMoniker} failures={failures.Count}");
+ foreach (string failure in failures) {
+ Console.Error.WriteLine($" - [asset={assetMoniker}] {failure}");
+ }
+
+ return 1;
+ }
+
+ private static void RunSmoke(List failures) {
+ // Scalars + constraints.
+ int roll = Any.Int32().Between(1, 6).Generate();
+ Require(failures, roll is >= 1 and <= 6, $"Int32().Between(1,6) produced {roll}");
+
+ int positive = Any.Int32().Positive().Generate();
+ Require(failures, positive > 0, $"Int32().Positive() produced {positive}");
+
+ string capped = Any.String().NonEmpty().WithMaxLength(50).Generate();
+ Require(failures, capped.Length is >= 1 and <= 50, $"String().NonEmpty().WithMaxLength(50) produced length {capped.Length}");
+
+ double real = Any.Double().Between(0d, 1000d).Generate();
+ Require(failures, real is >= 0d and <= 1000d, $"Double().Between(0,1000) produced {real.ToString("R", CultureInfo.InvariantCulture)}");
+
+ // A contradiction in the Arrange must fail fast at declaration time (part of the library's contract):
+ // the prefix alone requires 4 characters, so WithLength(3) cannot be satisfied.
+ bool threw = false;
+ try { Any.String().WithLength(3).StartingWith("ORD-"); } catch (ConflictingAnyConstraintException) { threw = true; }
+ Require(failures, threw, "a contradictory String constraint did not throw ConflictingAnyConstraintException");
+
+ // Composition through a factory (.As).
+ string composed = Any.Int32().Between(1, 999).As(n => "ID-" + n.ToString(CultureInfo.InvariantCulture)).Generate();
+ Require(failures, composed.StartsWith("ID-", StringComparison.Ordinal), $"As(...) produced '{composed}'");
+
+ // Collections.
+ List list = Any.ListOf(Any.Int32().Between(0, 9)).WithCount(4).Generate();
+ Require(failures, list.Count == 4 && list.All(value => value is >= 0 and <= 9), $"ListOf(...).WithCount(4) produced [{string.Join(",", list)}]");
+
+ HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3).Generate();
+ Require(failures, set.Count == 3, $"SetOf(...).WithCount(3) produced {set.Count} elements");
+
+ // Seeded reproducibility: two contexts with the same seed replay an identical mixed sequence, and a
+ // different seed diverges. This is the library's crown-jewel guarantee — verified here on each asset.
+ string first = SeedBatch(Any.WithSeed(20260719));
+ string second = SeedBatch(Any.WithSeed(20260719));
+ Require(failures, first == second, "same-seed contexts diverged");
+
+ string other = SeedBatch(Any.WithSeed(987654321));
+ Require(failures, first != other, "different-seed contexts produced identical sequences");
+ }
+
+ // Draws a fixed mixed sequence from the COMMON surface only (no modern types), so it compiles and runs
+ // on both assets. Rendered with InvariantCulture to match the library's own culture-invariant rendering.
+ private static string SeedBatch(AnyContext any) {
+ List parts = new() {
+ any.Int32().Generate().ToString(CultureInfo.InvariantCulture),
+ any.Int32().Between(1, 1000).Generate().ToString(CultureInfo.InvariantCulture),
+ any.String().NonEmpty().WithMaxLength(50).Generate(),
+ any.Int64().Generate().ToString(CultureInfo.InvariantCulture),
+ any.UInt64().Generate().ToString(CultureInfo.InvariantCulture),
+ any.Double().Between(0d, 1000d).Generate().ToString("R", CultureInfo.InvariantCulture),
+ any.Decimal().Between(0m, 1000m).Generate().ToString(CultureInfo.InvariantCulture),
+ any.Bool().Generate().ToString(),
+ any.Guid().Generate().ToString(),
+ any.Char().Generate().ToString(),
+ any.TimeSpan().Generate().Ticks.ToString(CultureInfo.InvariantCulture),
+ any.DateTime().Generate().Ticks.ToString(CultureInfo.InvariantCulture)
+ };
+
+ return string.Join("|", parts);
+ }
+
+ private static void Require(List failures, bool condition, string message) {
+ if (!condition) { failures.Add(message); }
+ }
+
+}
diff --git a/tools/dummies-check/nuget.config b/tools/dummies-check/nuget.config
new file mode 100644
index 00000000..3f8f0818
--- /dev/null
+++ b/tools/dummies-check/nuget.config
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+