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
121 changes: 121 additions & 0 deletions Support/delist-prerelease-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Unlisting old prerelease packages on nuget.org

`delist-prerelease-packages.py` finds old alpha, beta, and other prerelease versions of packages you own on nuget.org and unlists them.

Unlisting hides a version from search and from the NuGet UI's version picker. Projects that already reference that exact version can still restore it. Unlisting can be undone: open the package's **Manage** page on nuget.org and relist the version. nuget.org doesn't let you permanently delete packages.

## Prerequisites

- Python 3 (standard library only, nothing to install)
- A nuget.org API key with the **Unlist package** scope (only needed with `--apply`)

## Quick start

From Git Bash:

```bash
cd /s/src/rdl/csla/Support

# 1. See what would be unlisted (dry run, changes nothing)
python delist-prerelease-packages.py --include Csla.Web

# 2. Unlist those versions
NUGET_API_KEY=oy2... python delist-prerelease-packages.py --include Csla.Web --apply
```

From PowerShell:

```powershell
$env:NUGET_API_KEY = 'oy2...'
python .\delist-prerelease-packages.py --include Csla.Web --apply
Remove-Item Env:\NUGET_API_KEY
```

Always run the dry run first and read the list.

## Getting an API key

nuget.org still supports API keys, but each one expires after at most 365 days. The CI release workflow uses **trusted publishing** instead. That gives the workflow a key that lasts one hour and is meant for pushing packages, so it's no help for unlisting from your machine. Create a separate short-lived key for each cleanup:

1. Sign in at https://www.nuget.org and go to **API Keys** (https://www.nuget.org/account/apikeys).
2. Click **Create** and set:
- **Key name:** something like `unlist-prerelease-2026-09`
- **Expires in:** 1 day
- **Select scopes:** **Unlist package** only (leave both Push options unchecked)
- **Select packages → Glob pattern:** `Csla*` (or whatever prefix you're cleaning up)
3. Copy the key. nuget.org shows it only once.
4. After the run, delete the key on the same page.

## What gets unlisted

The script queries the nuget.org search index for every package owned by `--owner` whose id starts with `--prefix`. The search index only includes listed versions, so versions that are already unlisted are skipped automatically.

It unlists a prerelease version for any of these reasons (shown in the last column of the output):

| Reason | Rule | Example |
|---|---|---|
| `superseded by stable` | The package has a listed stable version with an equal or higher `major.minor.patch` | `10.0.0-beta-0022-gbd53196668` is unlisted once `10.0.0` is on nuget.org |
| `non-public build` | The only prerelease tag is a git commit id (`-g<hex>`). Nerdbank.GitVersioning adds that when a build wasn't marked `PublicRelease=true`, so it was pushed by mistake | `Csla.Maui 10.0.1-geeb45f365a` |
| `--include` | The package id was passed with `--include`. Every prerelease of that package is unlisted, even with no stable release | `Csla.Web 10.0.0-beta-0011-gc72d4fb04f` (package replaced by `Csla.Web.Mvc`) |
| `--all-prerelease` | The flag was given. Every prerelease is unlisted, **including the current beta** | `10.2.0-beta.1` |

Stable versions are never touched.

Prereleases for an upcoming version stay listed until that version ships (for example, `10.2.0-beta.1` stays while no stable 10.2.0 exists). Run the script again after each stable release to clear out the betas for that version.

## Options

| Option | Default | Description |
|---|---|---|
| `--owner OWNER` | `rockfordlhotka` | nuget.org profile name that owns the packages |
| `--prefix PREFIX` | `Csla` | Only consider package ids that start with this (not case-sensitive) |
| `--include ID` | none | Also unlist every prerelease of this package. Repeat for more than one package |
| `--all-prerelease` | off | Unlist every listed prerelease, including current betas |
| `--apply` | off | Actually unlist. Without it the script only prints the plan |

The API key is read from the `NUGET_API_KEY` environment variable, so it doesn't end up in your shell history as an argument. Don't commit a key to this script or anywhere else in the repo.

## Examples

```bash
# Routine cleanup after a stable release
python delist-prerelease-packages.py
NUGET_API_KEY=... python delist-prerelease-packages.py --apply

# Retire every prerelease of packages that are no longer maintained
python delist-prerelease-packages.py --include Csla.Web --include Csla.Uwp

# Check other packages you own
python delist-prerelease-packages.py --prefix RockBot

# Wipe every prerelease, current betas included
python delist-prerelease-packages.py --all-prerelease
```

## Output

Dry run:

```
Csla 10.0.0-beta-0022-gbd53196668 superseded by stable
Csla.Maui 10.0.1-geeb45f365a non-public build
Csla.Web 10.0.0-beta-0011-gc72d4fb04f --include

87 prerelease version(s) would be unlisted (dry run).
```

With `--apply`, each version prints `unlisted` or `FAILED` along with the HTTP status. The script ends with a summary and exits with code 1 if anything failed.

## Troubleshooting

- **403 Forbidden:** the key is missing the **Unlist package** scope, its glob pattern doesn't match the package id, or the key has expired.
- **401 Unauthorized:** the key is wrong or has been deleted.
- **A version still appears after unlisting:** the search index updates a few minutes after the change. Run the dry run again later to confirm.
- **An unlisted version shows up in the plan:** this is the same search-index delay. Unlisting a version twice does no harm.
- **Need to undo:** on nuget.org, go to the package → **Manage package** → **Listing**, then check the version to relist it.

## How it works

- Package discovery: `GET https://azuresearch-usnc.nuget.org/query?q=owner:<owner>&prerelease=true&semVerLevel=2.0.0`
- Unlist: `DELETE https://www.nuget.org/api/v2/package/<id>/<version>` with an `X-NuGet-ApiKey` header. This is the same call `dotnet nuget delete` makes, and on nuget.org it unlists the version rather than deleting it.
- There's a half-second pause between calls to stay well within nuget.org's rate limits.
141 changes: 141 additions & 0 deletions Support/delist-prerelease-packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
Find and unlist old prerelease package versions on nuget.org.

A listed prerelease version is unlisted when:
- the same package has a listed stable version whose core version is equal
or higher (10.0.0-beta-0022 goes once 10.0.0 ships; 10.2.0-beta.1 stays
until 10.2.0 or later is published), or
- its only prerelease tag is a git commit id (e.g. 10.0.1-geeb45f365a),
meaning a non-public build was pushed by mistake, or
- its package id was passed with --include, or --all-prerelease was given.

Dry run by default: prints the plan and changes nothing.
See delist-prerelease-packages.md for full usage.
"""

import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

SEARCH_URL = "https://azuresearch-usnc.nuget.org/query"
DELETE_URL = "https://www.nuget.org/api/v2/package/{id}/{version}"
# Nerdbank.GitVersioning appends only "-g<commit>" when PublicRelease isn't set,
# so a version like 10.0.1-geeb45f365a is a build that was never meant to ship.
GIT_COMMIT_TAG = re.compile(r"g[0-9a-f]{7,40}")


def core_version(version):
core = version.split("+", 1)[0].split("-", 1)[0]
parts = [int(p) if p.isdigit() else 0 for p in core.split(".")]
return tuple((parts + [0, 0, 0, 0])[:4])


def owned_packages(owner):
query = urllib.parse.urlencode({
"q": f"owner:{owner}",
"prerelease": "true",
"semVerLevel": "2.0.0",
"take": 1000,
})
with urllib.request.urlopen(f"{SEARCH_URL}?{query}") as response:
data = json.load(response)
# The search index only returns listed versions, which is exactly what we need.
return [p for p in data["data"] if owner.lower() in (o.lower() for o in p.get("owners", []))]


def prerelease_tag(version):
release = version.split("+", 1)[0]
return release.split("-", 1)[1] if "-" in release else None


def plan(packages, prefix, all_prerelease, include):
include = {i.lower() for i in include}
result = []
for package in sorted(packages, key=lambda p: p["id"].lower()):
if not package["id"].lower().startswith(prefix.lower()):
continue
versions = [v["version"] for v in package["versions"]]
stable = [core_version(v) for v in versions if prerelease_tag(v) is None]
newest_stable = max(stable) if stable else None
for version in versions:
tag = prerelease_tag(version)
if tag is None:
continue
if newest_stable is not None and newest_stable >= core_version(version):
reason = "superseded by stable"
elif GIT_COMMIT_TAG.fullmatch(tag):
reason = "non-public build"
elif package["id"].lower() in include:
reason = "--include"
elif all_prerelease:
reason = "--all-prerelease"
else:
continue
result.append((package["id"], version, reason))
return result


def unlist(package_id, version, api_key):
url = DELETE_URL.format(id=urllib.parse.quote(package_id), version=urllib.parse.quote(version))
request = urllib.request.Request(url, method="DELETE", headers={
"X-NuGet-ApiKey": api_key,
"User-Agent": "csla-delist-prerelease",
})
with urllib.request.urlopen(request) as response:
return response.status


def main():
parser = argparse.ArgumentParser(description="Unlist superseded prerelease packages on nuget.org")
parser.add_argument("--owner", default="rockfordlhotka", help="nuget.org owner profile name")
parser.add_argument("--prefix", default="Csla", help="only packages whose id starts with this")
parser.add_argument("--all-prerelease", action="store_true", help="also unlist prereleases with no newer stable release")
parser.add_argument("--include", action="append", default=[], metavar="ID",
help="also unlist every prerelease of this package id (repeatable)")
parser.add_argument("--apply", action="store_true", help="actually unlist (requires NUGET_API_KEY env var)")
args = parser.parse_args()

targets = plan(owned_packages(args.owner), args.prefix, args.all_prerelease, args.include)
if not targets:
print("Nothing to unlist.")
return 0

id_width = max(len(i) for i, _, _ in targets)
version_width = max(len(v) for _, v, _ in targets)
for package_id, version, reason in targets:
print(f" {package_id:<{id_width}} {version:<{version_width}} {reason}")
print(f"\n{len(targets)} prerelease version(s) {'to unlist' if args.apply else 'would be unlisted (dry run)'}.")

if not args.apply:
return 0

api_key = os.environ.get("NUGET_API_KEY")
if not api_key:
print("Error: set NUGET_API_KEY to an API key with the 'Unlist package' scope.", file=sys.stderr)
return 1

failures = 0
for package_id, version, _ in targets:
try:
status = unlist(package_id, version, api_key)
print(f" unlisted {package_id} {version} ({status})")
except urllib.error.HTTPError as ex:
failures += 1
print(f" FAILED {package_id} {version} ({ex.code} {ex.reason})", file=sys.stderr)
if ex.code in (401, 403) and failures == 1:
print(" (check the key's scope and glob pattern)", file=sys.stderr)
time.sleep(0.5)

print(f"\nDone: {len(targets) - failures} unlisted, {failures} failed.")
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main())