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!;
}
}