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 @@ + + + + + + + + + + + + + + + + + +