Use upper-bound version ranges for sibling package dependencies#4337
Use upper-bound version ranges for sibling package dependencies#4337paulmedynski wants to merge 3 commits into
Conversation
Compute [floor, ceiling) ranges for sibling packages so NuGet cannot resolve an incompatible newer minor/major version at restore time. - Directory.Packages.props: derive *VersionCeiling from *PackageVersion; apply ranges to sibling PackageVersion items; add SniVersionRange - SqlClient csproj: compute version range properties for nuspec expansion - SqlClient nuspec: replace bare version tokens with range tokens
There was a problem hiding this comment.
Pull request overview
This PR changes how Microsoft.Data.SqlClient expresses dependency versions by computing [floor, ceiling) version ranges (ceiling = next minor) for sibling packages and SNI, and emitting those ranges into the produced .nuspec/.nupkg.
Changes:
- Compute “next-minor” ceilings from existing
*PackageVersion/SniVersionvalues and form[floor, ceiling)ranges. - Apply those ranges in Central Package Management (
Directory.Packages.props) for sibling package dependencies (Package mode) and SNI. - Update the SqlClient pack flow to materialize dependency range tokens into the generated nuspec.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| Directory.Packages.props | Adds MSBuild-derived ceiling/range properties and applies [floor, ceiling) ranges to sibling package PackageVersions and SNI. |
| src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj | Computes version ranges during nuspec materialization and substitutes new range tokens. |
| src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.nuspec | Replaces dependency version tokens with range tokens for sibling packages and SNI. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4337 +/- ##
==========================================
- Coverage 66.69% 64.53% -2.17%
==========================================
Files 284 279 -5
Lines 43238 66069 +22831
==========================================
+ Hits 28836 42635 +13799
- Misses 14402 23434 +9032
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| --> | ||
| <PropertyGroup> | ||
| <!-- SNI version (external package, version declared once here) --> | ||
| <SniVersion>6.0.2</SniVersion> |
There was a problem hiding this comment.
I decided to declare the SNI version once here rather than here and in the SqlClient nuspec. It's a pseudo-sibling package, so I felt if warranted this special treatement.
The CI/PR pack jobs for Abstractions and Azure extensions were not passing ReferenceType=Package to dotnet pack, causing the projects to use ProjectReference mode for sibling dependencies. NuGet converts ProjectReferences to bare version dependencies (no ranges), which defeats the upper-bound version ranges defined in Directory.Packages.props. Fix: - pack-abstractions-package-ci-job: Add referenceType, loggingArtifactsName, and loggingPackageVersion parameters. When referenceType=Package, download Logging artifacts and pass ReferenceType + LoggingPackageVersion to dotnet pack buildProperties. - pack-azure-package-ci-job: Add loggingPackageVersion parameter. When referenceType=Package, pass ReferenceType + dependency versions to dotnet pack buildProperties. - Wire the new parameters through build-abstractions-package-ci-stage.yml and dotnet-sqlclient-ci-core.yml.
| - detailed | ||
| - diagnostic | ||
|
|
||
| # The name of the Logging pipeline artifacts to download. |
There was a problem hiding this comment.
The Abstractions and Azure build/pack CI jobs were missing some sibling package versions, and weren't using package-mode properly.
Address Copilot review feedback: guard against whitespace in version
values passed via -p: arguments by trimming before splitting.
- Directory.Packages.props: Add .Trim() before .Split('.') on all
*PackageVersion properties used in ceiling computation.
- Microsoft.Data.SqlClient.csproj: Use the already-computed
_*PackageVersionTrimmed properties as inputs to the range strings
instead of the raw *PackageVersion values.
| <!-- | ||
| Compute version ceilings for sibling packages. The ceiling is the next minor version | ||
| (prerelease suffix stripped) derived from the *floor* (XxxPackageVersion), giving a range | ||
| like [1.1.0-preview1-ci123, 1.2.0). This prevents NuGet from resolving an incompatible |
There was a problem hiding this comment.
The ceiling should be the next major version that can contain breaking changes.
Per SemVer versioning semantics, a minor release is never expected to contain breaking changes for any consumers, and should be backwards-compatible with consuming applications if customers happen to upgrade.
There was a problem hiding this comment.
Yes I thought about that, but do we actually want to allow dependency across the minor version boundary? Do we intend to release Abstractions 1.1.0 without releasing Azure 1.1.0, for example?
This is a policy question:
- Do we set our policy such that we release all siblings as minor version bumps together, and only let their patch versions diverge?
- Your suggestion implies we already must release all siblings as major version bumps together, even if some of them don't contain any major-worthy changes.
There was a problem hiding this comment.
I'm actually thinking that we don't need to set upper bounds at all. What is the actual use case for explicitly limiting? Why is our suite special in a way that requires upper bounds when the ecosystem discourages them? When nuget goes to resolve the dependency graph, it will already select the lowest version that satisfies all constraints in the graph. This means that as long as we continue to set exact lower bounds, customers will not get even minor version bumps of sibling packages except in certain special situations mentioned below.
Consider:
App {
MDS 7.0.0 {
Abstractions >= 1.0.0
}
}
If we publish Abstractions 1.1.0 (or even 2.0.0), the app will still resolve to 1.0.0.
The only ways a customer could get an incompatible version of a sibling package is:
- If they take a direct dependency on an incompatible version
- Customers will have to directly depend on extension packages to e.g. implement their own auth provider. But if we version all of our packages together, it's easy to keep your MDS and extension versions in sync.
- Still requires an explicit action by the app maintainer. They won't suddenly start resolving a new version just because we released one (unless they use float versions, which is an at-your-own-risk option).
(Can expose customers to behavior changes or runtime errors)
App {
MDS 7.0.0 {
Abstractions 1.0.0
}
Abstractions 2.0.0
}
- They take a dependency on another library, which takes a dependency on an incompatible version
- Maybe someone needs both EFCore and SqlClient in the same repo? It feels unlikely. But in that case, EFCore would also be bringing in a later MDS version and the customer would get a downgrade warning for MDS.
- To see an issue, would require a library that directly references an extensions package (Abstractions/Logging/etc.)
(gives a nuget warning that direct MDS 7.0.0 dependency is a downgrade from 8.0.0)
App {
MDS 7.0.0 {
Abstractions 1.0.0
}
EFCore {
MDS 8.0.0 {
Abstractions 2.0.0
}
}
}
(can fail at runtime)
App {
MDS 7.0.0 {
Abstractions 1.0.0
}
OtherLibrary 1.0.0 {
Abstractions 2.0.0
}
}
The core of my point is that our risks aren't special. They're the same risks any other library has. The ecosystem puts the burden on users to safely manage their package versions. In exchange, it's almost always possible to resolve the package graph without irreconcilable version constraints. Avoiding upper bounds maintains a safety hatch for customers. In case they want/need to take a higher version of a sibling package, they can do so at their own risk.
There was a problem hiding this comment.
I agree with @mdaigle - why is MDS special from any other nuget package, and constraining is highly discouraged
There was a problem hiding this comment.
I did a little more digging myself:
Industry norm: bare minimum versions (1.2.3 meaning >= 1.2.3). This is what the vast majority of NuGet packages ship with, including most Microsoft packages. The reasoning:
- NuGet's resolver picks the lowest applicable version, so in practice you usually get exactly what was specified.
- It gives consumers flexibility to unify transitive versions without conflicts.
- SemVer is trusted to mean minor/patch bumps won't break you.
Bounded ranges are rare but recommended for tightly-coupled packages. Microsoft's own guidance (the NuGet docs on version ranges) says:
| Pattern | Use case |
|---|---|
| 1.2.3 (bare) | General dependencies — trust SemVer |
| [1.2.3, 2.0.0) | Tightly coupled siblings or plugins where a major bump is breaking |
| [1.2.3] (exact) | Almost never — too restrictive, causes diamond-dependency conflicts |
Who uses bounded ranges in practice:
- ASP.NET Core shared framework packages use [major.minor.*, next-major.0.0) for their internal sibling refs.
- Azure SDK uses bounded ranges between tightly-versioned sibling libs.
- EF Core bounds its internal package references.
We're definitely in the "tightly coupled sibling" case, but does that mean we absolutely need upper bounds? Our EF Core friends use it, so perhaps a discussion with them is warranted.
There was a problem hiding this comment.
"Tightly coupled siblings or plugins where a major bump is breaking" - interesting!
Compute
[floor, ceiling)ranges for sibling packages so NuGet cannot resolve an incompatible newer minor/major version at restore time.Changes
*VersionCeilingfrom*PackageVersionusing MSBuild property functions; apply[floor, ceiling)ranges to all siblingPackageVersionitems; add centralizedSniVersion/SniVersionRangeproperties.$AbstractionsVersionRange$,$LoggingVersionRange$,$SqlServerVersionRange$,$SniVersionRange$).How it works
The ceiling is the next minor version (prerelease suffix stripped) derived from the floor:
This is self-contained — no separate "next version" lookup is needed. The range prevents NuGet from pulling in a newer minor or major version that could be binary-incompatible.
Pipeline Verification
Reviewers should inspect the NuGet package artifacts produced by the above runs to confirm that the sibling dependencies specify the expected version ranges.
Manual Verification
dotnet build(direct, net8.0/net9.0) — Project mode ✓, Package mode ✓build.proj -t:Build— Project mode ✓, Package mode ✓build.proj -t:Pack— Project mode ✓, Package mode ✓.nupkgfiles contain correct[floor, ceiling)dependency ranges for all sibling packages and SNI