From aa7caaaf6b461493abd57e7db9725228691fb3e9 Mon Sep 17 00:00:00 2001 From: DarylBagley Date: Thu, 20 Aug 2026 16:11:30 -0700 Subject: [PATCH] Add NuGet publish cooldown rules Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- conventions/update-nuget-packages/README.md | 7 +++- .../convention.Tests.ps1 | 38 +++++++++++++++++++ .../update-nuget-packages/convention.cs | 32 ++++++++++++++-- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/conventions/update-nuget-packages/README.md b/conventions/update-nuget-packages/README.md index 32a8b2d..c6a25d3 100644 --- a/conventions/update-nuget-packages/README.md +++ b/conventions/update-nuget-packages/README.md @@ -12,6 +12,7 @@ Rule properties: - `version`: Optional string, default `update-major`. Valid policy values are `update-major`, `update-minor`, `update-patch`, and `no-update`. A specific version such as `7.0.0` updates only to that exact version. A NuGet version range such as `[7.0.0, 8.0.0)` updates only to versions inside that range. - `include-prerelease`: Optional boolean, default `false`. When true, prerelease candidates are eligible. - `prerelease-channel`: Optional string. When set, candidates must be prerelease versions that use the specified prerelease label. This setting takes precedence over `include-prerelease`. +- `publish-cooldown`: Optional string, default `weekly`. `weekly` only permits versions published on or before the Tuesday before the last Tuesday. `none` permits the newest eligible version regardless of publish date. Use this for package ID patterns owned and validated by your organization. ## Behavior @@ -19,7 +20,7 @@ The convention requires the target directory to be inside a git worktree and onl Package metadata is resolved from enabled NuGet package sources configured for the repository, including sources inherited from `nuget.config`. Non-HTTP package sources such as local folders are supported; because those sources use local file timestamps rather than package publish timestamps, their versions are treated as old enough for the publish-date cutoff. -Only versions published on or before the Tuesday before the last Tuesday are eligible. This gives newly published packages at least one full week before the convention can select them. +By default, only versions published on or before the Tuesday before the last Tuesday are eligible. This gives newly published packages at least one full week before the convention can select them. A matching rule can set `publish-cooldown: none` for some packages while leaving the default cooldown in place for others, such as third-party packages. The convention leaves package reference wildcard versions, package reference version ranges, computed MSBuild expressions, and unsupported XML shapes unchanged. Same-file property expressions such as `Version="$(PackageVersion)"` are updated when the property has a single literal definition in the same file. @@ -42,4 +43,8 @@ conventions: version: '[8.0.0, 9.0.0)' - packages: StackExchange.Redis prerelease-channel: faithlife + - packages: + - Faithlife.* + - Logos.* + publish-cooldown: none ``` diff --git a/conventions/update-nuget-packages/convention.Tests.ps1 b/conventions/update-nuget-packages/convention.Tests.ps1 index af00647..2f18b32 100644 --- a/conventions/update-nuget-packages/convention.Tests.ps1 +++ b/conventions/update-nuget-packages/convention.Tests.ps1 @@ -371,6 +371,44 @@ Describe 'update-nuget-packages convention' { } } + It 'allows matching package rules to skip the publish cooldown' { + $testDirectory = New-TemporaryDirectory + + try { + Initialize-TestRepository -Path $testDirectory + $projectPath = Join-Path $testDirectory 'App.csproj' + WriteTestFile -Path $projectPath -Content @' + + + + + + +'@ + + AddAndCommitAll -TestDirectory $testDirectory -Message 'Add project file' + $metadataPath = WriteMetadataFile -Packages @{ + 'Faithlife.Package' = @(@{ version = '2.0.0'; publishedUtc = '2026-05-20T00:00:00Z'; listed = $true }) + 'ThirdParty.Package' = @(@{ version = '2.0.0'; publishedUtc = '2026-05-20T00:00:00Z'; listed = $true }) + } + + InvokeUpdateNugetPackagesConvention -TestDirectory $testDirectory -Settings @{ + 'test-package-metadata-file' = $metadataPath + 'now-utc' = '2026-05-27T12:00:00Z' + rules = @( + @{ packages = 'Faithlife.*'; 'publish-cooldown' = 'none' } + ) + } | Out-Null + + $content = Get-Content -LiteralPath $projectPath -Raw + $content | Should -Match 'Include="Faithlife\.Package" Version="2\.0\.0"' + $content | Should -Match 'Include="ThirdParty\.Package" Version="1\.0\.0"' + } + finally { + Remove-Item -LiteralPath $testDirectory -Recurse -Force + } + } + It 'updates same-file properties and MSBuild SDK references' { $testDirectory = New-TemporaryDirectory diff --git a/conventions/update-nuget-packages/convention.cs b/conventions/update-nuget-packages/convention.cs index fd95502..bbcf393 100644 --- a/conventions/update-nuget-packages/convention.cs +++ b/conventions/update-nuget-packages/convention.cs @@ -193,6 +193,9 @@ public EffectiveRule GetEffectiveRule(string packageId) if (rule.PrereleaseChannel is not null) effectiveRule.PrereleaseChannel = rule.PrereleaseChannel; + + if (rule.PublishCooldownPolicy.HasValue) + effectiveRule.PublishCooldownPolicy = rule.PublishCooldownPolicy.Value; } return effectiveRule; @@ -216,6 +219,7 @@ internal sealed class Rule public VersionRange? AllowedVersionRange { get; } public bool? IncludePrerelease { get; } public string? PrereleaseChannel { get; } + public PublishCooldown? PublishCooldownPolicy { get; } public static Rule Parse(JsonElement element) { @@ -286,7 +290,21 @@ public static Rule Parse(JsonElement element) prereleaseChannel = prereleaseChannelElement.GetString(); } - return new Rule(packages, hasVersion, versionPolicy, exactVersion, allowedVersionRange, includePrerelease, prereleaseChannel); + PublishCooldown? publishCooldown = null; + if (element.TryGetProperty("publish-cooldown", out var publishCooldownElement)) + { + if (publishCooldownElement.ValueKind != JsonValueKind.String) + throw new InvalidOperationException("Rule 'publish-cooldown' must be a string."); + + publishCooldown = publishCooldownElement.GetString() switch + { + "weekly" => PublishCooldown.Weekly, + "none" => PublishCooldown.None, + _ => throw new InvalidOperationException("Rule 'publish-cooldown' must be 'weekly' or 'none'.") + }; + } + + return new Rule(packages, hasVersion, versionPolicy, exactVersion, allowedVersionRange, includePrerelease, prereleaseChannel, publishCooldown); } public bool IsMatch(string packageId) => m_packagePatterns.Any(pattern => pattern.IsMatch(packageId)); @@ -319,7 +337,7 @@ private static Regex CreateWildcardRegex(string pattern) return new Regex(regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } - private Rule(List packages, bool hasVersion, VersionPolicy versionPolicy, NuGetVersion? exactVersion, VersionRange? allowedVersionRange, bool? includePrerelease, string? prereleaseChannel) + private Rule(List packages, bool hasVersion, VersionPolicy versionPolicy, NuGetVersion? exactVersion, VersionRange? allowedVersionRange, bool? includePrerelease, string? prereleaseChannel, PublishCooldown? publishCooldown) { m_packagePatterns = packages.Select(CreateWildcardRegex).ToList(); HasVersion = hasVersion; @@ -328,6 +346,7 @@ private Rule(List packages, bool hasVersion, VersionPolicy versionPolicy AllowedVersionRange = allowedVersionRange; IncludePrerelease = includePrerelease; PrereleaseChannel = prereleaseChannel; + PublishCooldownPolicy = publishCooldown; } private readonly List m_packagePatterns; @@ -340,6 +359,13 @@ internal sealed class EffectiveRule public VersionRange? AllowedVersionRange { get; set; } public bool IncludePrerelease { get; set; } public string? PrereleaseChannel { get; set; } + public PublishCooldown PublishCooldownPolicy { get; set; } = PublishCooldown.Weekly; +} + +internal enum PublishCooldown +{ + Weekly, + None } internal enum VersionPolicy @@ -452,7 +478,7 @@ internal static class VersionResolver { var filteredCandidates = candidates .Where(candidate => candidate.Listed) - .Where(candidate => candidate.PublishedUtc.HasValue && candidate.PublishedUtc.Value <= cutoffUtc) + .Where(candidate => rule.PublishCooldownPolicy == PublishCooldown.None || (candidate.PublishedUtc.HasValue && candidate.PublishedUtc.Value <= cutoffUtc)) .Where(candidate => candidate.Version.CompareTo(currentVersion) > 0) .Where(candidate => IsPrereleaseAllowed(candidate.Version, rule)) .Where(candidate => IsAllowedByPolicy(candidate.Version, currentVersion, rule))