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
92 changes: 92 additions & 0 deletions .github/workflows/dummies.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
81 changes: 81 additions & 0 deletions tools/dummies-check/DummiesCheck.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- Packaged-asset compatibility consumer for the Dummies NuGet package. Deliberately NOT part of
FirstClassErrors.sln: ci.yml would build it under the .NET 10 SDK against a single asset, which is
exactly what this project exists to AVOID. It is built and run solely by .github/workflows/dummies.yml.

WHY THIS EXISTS
Dummies ships two target-framework assets in one package: lib/netstandard2.0 and lib/net8.0. The main
test project (Dummies.UnitTests) targets net10.0, so NuGet resolves the NEAREST compatible asset —
net8.0 — and the netstandard2.0 asset, though built, is never executed by a test. This consumer closes
that gap by consuming the PACKED package from two different consumer TFMs, each forcing one asset:
* net8.0 consumer -> restores lib/net8.0 (DateOnly/TimeOnly/Int128/UInt128/Half present)
* net6.0 consumer -> restores lib/netstandard2.0 (those types absent; the #if-additive branch out)
Program.cs then proves by reflection WHICH asset it got, that the modern surface is present/absent
accordingly, and exercises the common smoke surface (scalars, constraints, composition, collections,
seeded reproducibility). A failure prints the offending asset moniker so the red step names the asset.

WHY NO NESTED global.json (unlike tools/floor-check)
The floor check pins an OLD SDK because its contract is "the analyzer loads on the oldest Roslyn". Here
the contract is ASSET SELECTION, driven by the consumer's TargetFramework, not by the SDK. So this
project builds under the repo-root SDK (.NET 10) and lets each TFM select its asset; the .NET 6 and
.NET 8 RUNTIMES (installed by the workflow) are what actually execute each leg.

FOLLOW-UP: a .NET Framework (net472) leg would additionally exercise the netstandard2.0 asset on the
.NET Framework CLR — the emblematic downlevel consumer. It is intentionally deferred: it would be the
repository's first Framework target (Windows runner + Framework toolchain). Add it as another entry in
<TargetFrameworks> plus a step in dummies.yml when a Framework consumer justifies that cost. -->
<PropertyGroup>
<!-- net8.0 forces the net8.0 asset; net6.0 forces the netstandard2.0 asset (any runnable TFM below net8.0
does — net6.0 is CoreCLR, cheap on ubuntu, and keeps a clean A/B against the net8.0 leg). -->
<TargetFrameworks>net8.0;net6.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>

<!-- Opt out of the repo-wide CI warning ratchet (Directory.Build.props): this project builds a DOWN-LEVEL,
out-of-support TFM (net6.0) under the .NET 10 SDK, which legitimately emits SDK warnings the net10 CI
legs never see. Elevating those to errors would redden this job for reasons foreign to the one contract
it checks — packaged-asset selection. The explicit Environment exit code in Program.cs is the signal. -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>false</MSBuildTreatWarningsAsErrors>
<!-- net6.0 is out of support; NETSDK1138 would warn on every build. Silence it: consuming a downlevel asset
from a downlevel TFM is the deliberate point of this project, not an oversight. -->
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<!-- Opt out of repo-wide Central Package Management (Directory.Packages.props): this project consumes the
Dummies package at a dynamic $(DummiesCheckVersion) and is built in isolation, so it keeps its version
inline (a PackageReference Version under CPM would otherwise raise NU1008). -->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>

<PropertyGroup>
<!-- The exact package version to consume. The dummies workflow owns it and passes -p:DummiesCheckVersion
(the same value it packs with). The default is only a local-dev fallback for building by hand:
rm -rf packages local-feed bin obj
dotnet pack ../../Dummies/Dummies.csproj -c Release -p:Version=1.0.0-dummiescheck.dev -o local-feed
# only the .NET 10 runtime may be present locally; roll forward so each TFM can still run:
DOTNET_ROLL_FORWARD=LatestMajor dotnet run -c Release -f net8.0
DOTNET_ROLL_FORWARD=LatestMajor dotnet run -c Release -f net6.0
The wipe is MANDATORY on a re-pack: the default version is fixed and NuGet treats a version as
immutable, so without it the run silently reuses the previously extracted package. -->
<DummiesCheckVersion Condition="'$(DummiesCheckVersion)' == ''">1.0.0-dummiescheck.dev</DummiesCheckVersion>
<!-- Extract restored packages into ./packages instead of the machine-global ~/.nuget/packages, which is
keyed by (id, version) and never re-reads a feed for a version it already holds — so with the fixed
local-dev version a re-pack would silently serve the STALE copy. A project-local folder makes the reset
above cheap and keeps this throwaway package out of the developer's real cache. -->
<RestorePackagesPath>$(MSBuildThisFileDirectory)packages</RestorePackagesPath>
<!-- packages/ lives UNDER the project dir (RestorePackagesPath) and the SDK's default globs compile every
**/*.cs beneath it; exclude it so a dependency shipping sources could never be swept into this build. -->
<DefaultItemExcludes>$(DefaultItemExcludes);packages/**</DefaultItemExcludes>
</PropertyGroup>

<ItemGroup>
<!-- Consume the PACKED package, NOT a ProjectReference: a ProjectReference binds the project's own
multi-target output and would defeat the whole point (asset selection as a real consumer sees it). The
version is pinned EXACTLY, not floated, and nuget.config maps the id to the local feed only, so a future
stable Dummies on nuget.org can never be substituted for the artifact under test. -->
<PackageReference Include="Dummies" Version="$(DummiesCheckVersion)" />
</ItemGroup>

</Project>
149 changes: 149 additions & 0 deletions tools/dummies-check/Program.cs
Original file line number Diff line number Diff line change
@@ -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<TargetFrameworkAttribute>()?.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<string> 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<string> 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<int> 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<int> 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<string> 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<string> failures, bool condition, string message) {
if (!condition) { failures.Add(message); }
}

}
Loading