diff --git a/Support/delist-prerelease-packages.md b/Support/delist-prerelease-packages.md new file mode 100644 index 0000000000..7b34e12568 --- /dev/null +++ b/Support/delist-prerelease-packages.md @@ -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`). 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:&prerelease=true&semVerLevel=2.0.0` +- Unlist: `DELETE https://www.nuget.org/api/v2/package//` 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. diff --git a/Support/delist-prerelease-packages.py b/Support/delist-prerelease-packages.py new file mode 100644 index 0000000000..722ed69ad3 --- /dev/null +++ b/Support/delist-prerelease-packages.py @@ -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" 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())