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
8 changes: 6 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 31 additions & 15 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,34 @@
<xUnit>4.0.0</xUnit>
<AutoFixture>4.18.1</AutoFixture>
<CloudEvents>2.9.0</CloudEvents>
<!-- The whole net10 dependency floor, in one place: the family ships in lockstep, and a bump
has a single line to change instead of eleven that must agree. -->
<MEVersion10>10.0.12</MEVersion10>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="MessagePack" Version="3.1.8" />
</ItemGroup>
<!-- Per-TFM dependency floors: keep both groups in sync ID-for-ID, each on its own major. -->
<!-- Per-TFM dependency floors: keep both groups in sync ID-for-ID, each on its own major.
Order matters. Dependabot does not evaluate the conditions, so it sees one version per ID and
edits the first PackageVersion line it finds for it: the net10 group is declared first so that
line is the one it can have. Its versions come from $(MEVersion10), which is also the only line
a family bump needs to touch. The net8 group below is the floor for net8.0 consumers and is
moved by hand. What keeps a 10.x patch off it is this order, not the semver-major ignore in
.github/dependabot.yml: that rule never sees a 10.x patch as major, and only blocks an outright
8.x to 10.x proposal. PackageVersionFloorTests guards the order, the property and both majors. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Primitives" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(MEVersion10)" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="$(MEVersion10)" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
Expand All @@ -23,21 +46,14 @@
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.30" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Primitives" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<!-- The one Microsoft.Extensions.* package deliberately not split per TFM, so it is the net8
floor for this one package too. StackExchange.Redis 3.x declares it >= 10.0.5 in every
dependency group including net8.0, so an 8.0.x floor here cannot restore (NU1605); 10.x
ships a net8.0 asset, so a net8.0 consumer still resolves a net8-targeted assembly rather
than a downlevel one. Raised on purpose in #85 as the prerequisite for SE.Redis 3.0, and it
still has to move with the family: 10.0.12 of any of them asks for 10.0.12 of this. -->
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.5.3" />
Expand Down
4 changes: 3 additions & 1 deletion src/UiPath.Caching.Azure/UiPath.Caching.Azure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
<PackageTags>UiPath;Caching;Azure;Entra;Redis</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" VersionOverride="10.0.11" />
<!-- Tracks $(MEVersion10) rather than repeating it: this override has to move with the net10
floor or restore fails NU1605 against it, and hand-aligning it is what broke in #134. -->
<PackageReference Include="Microsoft.Extensions.Options" VersionOverride="$(MEVersion10)" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Azure.StackExchangeRedis" />
Expand Down
84 changes: 81 additions & 3 deletions tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

/// <summary>
/// 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.
/// </summary>
[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<string> 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<XElement> IsFloor(string tfm) =>
g => g.Attribute("Condition")?.Value.Contains($"'{tfm}'", StringComparison.Ordinal) == true;

private static IEnumerable<XElement> FloorGroup(string tfm)
{
var group = Root().Elements("ItemGroup")
Expand All @@ -60,9 +121,26 @@ private static IEnumerable<XElement> 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));

/// <summary>A floor pinned through a property gives a bump one line to land on for the whole family.</summary>
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")))
Expand All @@ -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!;
}
}