Skip to content

Instantly share code, notes, and snippets.

@tcartwright
Last active January 12, 2026 16:28
Show Gist options
  • Select an option

  • Save tcartwright/f7e5d71864749dbd9bdb3f6e1471c7cb to your computer and use it in GitHub Desktop.

Select an option

Save tcartwright/f7e5d71864749dbd9bdb3f6e1471c7cb to your computer and use it in GitHub Desktop.
Azure: Prune upstream packages like nuget.org
#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
@tcartwright
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment