diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5dec78..e276c55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,3 +250,64 @@ jobs: --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate + + deploy-stable: + name: Deploy Stable Packages + runs-on: ubuntu-latest + needs: + - build-test-pack + if: github.event_name == 'push' && needs.build-test-pack.result == 'success' + environment: nuget-release + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore maintenance tool + run: dotnet restore tools/analyticsLibrary.NuGetMaintenance/analyticsLibrary.NuGetMaintenance.csproj + + - name: Build maintenance tool + run: dotnet build -c Release --no-restore tools/analyticsLibrary.NuGetMaintenance/analyticsLibrary.NuGetMaintenance.csproj + + - name: Download artifacts + uses: actions/download-artifact@v7 + with: + name: nuget-packages-${{ github.run_id }} + path: ./artifacts + + - name: Publish stable packages + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "ERROR: NUGET_API_KEY is not set; cannot publish stable packages." + exit 1 + fi + + for pkg in ./artifacts/*.nupkg; do + if [[ "$pkg" == *"-beta.pr-"* ]]; then + echo "Skipping prerelease package: $(basename "$pkg")" + continue + fi + dotnet nuget push "$pkg" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done + + - name: Deprecate beta packages + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "NUGET_API_KEY is not set; skipping beta deprecation." + exit 0 + fi + dotnet run -c Release --no-build --project tools/analyticsLibrary.NuGetMaintenance -- deprecate-betas diff --git a/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs b/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs index 2d26ae0..64553e4 100644 --- a/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs +++ b/src/analyticsLibrary.ReleaseTooling/NuGetGalleryDeprecationApi.cs @@ -11,8 +11,8 @@ public static HttpRequestMessage CreateDeprecationRequest( bool hasCriticalBugs, bool isOther, string? customMessage, - string alternatePackageId, - string alternatePackageVersion, + string? alternatePackageId, + string? alternatePackageVersion, string apiKey) { if (versions.Count == 0) @@ -20,6 +20,14 @@ public static HttpRequestMessage CreateDeprecationRequest( throw new ArgumentException("At least one version is required.", nameof(versions)); } + var hasAltId = !string.IsNullOrEmpty(alternatePackageId); + var hasAltVersion = !string.IsNullOrEmpty(alternatePackageVersion); + if (hasAltId != hasAltVersion) + { + throw new ArgumentException( + "alternatePackageId and alternatePackageVersion must both be provided or both be omitted."); + } + var pairs = new List>(); for (var i = 0; i < versions.Count; i++) { @@ -46,8 +54,15 @@ public static HttpRequestMessage CreateDeprecationRequest( pairs.Add(new KeyValuePair("message", customMessage)); } - pairs.Add(new KeyValuePair("alternatePackageId", alternatePackageId)); - pairs.Add(new KeyValuePair("alternatePackageVersion", alternatePackageVersion)); + if (!string.IsNullOrEmpty(alternatePackageId)) + { + pairs.Add(new KeyValuePair("alternatePackageId", alternatePackageId)); + } + + if (!string.IsNullOrEmpty(alternatePackageVersion)) + { + pairs.Add(new KeyValuePair("alternatePackageVersion", alternatePackageVersion)); + } pairs.Add(new KeyValuePair("listedVerb", "Unchanged")); var url = string.Format(DeprecationEndpointTemplate, Uri.EscapeDataString(packageId)); diff --git a/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs b/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs index 31e71e7..23dfbf3 100644 --- a/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs +++ b/tests/analyticsLibrary.ReleaseTooling.Tests/NuGetGalleryDeprecationApiTests.cs @@ -25,4 +25,40 @@ public void CreateDeprecationRequest_uses_put_form_urlencoded() Assert.NotNull(req.Content); Assert.Equal("application/x-www-form-urlencoded", req.Content.Headers.ContentType!.MediaType); } + + [Fact] + public void CreateDeprecationRequest_allows_both_alternate_fields_omitted() + { + using var req = NuGetGalleryDeprecationApi.CreateDeprecationRequest( + "Some.Package", + new[] { "1.0.0" }, + isLegacy: false, + hasCriticalBugs: false, + isOther: true, + "msg", + alternatePackageId: null, + alternatePackageVersion: null, + "fake-key"); + + Assert.NotNull(req); + } + + [Theory] + [InlineData("Some.Package", null)] + [InlineData(null, "2.0.0")] + public void CreateDeprecationRequest_throws_when_only_one_alternate_field_is_provided( + string? altId, string? altVersion) + { + Assert.Throws(() => + NuGetGalleryDeprecationApi.CreateDeprecationRequest( + "Some.Package", + new[] { "1.0.0" }, + isLegacy: false, + hasCriticalBugs: false, + isOther: true, + "msg", + altId, + altVersion, + "fake-key")); + } } diff --git a/tools/analyticsLibrary.NuGetMaintenance/Program.cs b/tools/analyticsLibrary.NuGetMaintenance/Program.cs index d270a29..f6d5160 100644 --- a/tools/analyticsLibrary.NuGetMaintenance/Program.cs +++ b/tools/analyticsLibrary.NuGetMaintenance/Program.cs @@ -2,32 +2,57 @@ using analyticsLibrary.ReleaseTooling; using NuGet.Versioning; -if (args.Length < 2 || !string.Equals(args[0], "apply-deprecations", StringComparison.Ordinal)) -{ - Console.Error.WriteLine("Usage: analyticsLibrary.NuGetMaintenance apply-deprecations "); - return 1; -} +var command = args.Length > 0 ? args[0] : string.Empty; -var releaseRaw = args[1]; -var release = VersionDeprecationPlanner.TryParseReleaseVersion(releaseRaw); -if (release is null) +if (string.Equals(command, "apply-deprecations", StringComparison.Ordinal)) { - Console.Error.WriteLine($"Invalid release version: {releaseRaw}"); - return 1; + if (args.Length < 2) + { + Console.Error.WriteLine("Usage: analyticsLibrary.NuGetMaintenance apply-deprecations "); + return 1; + } + + var releaseRaw = args[1]; + var release = VersionDeprecationPlanner.TryParseReleaseVersion(releaseRaw); + if (release is null) + { + Console.Error.WriteLine($"Invalid release version: {releaseRaw}"); + return 1; + } + + var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + Console.Error.WriteLine("NUGET_API_KEY is not set."); + return 1; + } + + using var http = new HttpClient(); + return await RunAsync(http, apiKey, release, CancellationToken.None).ConfigureAwait(false); } +else if (string.Equals(command, "deprecate-betas", StringComparison.Ordinal)) +{ + var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + Console.Error.WriteLine("NUGET_API_KEY is not set."); + return 1; + } -var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); -if (string.IsNullOrWhiteSpace(apiKey)) + using var http = new HttpClient(); + return await DeprecateBetasAsync(http, apiKey, CancellationToken.None).ConfigureAwait(false); +} +else { - Console.Error.WriteLine("NUGET_API_KEY is not set."); + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" analyticsLibrary.NuGetMaintenance apply-deprecations "); + Console.Error.WriteLine(" analyticsLibrary.NuGetMaintenance deprecate-betas"); return 1; } -using var http = new HttpClient(); -return await RunAsync(http, apiKey, release, CancellationToken.None).ConfigureAwait(false); - static async Task RunAsync(HttpClient http, string apiKey, NuGetVersion releaseVersion, CancellationToken cancellationToken) { + // 40 matches the NuGet Gallery API's per-request version limit for deprecation batches const int chunkSize = 40; foreach (var packageId in ReleaseConstants.PackageIds) { @@ -98,6 +123,55 @@ static async Task RunAsync(HttpClient http, string apiKey, NuGetVersion rel return 0; } +static async Task DeprecateBetasAsync(HttpClient http, string apiKey, CancellationToken cancellationToken) +{ + // 40 matches the NuGet Gallery API's per-request version limit for deprecation batches + const int chunkSize = 40; + foreach (var packageId in ReleaseConstants.PackageIds) + { + IReadOnlyList published; + try + { + published = await NuGetFlatContainerHttp.GetPublishedVersionsAsync(http, packageId, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to list versions for {packageId}: {ex.Message}"); + return 1; + } + + if (published.Count == 0) + { + Console.WriteLine($"No published versions found for {packageId}; skipping."); + continue; + } + + var betas = published.Where(VersionDeprecationPlanner.IsCiBetaPrerelease).ToList(); + foreach (var chunk in Chunk(betas, chunkSize)) + { + var code = await SendDeprecationAsync( + http, + apiKey, + packageId, + chunk, + isLegacy: false, + hasCriticalBugs: false, + isOther: true, + ReleaseConstants.PrereleaseTestingDeprecationMessage, + alternateVersion: null, + cancellationToken) + .ConfigureAwait(false); + if (code != 0) + { + return code; + } + } + } + + return 0; +} + static List> Chunk(IReadOnlyList items, int size) { var chunks = new List>(); @@ -125,7 +199,7 @@ static async Task SendDeprecationAsync( bool hasCriticalBugs, bool isOther, string message, - NuGetVersion releaseVersion, + NuGetVersion? alternateVersion, CancellationToken cancellationToken) { if (versions.Count == 0) @@ -133,7 +207,7 @@ static async Task SendDeprecationAsync( return 0; } - var normalizedRelease = releaseVersion.ToNormalizedString(); + var normalizedRelease = alternateVersion?.ToNormalizedString(); using var request = NuGetGalleryDeprecationApi.CreateDeprecationRequest( packageId, versions, @@ -141,7 +215,7 @@ static async Task SendDeprecationAsync( hasCriticalBugs, isOther, message, - packageId, + normalizedRelease is not null ? packageId : null, normalizedRelease, apiKey);