Last active
January 12, 2026 16:28
-
-
Save tcartwright/f7e5d71864749dbd9bdb3f6e1471c7cb to your computer and use it in GitHub Desktop.
Azure: Prune upstream packages like nuget.org
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Version 5.1 | |
| <# | |
| .SYNOPSIS | |
| Prunes upstream-cached NuGet packages from an Azure Artifacts feed while preserving internal packages. | |
| .DESCRIPTION | |
| This script identifies packages in your Azure Artifacts feed that originated from upstream sources | |
| (like nuget.org) and deletes old versions, keeping only a specified number of recent versions. | |
| Internal packages (those published directly to your feed) are not touched. | |
| .PARAMETER Organization | |
| Your Azure DevOps organization name. | |
| .PARAMETER Project | |
| Your Azure DevOps project name (optional - omit for organization-scoped feeds). | |
| .PARAMETER FeedName | |
| The name of your Azure Artifacts feed. | |
| .PARAMETER VersionsToKeep | |
| Number of versions to retain per upstream package. Default is 1. | |
| .PARAMETER PAT | |
| Personal Access Token with Packaging read/write permissions. | |
| .PARAMETER WhatIf | |
| Run in dry-run mode - shows what would be deleted without actually deleting. | |
| .EXAMPLE | |
| .\Prune-UpstreamPackages.ps1 -Organization "myorg" -FeedName "myfeed" -PAT $pat -WhatIf | |
| #> | |
| [CmdletBinding(SupportsShouldProcess)] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Organization, | |
| [Parameter(Mandatory)] | |
| [string]$Project, | |
| [Parameter(Mandatory)] | |
| [string]$FeedName, | |
| [Parameter()] | |
| [int]$VersionsToKeep = 1, | |
| [Parameter()] | |
| [string]$PAT = $env:AZURE_PAT_TOKEN | |
| ) | |
| # $WhatIfPreference = $false # turn off what if | |
| # $WhatIfPreference = $true # Force WhatIf for debugging | |
| $ErrorActionPreference = 'Stop' | |
| # Build auth header | |
| $base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$PAT")) | |
| $headers = @{ | |
| Authorization = "Basic $base64Auth" | |
| 'Content-Type' = 'application/json' | |
| } | |
| # Build base URLs based on whether project-scoped or org-scoped | |
| if ($Project) { | |
| $feedsBaseUrl = "https://feeds.dev.azure.com/$Organization/$Project/_apis/packaging/feeds" | |
| $pkgsBaseUrl = "https://pkgs.dev.azure.com/$Organization/$Project/_apis/packaging/feeds" | |
| } else { | |
| $feedsBaseUrl = "https://feeds.dev.azure.com/$Organization/_apis/packaging/feeds" | |
| $pkgsBaseUrl = "https://pkgs.dev.azure.com/$Organization/_apis/packaging/feeds" | |
| } | |
| $apiVersion = "api-version=7.1-preview.1" | |
| function Get-AllPackages { | |
| param([string]$FeedName) | |
| $packages = @() | |
| $url = "$feedsBaseUrl/$FeedName/packages?protocolType=NuGet&includeAllVersions=false&$apiVersion" | |
| do { | |
| Write-Host "Fetching packages from: $url" -ForegroundColor Gray | |
| $response = Invoke-RestMethod -Uri $url -Headers $headers -Method Get | |
| $packages += $response.value | |
| $url = $response.nextLink | |
| } while ($url) | |
| return $packages | |
| } | |
| function Get-PackageVersions { | |
| param( | |
| [string]$FeedName, | |
| [string]$PackageId | |
| ) | |
| $url = "$feedsBaseUrl/$FeedName/packages/$PackageId/versions?$apiVersion" | |
| $response = Invoke-RestMethod -Uri $url -Headers $headers -Method Get | |
| return $response.value | |
| } | |
| function Remove-PackageVersion { | |
| param( | |
| [string]$FeedName, | |
| [string]$PackageName, | |
| [string]$Version | |
| ) | |
| $url = "$pkgsBaseUrl/$FeedName/nuget/packages/$PackageName/versions/$Version`?$apiVersion" | |
| Invoke-RestMethod -Uri $url -Headers $headers -Method Delete | Out-Null | |
| } | |
| # Main execution | |
| Write-Host "`n========================================" -ForegroundColor Cyan | |
| Write-Host "Azure Artifacts Upstream Package Pruner" -ForegroundColor Cyan | |
| Write-Host "========================================`n" -ForegroundColor Cyan | |
| if ($WhatIfPreference) { | |
| Write-Host "*** RUNNING IN DRY-RUN MODE - NO PACKAGES WILL BE DELETED ***`n" -ForegroundColor Yellow | |
| } | |
| Write-Host "Configuration:" -ForegroundColor White | |
| Write-Host " Organization: $Organization" | |
| Write-Host " Project: $(if ($Project) { $Project } else { '(org-scoped)' })" | |
| Write-Host " Feed: $FeedName" | |
| Write-Host " Versions to keep per package: $VersionsToKeep`n" | |
| # Get all packages | |
| Write-Host "Fetching package list..." -ForegroundColor White | |
| $allPackages = Get-AllPackages -FeedName $FeedName | |
| Write-Host "Found $($allPackages.Count) total packages`n" -ForegroundColor Green | |
| # Filter to upstream-sourced packages by checking if any version has directUpstreamSourceId | |
| $upstreamPackages = $allPackages | Where-Object { | |
| $_.versions | Where-Object { $_.directUpstreamSourceId } | |
| } | |
| $internalPackages = $allPackages | Where-Object { | |
| -not ($_.versions | Where-Object { $_.directUpstreamSourceId }) | |
| } | |
| Write-Host "Package breakdown:" -ForegroundColor White | |
| Write-Host " Internal packages: $($internalPackages.Count) (will be preserved)" -ForegroundColor Green | |
| Write-Host " Upstream-cached packages: $($upstreamPackages.Count) (candidates for pruning)`n" -ForegroundColor Yellow | |
| if ($upstreamPackages.Count -eq 0) { | |
| Write-Host "No upstream packages found to prune. Exiting." -ForegroundColor Green | |
| exit 0 | |
| } | |
| # Process each upstream package | |
| $totalDeleted = 0 | |
| $totalSpaceSaved = 0 | |
| $errors = @() | |
| foreach ($package in $upstreamPackages) { | |
| Write-Host "Processing: $($package.name)" -ForegroundColor White | |
| try { | |
| # Get all versions of this package | |
| $versions = Get-PackageVersions -FeedName $FeedName -PackageId $package.id | |
| # Sort by publish date descending, only consider versions with upstream source | |
| $upstreamVersions = $versions | Where-Object { $_.directUpstreamSourceId } | |
| $sortedVersions = $upstreamVersions | Sort-Object { [DateTime]$_.publishDate } -Descending | |
| $versionsToDelete = $sortedVersions | Select-Object -Skip $VersionsToKeep | |
| if ($versionsToDelete.Count -eq 0) { | |
| Write-Host " No versions to prune (has $($upstreamVersions.Count) upstream version(s))" -ForegroundColor Gray | |
| continue | |
| } | |
| Write-Host " Upstream versions: $($upstreamVersions.Count), Keeping: $VersionsToKeep, Deleting: $($versionsToDelete.Count)" -ForegroundColor Gray | |
| foreach ($version in $versionsToDelete) { | |
| $versionString = $version.version | |
| if ($PSCmdlet.ShouldProcess("$($package.name)@$versionString", "Delete package version")) { | |
| try { | |
| Write-Host " Deleting version: $versionString" -ForegroundColor Red | |
| Remove-PackageVersion -FeedName $FeedName -PackageName $package.name -Version $versionString | |
| $totalDeleted++ | |
| } catch { | |
| Write-Host " ERROR deleting $versionString : $_" -ForegroundColor Red | |
| $errors += "$($package.name)@$versionString : $_" | |
| } | |
| } | |
| } | |
| } catch { | |
| Write-Host " ERROR processing package: $_" -ForegroundColor Red | |
| $errors += "$($package.name) : $_" | |
| } | |
| } | |
| # Summary | |
| Write-Host "`n========================================" -ForegroundColor Cyan | |
| Write-Host "Summary" -ForegroundColor Cyan | |
| Write-Host "========================================" -ForegroundColor Cyan | |
| if ($WhatIfPreference) { | |
| Write-Host "DRY-RUN complete. No packages were deleted." -ForegroundColor Yellow | |
| Write-Host "Remove -WhatIf parameter to perform actual deletion." -ForegroundColor Yellow | |
| } else { | |
| Write-Host "Total package versions deleted: $totalDeleted" -ForegroundColor Green | |
| } | |
| if ($errors.Count -gt 0) { | |
| Write-Host "`nErrors encountered:" -ForegroundColor Red | |
| $errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red } | |
| } | |
| Write-Host "`nNote: It may take some time for storage metrics to update in Azure DevOps." -ForegroundColor Gray |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Azure charges you for package storage, and caches them for quite a while. You can make your prune settings more aggressive. However that affects your internal packages as well.
I developed this script as an alternative (via Claude) so that it will auto prune any packages that are from an upstream like nuget. Keeping only the latest N versions.