diff --git a/readme.md b/readme.md
index 34cc8ed..a595d19 100644
--- a/readme.md
+++ b/readme.md
@@ -330,6 +330,51 @@ The next time you run the updater, it will update to the latest version.
- Comments and formatting around pinned packages are preserved during updates
+## Version Overrides
+
+
+### Overview
+
+Under Central Package Management, a project can reference a different version than the one declared centrally in `Directory.Packages.props` by adding a [`VersionOverride`](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management#overriding-package-versions) attribute to its `PackageReference`.
+
+PackageUpdate keeps these overrides up to date, independently of the central version. Each override is updated using the same rule applied to central versions:
+
+- A stable override is only moved to a newer stable version.
+- A pre-release override also considers pre-release versions.
+
+
+### Example
+
+A common use case is keeping the packable projects on the **stable** version of a dependency, while a test project tracks its **pre-release**.
+
+`Directory.Packages.props` stays on the stable:
+
+```xml
+
+```
+
+`Tests/Tests.csproj` tracks the pre-release:
+
+```xml
+
+```
+
+When a newer pre-release is published, only the override in the test project is advanced; the central stable entry is left untouched.
+
+Overrides that point at a stable version work the same way, and are moved to newer stable versions:
+
+```xml
+
+```
+
+
+### Behavior
+
+- `VersionOverride` entries are updated in all `*.csproj` files under each solution directory.
+- A `VersionOverride` is only skipped when the `PackageReference` itself has `Pinned="true"`. Pinning the central `PackageVersion` does not pin the override, so the deployed projects can stay fixed while a test project keeps floating.
+- The `--package` filter also applies to override updates.
+
+
## Automatic Package Migration
diff --git a/readme.source.md b/readme.source.md
index 955d546..0312d26 100644
--- a/readme.source.md
+++ b/readme.source.md
@@ -305,6 +305,51 @@ The next time you run the updater, it will update to the latest version.
- Comments and formatting around pinned packages are preserved during updates
+## Version Overrides
+
+
+### Overview
+
+Under Central Package Management, a project can reference a different version than the one declared centrally in `Directory.Packages.props` by adding a [`VersionOverride`](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management#overriding-package-versions) attribute to its `PackageReference`.
+
+PackageUpdate keeps these overrides up to date, independently of the central version. Each override is updated using the same rule applied to central versions:
+
+- A stable override is only moved to a newer stable version.
+- A pre-release override also considers pre-release versions.
+
+
+### Example
+
+A common use case is keeping the packable projects on the **stable** version of a dependency, while a test project tracks its **pre-release**.
+
+`Directory.Packages.props` stays on the stable:
+
+```xml
+
+```
+
+`Tests/Tests.csproj` tracks the pre-release:
+
+```xml
+
+```
+
+When a newer pre-release is published, only the override in the test project is advanced; the central stable entry is left untouched.
+
+Overrides that point at a stable version work the same way, and are moved to newer stable versions:
+
+```xml
+
+```
+
+
+### Behavior
+
+- `VersionOverride` entries are updated in all `*.csproj` files under each solution directory.
+- A `VersionOverride` is only skipped when the `PackageReference` itself has `Pinned="true"`. Pinning the central `PackageVersion` does not pin the override, so the deployed projects can stay fixed while a test project keeps floating.
+- The `--package` filter also applies to override updates.
+
+
## Automatic Package Migration
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index e4b4426..29e8ef4 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,7 +1,7 @@
- 4.2.0
+ 4.3.0
preview
NU1608
1.0.0
diff --git a/src/PackageUpdate/ProcessOutputReader.cs b/src/PackageUpdate/ProcessOutputReader.cs
index 9759199..fb603b2 100644
--- a/src/PackageUpdate/ProcessOutputReader.cs
+++ b/src/PackageUpdate/ProcessOutputReader.cs
@@ -9,6 +9,7 @@ public static async Task> ReadLines(this Process process)
{
continue;
}
+
list.Add(line);
}
diff --git a/src/PackageUpdate/Updater.cs b/src/PackageUpdate/Updater.cs
index 71f3a99..0f60743 100644
--- a/src/PackageUpdate/Updater.cs
+++ b/src/PackageUpdate/Updater.cs
@@ -110,6 +110,20 @@ public static async Task Update(
Log.Information("Updated {Package}: {NuGetVersion} -> {LatestVersion}", package.Package, currentVersion, latestVersion);
}
+ await SaveXml(directoryPackagesPropsPath, xml, newLine, hasTrailingNewline);
+
+ // Update PackageReference entries in csproj files for migrated packages
+ if (migrations.Count > 0)
+ {
+ await UpdateCsprojFiles(directory, migrations);
+ }
+
+ // Update VersionOverride entries in csproj files (e.g. test projects tracking a pre-release)
+ await UpdateVersionOverrides(directory, packageName, sources, cache);
+ }
+
+ static async Task SaveXml(string path, XDocument xml, string newLine, bool hasTrailingNewline)
+ {
var xmlSettings = new XmlWriterSettings
{
OmitXmlDeclaration = true,
@@ -120,7 +134,7 @@ public static async Task Update(
Async = true
};
- await using (var writer = XmlWriter.Create(directoryPackagesPropsPath, xmlSettings))
+ await using (var writer = XmlWriter.Create(path, xmlSettings))
{
await xml.SaveAsync(writer, Cancel.None);
}
@@ -128,13 +142,7 @@ public static async Task Update(
// Match the original trailing newline convention
if (hasTrailingNewline)
{
- await File.AppendAllTextAsync(directoryPackagesPropsPath, newLine);
- }
-
- // Update PackageReference entries in csproj files for migrated packages
- if (migrations.Count > 0)
- {
- await UpdateCsprojFiles(directory, migrations);
+ await File.AppendAllTextAsync(path, newLine);
}
}
@@ -362,25 +370,84 @@ static async Task UpdateCsprojFiles(string directory, List<(string OldPackage, s
if (updated)
{
- var xmlSettings = new XmlWriterSettings
+ await SaveXml(csprojPath, csprojXml, newLine, hasTrailingNewline);
+ }
+ }
+ }
+
+ static async Task UpdateVersionOverrides(
+ string directory,
+ string? packageName,
+ List sources,
+ SourceCacheContext cache)
+ {
+ foreach (var csprojPath in EnumerateCsprojFiles(directory))
+ {
+ var (newLine, hasTrailingNewline) = DetectNewLineInfo(csprojPath);
+ var csprojXml = XDocument.Load(csprojPath);
+
+ var overrides = csprojXml.Descendants("PackageReference")
+ .Select(element => new
+ {
+ Element = element,
+ Package = element.Attribute("Include")?.Value,
+ CurrentOverride = element.Attribute("VersionOverride")?.Value,
+ Pinned = element.Attribute("Pinned")?.Value == "true"
+ })
+ .Where(_ => _.Package != null &&
+ _.CurrentOverride != null &&
+ !_.Pinned)
+ .ToList();
+
+ var updated = false;
+
+ foreach (var packageOverride in overrides)
+ {
+ // Filter to specific package if requested
+ if (!string.IsNullOrEmpty(packageName) &&
+ !string.Equals(packageOverride.Package, packageName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (!NuGetVersion.TryParse(packageOverride.CurrentOverride, out var currentVersion))
{
- OmitXmlDeclaration = true,
- Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
- Indent = true,
- IndentChars = " ",
- NewLineChars = newLine,
- Async = true
- };
-
- await using (var writer = XmlWriter.Create(csprojPath, xmlSettings))
+ continue;
+ }
+
+ // An override follows the same rule as a central version: a stable override only moves
+ // to a newer stable, a pre-release override also considers pre-releases
+ var latestMetadata = await GetLatestVersion(
+ packageOverride.Package!,
+ currentVersion,
+ sources,
+ cache);
+
+ if (latestMetadata == null)
{
- await csprojXml.SaveAsync(writer, Cancel.None);
+ continue;
}
- if (hasTrailingNewline)
+ var latestVersion = latestMetadata.Identity.Version;
+
+ if (latestVersion <= currentVersion)
{
- await File.AppendAllTextAsync(csprojPath, newLine);
+ continue;
}
+
+ packageOverride.Element.SetAttributeValue("VersionOverride", latestVersion.ToString());
+ updated = true;
+ Log.Information(
+ "Updated {Package} VersionOverride: {NuGetVersion} -> {LatestVersion} in {File}",
+ packageOverride.Package,
+ currentVersion,
+ latestVersion,
+ Path.GetFileName(csprojPath));
+ }
+
+ if (updated)
+ {
+ await SaveXml(csprojPath, csprojXml, newLine, hasTrailingNewline);
}
}
}
diff --git a/src/PackageUpdate/nuget.md b/src/PackageUpdate/nuget.md
index a2586be..d067010 100644
--- a/src/PackageUpdate/nuget.md
+++ b/src/PackageUpdate/nuget.md
@@ -34,6 +34,7 @@ If no directory is passed the current directory will be used.
* Updates all packages across all solutions in a directory
* Respects `Pinned="true"` attribute to skip specific packages
+ * Updates `VersionOverride` entries in projects independently of the central version
* Automatically migrates deprecated packages to recommended alternatives
* Preserves file formatting (newlines, indentation)
* Queries all configured NuGet sources
diff --git a/src/Tests/UpdaterTests.cs b/src/Tests/UpdaterTests.cs
index 09cc6ef..1b9197b 100644
--- a/src/Tests/UpdaterTests.cs
+++ b/src/Tests/UpdaterTests.cs
@@ -1434,4 +1434,268 @@ public async Task ConcurrentRepositoryReaderAccessDoesNotThrow()
await Assert.That(metadataResource).IsNotNull();
}
}
+
+ static string nugetConfigOnlyOrg =
+ """
+
+
+
+
+
+
+
+ """;
+
+ static async Task<(string props, string csproj)> RunOverrideScenario(
+ string directoryPackages,
+ string csproj,
+ string? package = null)
+ {
+ using var cache = new SourceCacheContext
+ {
+ RefreshMemoryCache = true
+ };
+
+ using var directory = new TempDirectory();
+ var nugetConfigPath = Path.Combine(directory, "nuget.config");
+ var packagesPath = Path.Combine(directory, "Directory.Packages.props");
+ var csprojPath = Path.Combine(directory, "Tests.csproj");
+
+ await File.WriteAllTextAsync(nugetConfigPath, nugetConfigOnlyOrg);
+ await File.WriteAllTextAsync(packagesPath, directoryPackages);
+ await File.WriteAllTextAsync(csprojPath, csproj);
+
+ await Updater.Update(cache, packagesPath, package);
+
+ return (
+ await File.ReadAllTextAsync(packagesPath),
+ await File.ReadAllTextAsync(csprojPath));
+ }
+
+ static string? OverrideOf(string csproj, string package) =>
+ XDocument.Parse(csproj)
+ .Descendants("PackageReference")
+ .FirstOrDefault(_ => string.Equals(_.Attribute("Include")?.Value, package, StringComparison.OrdinalIgnoreCase))
+ ?.Attribute("VersionOverride")?.Value;
+
+ [Test]
+ public async Task UpdatesVersionOverrideInCsproj()
+ {
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+ """;
+
+ var (props, csprojResult) = await RunOverrideScenario(directoryPackages, csproj);
+
+ // The VersionOverride in the csproj should have moved forward
+ var updatedOverride = OverrideOf(csprojResult, "Newtonsoft.Json");
+ await Assert.That(updatedOverride).IsNotEqualTo("12.0.1");
+ await Assert.That(NuGetVersion.TryParse(updatedOverride, out var overrideVersion)).IsTrue();
+ await Assert.That(overrideVersion! > NuGetVersion.Parse("12.0.1")).IsTrue();
+
+ // A stable override must stay stable, never jumping to a pre-release
+ await Assert.That(overrideVersion!.IsPrerelease).IsFalse();
+
+ // The central stable version must stay stable
+ var centralVersion = XDocument.Parse(props)
+ .Descendants("PackageVersion")
+ .Single(_ => _.Attribute("Include")?.Value == "Newtonsoft.Json")
+ .Attribute("Version")!.Value;
+ await Assert.That(NuGetVersion.Parse(centralVersion).IsPrerelease).IsFalse();
+ }
+
+ [Test]
+ public async Task VersionOverridePreReleaseMovesForward()
+ {
+ // Central stays on a stable, the test csproj tracks the pre-release via VersionOverride
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+ """;
+
+ var (_, csprojResult) = await RunOverrideScenario(directoryPackages, csproj);
+
+ var updatedOverride = OverrideOf(csprojResult, "Verify");
+ await Assert.That(updatedOverride).IsNotEqualTo("1.0.0-beta.1");
+ await Assert.That(NuGetVersion.TryParse(updatedOverride, out var overrideVersion)).IsTrue();
+ await Assert.That(overrideVersion! > NuGetVersion.Parse("1.0.0-beta.1")).IsTrue();
+ }
+
+ [Test]
+ public async Task VersionOverrideRespectsPackageFilter()
+ {
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+
+ """;
+
+ var (_, csprojResult) = await RunOverrideScenario(directoryPackages, csproj, "Newtonsoft.Json");
+
+ // Targeted package override moves
+ await Assert.That(OverrideOf(csprojResult, "Newtonsoft.Json")).IsNotEqualTo("12.0.1");
+
+ // Non-targeted package override is left untouched
+ await Assert.That(OverrideOf(csprojResult, "Serilog")).IsEqualTo("2.0.0");
+ }
+
+ [Test]
+ public async Task PackageReferenceWithoutVersionOverrideUnchanged()
+ {
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+ """;
+
+ var (_, csprojResult) = await RunOverrideScenario(directoryPackages, csproj);
+
+ // A plain PackageReference must never gain a VersionOverride or Version attribute
+ await Assert.That(csprojResult).DoesNotContain("VersionOverride");
+ await Assert.That(csprojResult).DoesNotContain("Version=");
+ }
+
+ [Test]
+ public async Task VersionOverridePreservesCsprojFormatting()
+ {
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+
+
+ """;
+
+ var (_, csprojResult) = await RunOverrideScenario(directoryPackages, csproj);
+
+ await Assert.That(csprojResult).Contains("");
+ await Assert.That(csprojResult).Contains("");
+ await Assert.That(OverrideOf(csprojResult, "Newtonsoft.Json")).IsNotEqualTo("12.0.1");
+ }
+
+ [Test]
+ public async Task PinnedVersionOverrideNotUpdated()
+ {
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+ """;
+
+ var (_, csprojResult) = await RunOverrideScenario(directoryPackages, csproj);
+
+ // The override itself is pinned, so it must not move
+ await Assert.That(OverrideOf(csprojResult, "Newtonsoft.Json")).IsEqualTo("12.0.1");
+ await Assert.That(csprojResult).Contains("Pinned=\"true\"");
+ }
+
+ [Test]
+ public async Task CentralPinDoesNotPinVersionOverride()
+ {
+ var directoryPackages =
+ """
+
+
+
+
+
+ """;
+
+ var csproj =
+ """
+
+
+
+
+
+ """;
+
+ var (props, csprojResult) = await RunOverrideScenario(directoryPackages, csproj);
+
+ // The central pin keeps the central version fixed
+ var centralVersion = XDocument.Parse(props)
+ .Descendants("PackageVersion")
+ .Single(_ => _.Attribute("Include")?.Value == "Newtonsoft.Json")
+ .Attribute("Version")!.Value;
+ await Assert.That(centralVersion).IsEqualTo("12.0.1");
+
+ // But the override is only pinned by its own Pinned attribute, so it still advances
+ await Assert.That(OverrideOf(csprojResult, "Newtonsoft.Json")).IsNotEqualTo("12.0.1");
+ }
}
\ No newline at end of file