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
76 changes: 76 additions & 0 deletions PowerForge.Tests/ModuleVersionStepperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ public void Step_UsesUnlistedPowerShellGalleryVersionWhenStepping()
Assert.Equal("3.0.0", result.CurrentVersion);
}

[Fact]
public void Step_UsesReservedPowerShellGalleryVersionWhenFeedOmitsIt()
{
using var client = new HttpClient(new FakeReservedPowerShellGalleryHandler());
var stepper = new ModuleVersionStepper(
new NullLogger(),
new StubPowerShellRunner(new PowerShellRunResult(0, VisibleRepositoryItem("PSPublishModule", "2.0.27"), string.Empty, "pwsh.exe")),
client);

var result = stepper.Step("3.0.X", moduleName: "PSPublishModule", localPsd1Path: null, repository: "PSGallery");

Assert.Equal("3.0.1", result.Version);
Assert.Equal(ModuleVersionSource.Repository, result.CurrentVersionSource);
Assert.Equal("3.0.0", result.CurrentVersion);
}

private static string VisibleRepositoryItem(string name, string version)
=> string.Join("::", new[]
{
Expand Down Expand Up @@ -58,6 +74,11 @@ private sealed class FakePowerShellGalleryFeedHandler : HttpMessageHandler
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var uri = request.RequestUri?.ToString() ?? string.Empty;
if (!uri.Contains("FindPackagesById", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}

var body = uri.Contains("$skip=100", StringComparison.OrdinalIgnoreCase)
? BuildSecondPage()
: BuildFirstPage();
Expand Down Expand Up @@ -103,4 +124,59 @@ private static string BuildSecondPage()
</feed>
""";
}

private sealed class FakeReservedPowerShellGalleryHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var uri = request.RequestUri?.ToString() ?? string.Empty;

if (uri.Contains("FindPackagesById", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices"
xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
<entry>
<content type="application/zip" src="https://www.powershellgallery.com/api/v2/package/PSPublishModule/2.0.27" />
<m:properties>
<d:Version>2.0.27</d:Version>
<d:IsPrerelease>false</d:IsPrerelease>
<d:Published m:type="Edm.DateTime">2026-03-10T10:00:00</d:Published>
</m:properties>
</entry>
</feed>
""", Encoding.UTF8, "application/atom+xml")
});
}

if (uri.Contains("Packages(", StringComparison.OrdinalIgnoreCase) &&
uri.Contains("PSPublishModule", StringComparison.OrdinalIgnoreCase) &&
uri.Contains("3.0.0", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
<?xml version="1.0" encoding="utf-8"?>
<entry xml:base="https://www.powershellgallery.com/api/v2"
xmlns="http://www.w3.org/2005/Atom"
xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices"
xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
<content type="application/zip" src="https://www.powershellgallery.com/api/v2/package/PSPublishModule/3.0.0" />
<m:properties>
<d:Version>3.0.0</d:Version>
<d:IsPrerelease>false</d:IsPrerelease>
<d:Published m:type="Edm.DateTime">1900-01-01T00:00:00</d:Published>
</m:properties>
</entry>
""", Encoding.UTF8, "application/atom+xml")
});
}

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
}
75 changes: 63 additions & 12 deletions PowerForge/Services/ModuleVersionStepper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ internal ModuleVersionStepper(ILogger logger, IPowerShellRunner runner, HttpClie
/// <param name="localPsd1Path">Optional local PSD1 path to resolve current version from.</param>
/// <param name="repository">Repository name used with PSResourceGet (default: PSGallery).</param>
/// <param name="prerelease">Whether to include prerelease versions when resolving current version.</param>
public ModuleVersionStepResult Step(
string expectedVersion,
string? moduleName = null,
string? localPsd1Path = null,
string repository = "PSGallery",
public ModuleVersionStepResult Step(
string expectedVersion,
string? moduleName = null,
string? localPsd1Path = null,
string repository = "PSGallery",
bool prerelease = false)
{
if (string.IsNullOrWhiteSpace(expectedVersion))
Expand All @@ -68,8 +68,8 @@ public ModuleVersionStepResult Step(
usedAutoVersioning: false);
}

var (current, source) = ResolveCurrentVersion(moduleName, localPsd1Path, repository, prerelease);
var proposed = ComputeNextVersion(expectedVersion, current);
var (current, source) = ResolveCurrentVersion(expectedVersion, moduleName, localPsd1Path, repository, prerelease);
var proposed = ComputeNextVersion(expectedVersion, current);

return new ModuleVersionStepResult(
expectedVersion: expectedVersion,
Expand All @@ -79,11 +79,12 @@ public ModuleVersionStepResult Step(
usedAutoVersioning: true);
}

private (Version? Version, ModuleVersionSource Source) ResolveCurrentVersion(
string? moduleName,
string? localPsd1Path,
string repository,
bool prerelease)
private (Version? Version, ModuleVersionSource Source) ResolveCurrentVersion(
string expectedVersion,
string? moduleName,
string? localPsd1Path,
string repository,
bool prerelease)
{
if (string.IsNullOrWhiteSpace(moduleName))
return (null, ModuleVersionSource.None);
Expand Down Expand Up @@ -121,6 +122,13 @@ public ModuleVersionStepResult Step(
try
{
var galleryVersion = TryResolveCurrentVersionFromPowerShellGalleryFeed(moduleName!, prerelease);
var reservedVersion = TryResolveReservedPowerShellGalleryVersion(expectedVersion, moduleName!, galleryVersion, prerelease);
if (reservedVersion is not null && (galleryVersion is null || reservedVersion.CompareTo(galleryVersion) > 0))
{
_logger.Verbose($"PowerShell Gallery reserved version for '{moduleName}' was resolved from the exact package metadata endpoint ({reservedVersion}).");
return (reservedVersion, ModuleVersionSource.Repository);
}

if (galleryVersion is not null)
return (galleryVersion, ModuleVersionSource.Repository);
}
Expand Down Expand Up @@ -155,6 +163,49 @@ public ModuleVersionStepResult Step(
}
}

private Version? TryResolveReservedPowerShellGalleryVersion(
string expectedVersion,
string moduleName,
Version? currentVersion,
bool prerelease)
{
if (prerelease)
return null;

Version? latestReserved = null;
Version? cursor = currentVersion;
var missCount = 0;
var hitCount = 0;
const int maxProbeCount = 12;
const int maxConsecutiveMissesAfterHit = 3;
const int maxConsecutiveMissesBeforeHit = 4;

for (var index = 0; index < maxProbeCount; index++)
{
var candidateText = ComputeNextVersion(expectedVersion, cursor);
if (!TryParseRepositoryVersion(candidateText, out var candidateVersion))
break;

if (_powerShellGalleryFeed.VersionExists(moduleName, candidateText, timeout: TimeSpan.FromSeconds(20)))
{
latestReserved = candidateVersion;
cursor = candidateVersion;
hitCount++;
missCount = 0;
continue;
}

cursor = candidateVersion;
missCount++;
if (hitCount > 0 && missCount >= maxConsecutiveMissesAfterHit)
break;
if (hitCount == 0 && missCount >= maxConsecutiveMissesBeforeHit)
break;
}

return latestReserved;
}

private Version? TryResolveCurrentVersionFromPowerShellGalleryFeed(string moduleName, bool prerelease)
{
var versions = _powerShellGalleryFeed.GetVersions(moduleName, prerelease, timeout: TimeSpan.FromMinutes(2));
Expand Down
39 changes: 39 additions & 0 deletions PowerForge/Services/PowerShellGalleryVersionFeedClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net.Http;
using System.Xml.Linq;
using System.Globalization;

namespace PowerForge;

Expand All @@ -9,6 +10,7 @@ namespace PowerForge;
public sealed class PowerShellGalleryVersionFeedClient
{
private const string FindPackagesByIdTemplate = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='{0}'";
private const string ExactPackageTemplate = "https://www.powershellgallery.com/api/v2/Packages(Id='{0}',Version='{1}')";
private const string UnlistedPublishedMarker = "1900-01-01T00:00:00";
private static readonly HttpClient SharedClient = CreateSharedClient();
private readonly HttpClient _client;
Expand Down Expand Up @@ -70,6 +72,43 @@ public IReadOnlyList<PowerShellGalleryPackageVersion> GetVersions(
return versions;
}

/// <summary>
/// Checks whether the exact package/version exists in the gallery metadata endpoint,
/// including versions that are unlisted or removed from the public feed listing.
/// </summary>
public bool VersionExists(
string packageId,
string version,
TimeSpan? timeout = null)
{
if (string.IsNullOrWhiteSpace(packageId))
throw new ArgumentException("PackageId is required.", nameof(packageId));
if (string.IsNullOrWhiteSpace(version))
throw new ArgumentException("Version is required.", nameof(version));

using var cts = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : null;
var token = cts?.Token ?? CancellationToken.None;
var requestUri = string.Format(
CultureInfo.InvariantCulture,
ExactPackageTemplate,
Uri.EscapeDataString(packageId.Trim()),
Uri.EscapeDataString(version.Trim()));

try
{
using var response = _client.GetAsync(requestUri, token).GetAwaiter().GetResult();
return response.IsSuccessStatusCode;
}
catch (TaskCanceledException)
{
return false;
}
catch (HttpRequestException)
{
return false;
}
}

private static IEnumerable<PowerShellGalleryPackageVersion> ParseEntries(XDocument document, bool includePrerelease)
{
XNamespace atom = "http://www.w3.org/2005/Atom";
Expand Down
Loading