From 728b5f58a43bbefc7353170dfc8e185915971245 Mon Sep 17 00:00:00 2001 From: Cosmin Staicu Date: Thu, 10 Sep 2026 15:17:48 +0300 Subject: [PATCH] chore(deps): give a Microsoft.Extensions bump one line it can land on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Dependabot PRs this week each rewrote the net8.0 floor to 10.0.12 while their titles said 10.0.11 to 10.0.12. Dependabot does not evaluate the TargetFramework conditions, so it reads one version per package ID, takes the highest (the net10 one), and edits the first PackageVersion line it finds for that ID. The net8 group was declared first, so that is the line it got, and a 10.x patch never looked major to the semver-major ignore that was supposed to stop it. PackageVersionFloorTests caught all five in build-linux, which is the only reason none landed. #134 was the same bug a few weeks earlier. The net10 group is now declared first, so the line Dependabot reaches is the one it may have, and its versions come from $(MEVersion10): the family ships in lockstep, so a bump has one line to change rather than eleven that have to agree. The net8 floor keeps its literals below, hand-managed, each package on its own 8.x patch — they are not uniform, which is why a single $(MEVersion8) would not express them. Bumping to 10.0.12 showed the version had two more homes. The family asks for its own version of Microsoft.Extensions.Logging.Abstractions, pinned unconditionally, so leaving that behind failed restore with NU1605. UiPath.Caching.Azure carries a VersionOverride on Options that has to move with the floor too; #134 aligned it by hand, which is exactly what broke this time, so it now reads $(MEVersion10) instead of repeating the number. Logging.Abstractions stays unconditional. StackExchange.Redis 3.x declares it >= 10.0.5 in every dependency group, net8.0 included, so an 8.0.x floor for it cannot restore, and 10.x ships a net8.0 asset so a net8.0 consumer still resolves a net8-targeted assembly. That was decided in #85 and the reason lived only in its commit message, which was long enough ago that reading the props file, the floor tests and the Dependabot config left me calling the pin a hole in the policy. It now sits next to the pin. Everything load-bearing here is guarded, and each guard was checked by making the mistake it exists for: - TheNet10FloorIsDeclaredFirst pins the order, and asserts both groups were found: a missing net10 group leaves -1, which is less than any index and would otherwise pass with no floor to order. - TheNet10FloorIsPinnedThroughOneProperty pins the property. - FloorGroupStaysOnItsOwnMajor resolves a property reference before judging the major. - EveryShippedExtensionsPackageIsFlooredExceptLoggingAbstractions keeps the exception to one package. It reads the shipped projects rather than the props file, because a floor only reaches a consumer through a published dependency group: Http.Resilience and ServiceDiscovery are pinned unconditionally too and belong to nobody's, since only the Aspire sample references them. Published floors verified against the packed nuspec: net10.0 asks for 10.0.12 across the family, net8.0 for 8.0.1 / 8.0.2 / 8.0.30 as before, with Logging.Abstractions at 10.0.12 by design. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu --- .github/dependabot.yml | 8 +- Directory.Packages.props | 46 ++++++---- .../UiPath.Caching.Azure.csproj | 4 +- .../PackageVersionFloorTests.cs | 84 ++++++++++++++++++- 4 files changed, 121 insertions(+), 21 deletions(-) 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!; } }