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