diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bcdd5e74..563c21be 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,8 +16,12 @@ updates: # Microsoft.Extensions.* versions are framework-aligned via TFM-conditional # ItemGroups in Directory.Packages.props (net8 -> 8.x, net10 -> 10.x). # Block major bumps so Dependabot can't push 10.x onto the net8 target; - # cross-major moves are managed by hand. Not sufficient on its own: a same-major bump - # can still land on the wrong floor, so PackageVersionFloorTests is the guard. + # cross-major moves are managed by hand. Not sufficient on its own: Dependabot does not + # evaluate the conditions, so it reads one version per ID (the highest, i.e. the net10 one) + # and a 10.x patch does not look major to this rule at all. What keeps it off the net8 floor + # is the layout: the net10 group is declared first, so the line it edits is that one, and its + # versions come from $(MEVersion10). PackageVersionFloorTests guards the order, the property + # and both majors. - dependency-name: "Microsoft.Extensions.*" update-types: ["version-update:semver-major"] - package-ecosystem: "github-actions" diff --git a/Directory.Packages.props b/Directory.Packages.props index c4f6b5a1..b7916d14 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,11 +5,34 @@ 4.0.0 4.18.1 2.9.0 + + 10.0.12 - + + + + + + + + + + + + + + @@ -23,21 +46,14 @@ - - - - - - - - - - - - - - + + diff --git a/src/UiPath.Caching.Azure/UiPath.Caching.Azure.csproj b/src/UiPath.Caching.Azure/UiPath.Caching.Azure.csproj index 31dc8cd2..407e6af1 100644 --- a/src/UiPath.Caching.Azure/UiPath.Caching.Azure.csproj +++ b/src/UiPath.Caching.Azure/UiPath.Caching.Azure.csproj @@ -8,7 +8,9 @@ UiPath;Caching;Azure;Entra;Redis - + + diff --git a/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs b/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs index c3c9c586..56a368cc 100644 --- a/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs +++ b/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs @@ -50,6 +50,67 @@ public void FloorPackagesAreNotAlsoPinnedUnconditionally() "a package pinned both conditionally and unconditionally is ambiguous, which is what lets a bump land on the wrong line"); } + /// + /// A floor only reaches a consumer through a shipped project, so that is what this checks: every + /// Microsoft.Extensions.* package a src project references has to be floored per TFM. The one that + /// cannot be is Logging.Abstractions, because StackExchange.Redis 3.x asks for >= 10.0.5 on every + /// target, net8.0 included, and an 8.0.x floor for it fails restore. Anything else escaping the + /// floors is a floor net8.0 consumers lose, and should be argued for here rather than land quietly. + /// Packages only samples or tests reference are not in the published dependency groups at all. + /// + [Fact] + public void EveryShippedExtensionsPackageIsFlooredExceptLoggingAbstractions() + { + var floored = FloorGroup("net8.0").Select(e => PackageOf(e).Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + var shipped = ShippedPackageReferences() + .Where(id => id.StartsWith("Microsoft.Extensions.", StringComparison.Ordinal)) + .Where(id => !id.Equals("Microsoft.Extensions.Logging.Abstractions", StringComparison.Ordinal)) + .Where(id => !floored.Contains(id)) + .Order() + .ToArray(); + + shipped.Should().BeEmpty( + "a Microsoft.Extensions.* package that a shipped project references without a per-TFM floor resolves 10.x for net8.0 consumers too, which is what the floors exist to avoid; Logging.Abstractions is the documented exception, forced by StackExchange.Redis 3.x asking for 10.0.5 or later on every target"); + } + + private static IEnumerable ShippedPackageReferences() + { + var src = new DirectoryInfo(Path.Combine(RepositoryRoot().FullName, "src")); + src.Exists.Should().BeTrue("the shipped projects live under src"); + + return src.EnumerateFiles("*.csproj", SearchOption.AllDirectories) + .SelectMany(f => XDocument.Load(f.FullName).Descendants("PackageReference")) + .Select(e => e.Attribute("Include")?.Value) + .Where(id => id is not null) + .Select(id => id!) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void TheNet10FloorIsDeclaredFirst() + { + var groups = Root().Elements("ItemGroup").ToList(); + var net10 = groups.FindIndex(IsFloor("net10.0")); + var net8 = groups.FindIndex(IsFloor("net8.0")); + + // Both have to be found first: a missing net10 group would leave -1, which is less than any + // index and would pass the order check while there is no floor to order. + net10.Should().BeGreaterThanOrEqualTo(0, "Directory.Packages.props should declare a floor gated on 'net10.0'"); + net8.Should().BeGreaterThanOrEqualTo(0, "Directory.Packages.props should declare a floor gated on 'net8.0'"); + net10.Should().BeLessThan(net8, + "Dependabot edits the first PackageVersion line it finds for an ID, so the net10 floor has to be the one it lands on rather than the net8 one"); + } + + [Fact] + public void TheNet10FloorIsPinnedThroughOneProperty() + { + FloorGroup("net10.0").Select(e => e.Attribute("Version")!.Value).Should().AllBe("$(MEVersion10)", + "the net10 family ships in lockstep, so a bump should have one line to change and no way to leave the group disagreeing"); + } + + private static Predicate IsFloor(string tfm) => + g => g.Attribute("Condition")?.Value.Contains($"'{tfm}'", StringComparison.Ordinal) == true; + private static IEnumerable FloorGroup(string tfm) { var group = Root().Elements("ItemGroup") @@ -60,9 +121,26 @@ private static IEnumerable FloorGroup(string tfm) } private static (string Id, string Version) PackageOf(XElement element) => - (element.Attribute("Include")!.Value, element.Attribute("Version")!.Value); + (element.Attribute("Include")!.Value, Resolve(element.Attribute("Version")!.Value)); + + /// A floor pinned through a property gives a bump one line to land on for the whole family. + private static string Resolve(string version) + { + if (!version.StartsWith("$(", StringComparison.Ordinal) || !version.EndsWith(')')) + { + return version; + } + + var name = version[2..^1]; + var value = Root().Elements("PropertyGroup").Elements(name).LastOrDefault()?.Value; + value.Should().NotBeNull($"'{version}' should resolve to a property declared in Directory.Packages.props"); + return value!; + } + + private static XElement Root() => + XDocument.Load(Path.Combine(RepositoryRoot().FullName, "Directory.Packages.props")).Root!; - private static XElement Root() + private static DirectoryInfo RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Directory.Packages.props"))) @@ -71,6 +149,6 @@ private static XElement Root() } directory.Should().NotBeNull("Directory.Packages.props should be findable by walking up from the test output directory"); - return XDocument.Load(Path.Combine(directory!.FullName, "Directory.Packages.props")).Root!; + return directory!; } }