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
34 changes: 34 additions & 0 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,37 @@ jobs:
exit 1
fi
echo "ok: the net8 fce and worker documented a net8 target on the .NET ${newest} preview runtime"

# Beyond the tooling above, layer BEHAVIOURAL coverage of the netstandard2.0 LIBRARIES on the preview. The fce
# step already proves the library LOADS on the preview runtime (the worker reflects over a netstandard2.0 target
# there); this runs the libraries' own unit and property suites on it, so a runtime regression that breaks
# behaviour — not just loading — turns the scheduled run red before that major ships. The test projects target
# net10.0; only the `dotnet test` execution is wrapped in DOTNET_ROLL_FORWARD, so the net10 test host binds the
# highest installed major (the preview) while the build stays on the .NET 10 SDK. Best-effort and non-blocking
# like the rest of the canary. RequestBinder.UnitTests IS included here (unlike the net472 floor job): DateOnly,
# which its fixtures bind, exists on every modern .NET — it is only absent from .NET Framework.
- name: Test the libraries on the preview runtime
if: steps.preview.outcome == 'success'
shell: bash
run: |
set -euo pipefail
echo "Installed .NET runtimes:"
dotnet --list-runtimes
newest="$(dotnet --list-runtimes | sed -nE 's/^Microsoft\.NETCore\.App ([0-9]+)\..*/\1/p' | sort -n | tail -1)"
if [ "${newest:-0}" -le 10 ]; then
echo "::notice::no runtime newer than the .NET 10 build SDK is installed; skipping the library preview run"
exit 0
fi
for proj in \
FirstClassErrors.UnitTests \
FirstClassErrors.PropertyTests \
FirstClassErrors.RequestBinder.UnitTests \
FirstClassErrors.RequestBinder.PropertyTests ; do
echo "::group::$proj on the .NET ${newest} preview"
# Build on the .NET 10 SDK (no roll-forward), then run ONLY the test host under roll-forward so it binds
# the preview major. The env is scoped inline to the test call so it never touches build/evaluation.
dotnet build "$proj/$proj.csproj" -c Release
DOTNET_ROLL_FORWARD=LatestMajor DOTNET_ROLL_FORWARD_TO_PRERELEASE=1 \
dotnet test "$proj/$proj.csproj" -c Release --no-build --logger "console;verbosity=normal"
echo "::endgroup::"
done
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,50 @@ jobs:
path: artifacts/coverage/**/coverage.opencover.xml
if-no-files-found: error

# build-test above runs the suite on the LATEST .NET (10). This job proves the LIBRARY's OTHER supported
# runtime: that the netstandard2.0 assemblies (FirstClassErrors, FirstClassErrors.Testing,
# FirstClassErrors.RequestBinder) actually load and pass their tests on .NET Framework 4.7.2 — the support
# floor recorded in doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md.
# netstandard2.0 is only a COMPILE contract; running on the real .NET Framework CLR (NLS globalization, the
# netstandard.dll facade, its reflection stack) is what proves the library behaves there — the ordinary net10
# build cannot. .NET Framework tests run on Windows only (there is no Mono on the Linux legs), and the net472
# inner build is gated behind EnableNet472Floor (build/Net472TestFloor.props) so it never touches build-test.
framework-floor:
name: Library on the .NET Framework 4.7.2 floor
runs-on: windows-latest
# Restore + build + test of three small projects; cap a hung run like the other jobs.
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

# global.json pins the .NET 10 SDK; that SDK compiles the net472 target. The .NET Framework runtime that
# RUNS net472 assemblies ships on the windows-latest image, and the net472 targeting pack for COMPILATION
# is supplied hermetically by the Microsoft.NETFramework.ReferenceAssemblies package — so no extra install.
- name: Setup .NET (build SDK)
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
with:
dotnet-version: '10.0.x'

# Only the three library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test
# projects (Roslyn / GenDoc / Cli) stay net10-only by design, and RequestBinder.UnitTests stays net10-only
# because its fixtures bind DateOnly, a .NET 6+ type absent from net472. RequestBinder is still floored here
# through its property tests. A per-project loop (not a solution-wide -f net472, which would force the TFM
# onto the net10-only projects and fail) keeps the net472 scope exactly these three.
- name: Test the netstandard2.0 libraries on .NET Framework 4.7.2
shell: bash
run: |
set -euo pipefail
for proj in \
FirstClassErrors.UnitTests \
FirstClassErrors.PropertyTests \
FirstClassErrors.RequestBinder.PropertyTests ; do
echo "::group::$proj (net472)"
dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \
--logger "console;verbosity=normal"
echo "::endgroup::"
done

# build-test above runs the whole suite on the LATEST .NET (10). This job proves the other end of the
# supported range: that the fce tool and its worker, which ship targeting net8.0 (the floor — see
# doc/handwritten/for-maintainers/adr/0002-floor-the-tooling-runtime.md), actually run on the .NET 8 RUNTIME. The net8 TFM
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" />
<PackageVersion Include="Microsoft.Sbom.Targets" Version="4.1.5" />
<PackageVersion Include="NFluent" Version="3.1.0" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- Adds the .NET Framework 4.7.2 support-floor leg (gated by EnableNet472Floor) and owns the target
frameworks; exercised by the `framework-floor` job in .github/workflows/ci.yml. -->
<Import Project="..\build\Net472TestFloor.props" />

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- Adds the .NET Framework 4.7.2 support-floor leg (gated by EnableNet472Floor) and owns the target
frameworks; exercised by the `framework-floor` job in .github/workflows/ci.yml. This project carries
the RequestBinder library onto the floor; its sibling *.RequestBinder.UnitTests stays net10.0-only
because its fixtures bind DateOnly, a .NET 6+ type absent from net472. -->
<Import Project="..\build\Net472TestFloor.props" />

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ public void ErrorPathsAreExactlyThePositionsOfFailingElements() {
public void ReorderingElementsReordersTheirErrorPathsAccordingly() {
Prop.ForAll(BinderGen.Slots().ToArbitrary(),
slots => {
(string? Raw, bool Fails)[] reversed = slots.Reverse().ToArray();
// AsEnumerable() pins the LINQ Reverse (a reversed copy). A bare slots.Reverse() binds instead
// to the void, in-place MemoryExtensions.Reverse(Span<T>) on the net472 floor, where
// System.Memory is in the graph (see build/Net472TestFloor.props).
(string? Raw, bool Fails)[] reversed = slots.AsEnumerable().Reverse().ToArray();

return FailingPaths(BindTokens(slots)).SetEquals(PositionsOfFailures(slots))
&& FailingPaths(BindTokens(reversed)).SetEquals(PositionsOfFailures(reversed));
Expand Down
13 changes: 9 additions & 4 deletions FirstClassErrors.UnitTests/ErrorCodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ public void CreatingErrorCodeWithValidCodeSucceeds(string code) {
[InlineData("")]
[InlineData(" ")]
public void CreatingErrorCodeWithBlankCodeIsRejected(string? code) {
// Exercise & verify
Check.ThatCode(() => ErrorCode.Create(code!))
.Throws<ArgumentException>()
.WithMessage("Error code cannot be null or whitespace. (Parameter 'code')");
// Exercise
ArgumentException exception = Assert.Throws<ArgumentException>(() => ErrorCode.Create(code!));

// Verify the contract — the message content and the offending parameter — not the
// ArgumentException.Message parameter-suffix formatting, which differs between .NET Framework
// ("...\nParameter name: code") and modern .NET ("... (Parameter 'code')"). The netstandard2.0
// library runs on both; see the framework-floor job in .github/workflows/ci.yml.
Check.That(exception.Message).Contains("Error code cannot be null or whitespace.");
Check.That(exception.ParamName).IsEqualTo("code");
}

[Fact(DisplayName = "Creating the same code twice is allowed and produces equal instances.")]
Expand Down
11 changes: 7 additions & 4 deletions FirstClassErrors.UnitTests/ErrorContextKeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,13 @@ public void AKeyCannotBeCreatedWithANullDescriptionProvider() {
[InlineData("")]
[InlineData(" ")]
public void RegisteringKeyWithBlankNameIsRejected(string? name) {
// Exercise & verify
Check.ThatCode(() => ErrorContextKey.Create<string>(name!))
.Throws<ArgumentException>()
.WithMessage("Value cannot be null or whitespace. (Parameter 'name')");
// Exercise
ArgumentException exception = Assert.Throws<ArgumentException>(() => ErrorContextKey.Create<string>(name!));

// Verify the contract, not the framework-specific parameter-suffix formatting of
// ArgumentException.Message (same net472-floor rationale as ErrorCodeTests).
Check.That(exception.Message).Contains("Value cannot be null or whitespace.");
Check.That(exception.ParamName).IsEqualTo("name");
}

[Fact(DisplayName = "Re-declaring a key with the same name and type returns the registered instance.")]
Expand Down
12 changes: 11 additions & 1 deletion FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- Adds the .NET Framework 4.7.2 support-floor leg (gated by EnableNet472Floor) and owns the target
frameworks; exercised by the `framework-floor` job in .github/workflows/ci.yml. -->
<Import Project="..\build\Net472TestFloor.props" />

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down Expand Up @@ -32,4 +35,11 @@
<Using Include="Xunit" />
</ItemGroup>

<!-- System.Text.Json is in-box on net10.0 but absent from net472. This test round-trips the GenDoc model
through it — a tooling-side contract the modern out-of-process worker relies on, never something a
net472 consumer does — so it is not part of the .NET Framework floor. It still runs on net10.0. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Compile Remove="DocumentationModelSerializationTests.cs" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion FirstClassErrors/README.nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ checked at build time and can be extracted into a living error catalog.
separate install. They catch, at build time, what would otherwise surface late:
duplicate error codes, unresolved `[DocumentedBy]` references, documented errors
missing from the catalog, an unused `ToException()` result, and more.
- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.6.1+,
- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.7.2+,
.NET Core 2.0+, .NET 5+ (and Mono / Xamarin / Unity). Nothing added to your
dependency graph.

Expand Down
11 changes: 11 additions & 0 deletions build/Net472TestFloor.IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Compiler-required marker behind C# `init` accessors and records. The .NET Framework 4.7.2 BCL does not
// ship it, so this polyfill is compiled ONLY into net472 test builds (see build/Net472TestFloor.props). It is
// internal, so it never leaves the test assembly and each net472 test assembly gets its own. The shipped
// FirstClassErrors libraries use neither `init` nor records, so nothing in the product relies on this.

// ReSharper disable once CheckNamespace -- the compiler resolves this type by its exact fully-qualified name.
namespace System.Runtime.CompilerServices {

internal static class IsExternalInit { }

}
50 changes: 50 additions & 0 deletions build/Net472TestFloor.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<Project>

<!--
Shared MSBuild plumbing that lets a netstandard2.0-library test project ALSO run on the
.NET Framework 4.7.2 support floor (see doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md).

A test project opts in with

<Import Project="..\build\Net472TestFloor.props" />

and drops its own <TargetFramework> — this file owns the target frameworks. Only projects that
exercise the netstandard2.0 libraries (FirstClassErrors, FirstClassErrors.Testing,
FirstClassErrors.RequestBinder) should import it; the tooling test projects stay net10.0-only.

The net472 inner build is GATED behind EnableNet472Floor so it never touches the ordinary build.
A plain `dotnet build` / `dotnet test` — the ci `build-test` matrix and the local inner loop — sees
only net10.0. The dedicated Windows `framework-floor` job in .github/workflows/ci.yml passes
-p:EnableNet472Floor=true to add the net472 inner build and runs it with `-f net472`; .NET Framework
tests only run on Windows (there is no Mono on the Linux legs).
-->

<PropertyGroup Condition="'$(EnableNet472Floor)' != 'true'">
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(EnableNet472Floor)' == 'true'">
<TargetFrameworks>net10.0;net472</TargetFrameworks>
</PropertyGroup>

<PropertyGroup>
<!-- net472 defaults C# to 7.3, which rejects <Nullable>enable</Nullable>. Pin the same latest compiler
the shipped netstandard2.0 projects use, so the floor bounds the BCL surface, not the C# syntax. -->
<LangVersion>latest</LangVersion>
<!-- xUnit.net v3 test projects must be executables on .NET Framework (the SDK does this implicitly for
modern TFMs; net472 does not). Scoped to net472 so the net10.0 inner build is unchanged. -->
<OutputType Condition="'$(TargetFramework)' == 'net472'">Exe</OutputType>
</PropertyGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<!-- Reference assemblies so net472 COMPILES on any box without the 4.7.2 targeting pack installed (the
Linux legs, a bare Windows runner). Build-time only (PrivateAssets=all): never a runtime or package
dependency. The version is pinned centrally in Directory.Packages.props. -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" PrivateAssets="all" />
<!-- `init` accessors and records (used by a few test models) need IsExternalInit, which the net472 BCL
does not ship. This private polyfill lets existing test code compile unchanged; it is internal, so
each net472 test assembly carries its own with no conflict. The SHIPPED libraries use neither `init`
nor records, so this is a test-only concession and never part of the product. -->
<Compile Include="$(MSBuildThisFileDirectory)Net472TestFloor.IsExternalInit.cs" />
</ItemGroup>

</Project>
Loading