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
1 change: 1 addition & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ Package WindowsAzure.Storage is deprecated with alternative Azure.Storage.Common
- Respects pre-release semantics (stable → stable, pre-release → any)
- Queries all configured NuGet sources and selects highest version
- Skips unlisted packages
- Respects the `minPublishAgeHours` attribute on a `packageSources` entry in nuget.config (set via `dotnet nuget add/update source --min-publish-age-hours`): versions published more recently than that are skipped for that source. `PackageSourceReader` reads the attribute from the config files directly, since `SourceItem` drops unknown attributes

### File Format Preservation

Expand Down
21 changes: 20 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!--
<!--
GENERATED FILE - DO NOT EDIT
This file was generated by [MarkdownSnippets](https://github.com/SimonCropp/MarkdownSnippets).
Source File: /readme.source.md
Expand Down Expand Up @@ -176,6 +176,25 @@ To use authenticated feed, add the [packageSourceCredentials](https://docs.micro
```


## Minimum package publication age

Sources can define a cooldown via the `minPublishAgeHours` attribute in `nuget.config`. Versions published more recently than that are not selected from that source.

This mirrors the NuGet [minimum package publication age](https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/rc2/nuget.md#configure-minimum-package-publication-age-from-the-cli) feature.

```xml
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" minPublishAgeHours="24" />
</packageSources>
```

This can be set via the CLI (.NET 11 SDK):

```
dotnet nuget update source nuget.org --min-publish-age-hours 24
```


## Package Version Pinning


Expand Down
21 changes: 20 additions & 1 deletion readme.source.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# <img src="/src/icon.png" height="30px"> PackageUpdate
# <img src="/src/icon.png" height="30px"> PackageUpdate

[![Build status](https://github.com/SimonCropp/PackageUpdate/actions/workflows/build.yml/badge.svg)](https://github.com/SimonCropp/PackageUpdate/actions/workflows/build.yml)
[![NuGet Status](https://img.shields.io/nuget/v/PackageUpdate.svg)](https://www.nuget.org/packages/PackageUpdate/)
Expand Down Expand Up @@ -151,6 +151,25 @@ To use authenticated feed, add the [packageSourceCredentials](https://docs.micro
```


## Minimum package publication age

Sources can define a cooldown via the `minPublishAgeHours` attribute in `nuget.config`. Versions published more recently than that are not selected from that source.

This mirrors the NuGet [minimum package publication age](https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/rc2/nuget.md#configure-minimum-package-publication-age-from-the-cli) feature.

```xml
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" minPublishAgeHours="24" />
</packageSources>
```

This can be set via the CLI (.NET 11 SDK):

```
dotnet nuget update source nuget.org --min-publish-age-hours 24
```


## Package Version Pinning


Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
<PackageVersion Include="EmptyFiles" Version="8.20.1" />
<PackageVersion Include="SimpleInfoName" Version="3.2.1" />
<PackageVersion Include="Verify" Version="33.3.3" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
</ItemGroup>
</Project>
4 changes: 3 additions & 1 deletion src/PackageUpdate/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@
global using NuGet.Versioning;
global using Serilog;
global using Serilog.Events;
global using Serilog.Parsing;
global using Serilog.Parsing;
global using System.Globalization;
global using System.Runtime.CompilerServices;
84 changes: 81 additions & 3 deletions src/PackageUpdate/PackageSourceReader.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
public static class PackageSourceReader
public static class PackageSourceReader
{
static XPlatMachineWideSetting machineSettings = new();
static ConditionalWeakTable<PackageSource, StrongBox<TimeSpan>> minPublishAges = new();

public static List<PackageSource> Read(string directory)
{
Expand All @@ -11,8 +12,85 @@ public static List<PackageSource> Read(string directory)
machineWideSettings: machineSettings);

var provider = new PackageSourceProvider(settings);
return provider.LoadPackageSources()
var sources = provider.LoadPackageSources()
.Where(_ => _.IsEnabled)
.ToList();

var ages = ReadMinPublishAges(settings);
foreach (var source in sources)
{
if (ages.TryGetValue(source.Name, out var age))
{
minPublishAges.AddOrUpdate(source, new(age));
}
}

return sources;
}

// Reads the minPublishAgeHours attribute from packageSources entries.
// https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/rc2/nuget.md#configure-minimum-package-publication-age-from-the-cli
// NuGet.Configuration drops unknown attributes from SourceItem, so the config files are read directly.
// GetConfigFilePaths is ordered closest first, so apply in reverse so closer files win.
static Dictionary<string, TimeSpan> ReadMinPublishAges(ISettings settings)
{
var ages = new Dictionary<string, TimeSpan>(StringComparer.OrdinalIgnoreCase);
foreach (var path in settings.GetConfigFilePaths().Reverse())
{
XElement? section;
try
{
section = XDocument.Load(path).Root?.Element("packageSources");
}
catch (Exception exception) when (exception is IOException or XmlException or UnauthorizedAccessException)
{
continue;
}

if (section == null)
{
continue;
}

foreach (var element in section.Elements())
{
if (element.Name.LocalName == "clear")
{
ages.Clear();
continue;
}

var key = element.Attribute("key")?.Value;
if (element.Name.LocalName != "add" || key == null)
{
continue;
}

var value = element.Attribute("minPublishAgeHours")?.Value;
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var hours) &&
hours > 0)
{
ages[key] = TimeSpan.FromHours(hours);
continue;
}

ages.Remove(key);
}
}

return ages;
}

public static void SetMinPublishAge(PackageSource source, TimeSpan age) =>
minPublishAges.AddOrUpdate(source, new(age));

public static TimeSpan? GetMinPublishAge(PackageSource source)
{
if (minPublishAges.TryGetValue(source, out var age))
{
return age.Value;
}

return null;
}
}
}
22 changes: 22 additions & 0 deletions src/PackageUpdate/Updater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public static async Task Update(
var (repository, metadataResource) = await RepositoryReader.Read(source);

var condidates = await GetCondidates(package, currentVersion, cache, repository);
var minPublishAge = PackageSourceReader.GetMinPublishAge(source);

foreach (var candidate in condidates)
{
Expand All @@ -156,6 +157,17 @@ public static async Task Update(
continue;
}

// Skip versions newer than the source's minPublishAgeHours cooldown
if (!IsOldEnough(metadata, minPublishAge))
{
Log.Information(
"Skipped {Package} {Version} from {Source}: published within minPublishAgeHours",
package,
candidate,
source.Name);
continue;
}

// Found a listed version - check if it's better than what we have
if (latestMetadata == null ||
candidate > latestMetadata.Identity.Version)
Expand All @@ -171,6 +183,16 @@ public static async Task Update(
return latestMetadata;
}

static bool IsOldEnough(IPackageSearchMetadata metadata, TimeSpan? minPublishAge)
{
if (minPublishAge == null || metadata.Published == null)
{
return true;
}

return DateTimeOffset.UtcNow - metadata.Published.Value >= minPublishAge.Value;
}

static async Task<IPackageSearchMetadata?> GetPackageMetadata(
string package,
NuGetVersion version,
Expand Down
47 changes: 47 additions & 0 deletions src/Tests/PackageSourceReaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
public class PackageSourceReaderTests
{
[Test]
public async Task ReadsMinPublishAgeHours()
{
using var temp = new TempDirectory();
await File.WriteAllTextAsync(
Path.Combine(temp, "nuget.config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="public" value="https://api.nuget.org/v3/index.json" minPublishAgeHours="24" />
<add key="other" value="https://example.com/v3/index.json" />
</packageSources>
</configuration>
""");

var sources = PackageSourceReader.Read(temp);

var publicSource = sources.Single(_ => _.Name == "public");
var otherSource = sources.Single(_ => _.Name == "other");
await Assert.That(PackageSourceReader.GetMinPublishAge(publicSource)).IsEqualTo(TimeSpan.FromHours(24));
await Assert.That(PackageSourceReader.GetMinPublishAge(otherSource)).IsNull();
}

[Test]
public async Task GetLatestVersion_SkipsVersionsWithinMinPublishAge()
{
using var cache = new SourceCacheContext
{
RefreshMemoryCache = true
};
var source = new PackageSource("https://api.nuget.org/v3/index.json");
// Larger than the age of any published version
PackageSourceReader.SetMinPublishAge(source, TimeSpan.FromDays(365 * 100));

var result = await Updater.GetLatestVersion(
"Newtonsoft.Json",
NuGetVersion.Parse("12.0.2"),
[source],
cache);

await Assert.That(result).IsNull();
}
}
Loading