From 859296ee40c818bc8787a29d489eb4b62b66b58a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:39:00 +0000 Subject: [PATCH] ci(dummies): validate packaged netstandard2.0 and net8.0 assets Dummies ships lib/netstandard2.0 and lib/net8.0 in one package, but Dummies.UnitTests targets net10.0 and so only ever exercises the net8.0 asset NuGet resolves as nearest-compatible. The netstandard2.0 asset is compiled but never executed by a test, so a downlevel-only regression (conditional compilation, API availability, seeded reproducibility) could reach NuGet with the suite green. Add a dedicated workflow that packs the real .nupkg and consumes it from an isolated project (tools/dummies-check) once per consumer TFM, mirroring the pack-and-consume pattern of tools/floor-check: * net8.0 consumer forces the net8.0 asset (modern generators present); * net6.0 consumer forces the netstandard2.0 asset (modern generators absent), the leg the net10.0 test project can never reach. The consumer proves by reflection which asset loaded, checks the conditional net8.0 surface (DateOnly/TimeOnly/Int128/UInt128/Half) is present only on the intended asset, exercises the common smoke surface (scalars, constraints, composition, collections, seeded reproducibility), and exits non-zero naming the asset that regressed. A net472 leg (netstandard2.0 on the .NET Framework CLR) is left as a documented follow-up: it would be the repository's first Framework target. Refs: #191 --- .github/workflows/dummies.yml | 92 +++++++++++++++ .gitignore | 6 + tools/dummies-check/DummiesCheck.csproj | 81 +++++++++++++ tools/dummies-check/Program.cs | 149 ++++++++++++++++++++++++ tools/dummies-check/nuget.config | 28 +++++ 5 files changed, 356 insertions(+) create mode 100644 .github/workflows/dummies.yml create mode 100644 tools/dummies-check/DummiesCheck.csproj create mode 100644 tools/dummies-check/Program.cs create mode 100644 tools/dummies-check/nuget.config 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 @@ + + + + + + + + + + + + + + + + + +