Cleanup Pre-release Packages #27
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
| name: Cleanup Pre-release Packages | |
| # Unlist old pre-release versions from NuGet.org and delete from GitHub Packages. | |
| # Runs weekly and can be triggered manually after a stable release. | |
| # | |
| # Logic: | |
| # 1. Superseded pre-releases (base version <= latest stable): unlist/delete ALL | |
| # 2. Current pre-releases (base version > latest stable): keep last 3 per channel (alpha/beta), unlist/delete the rest | |
| # | |
| # NuGet.org: unlists packages (still downloadable by exact version pin) | |
| # GitHub Packages: permanently deletes old versions | |
| on: | |
| schedule: | |
| - cron: "0 6 * * 0" # Every Sunday at 06:00 UTC | |
| workflow_dispatch: | |
| permissions: | |
| packages: write | |
| env: | |
| PACKAGE_NAME: ALCops.Analyzers | |
| PACKAGE_NAME_LOWER: alcops.analyzers | |
| KEEP_LAST_N: 3 | |
| jobs: | |
| cleanup: | |
| runs-on: ubuntu-latest | |
| name: Cleanup old pre-releases | |
| steps: | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v6 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Cleanup NuGet.org | |
| env: | |
| NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${NUGET_API_KEY:-}" ]; then | |
| echo "::warning::NUGET_API_KEY not configured, skipping NuGet.org cleanup" | |
| exit 0 | |
| fi | |
| echo "Fetching version metadata from NuGet.org registration API..." | |
| REGISTRATION_JSON=$(curl -sf --compressed \ | |
| "https://api.nuget.org/v3/registration5-gz-semver2/${PACKAGE_NAME_LOWER}/index.json") \ | |
| || { echo "::warning::Failed to fetch registration index"; exit 0; } | |
| # Extract listed versions from registration pages. | |
| # Pages may have inlined items or require fetching by URL (@id). | |
| ALL_VERSIONS=$(echo "$REGISTRATION_JSON" | jq -r ' | |
| [.items[] | | |
| if .items then | |
| .items[] | |
| else | |
| empty | |
| end | |
| | select(.catalogEntry.listed == true) | |
| | .catalogEntry.version | |
| ] | .[]') || { echo "::warning::Failed to parse registration data"; exit 0; } | |
| # Handle non-inlined pages: fetch each page URL if items were not inlined | |
| PAGE_URLS=$(echo "$REGISTRATION_JSON" | jq -r ' | |
| [.items[] | select(.items == null) | .["@id"]] | .[]' 2>/dev/null) | |
| if [ -n "$PAGE_URLS" ]; then | |
| while IFS= read -r page_url; do | |
| PAGE_VERSIONS=$(curl -sf --compressed "$page_url" | jq -r ' | |
| [.items[] | select(.catalogEntry.listed == true) | .catalogEntry.version] | .[]' 2>/dev/null) | |
| if [ -n "$PAGE_VERSIONS" ]; then | |
| ALL_VERSIONS=$(printf '%s\n%s' "$ALL_VERSIONS" "$PAGE_VERSIONS") | |
| fi | |
| done <<< "$PAGE_URLS" | |
| fi | |
| TOTAL_COUNT=$(echo "$REGISTRATION_JSON" | jq '[.items[].items // [] | length] | add // 0') | |
| LISTED_COUNT=$(echo "$ALL_VERSIONS" | grep -c . || true) | |
| echo "Found ${LISTED_COUNT} listed versions out of ${TOTAL_COUNT} total on NuGet.org" | |
| if [ -z "$ALL_VERSIONS" ]; then | |
| echo "No listed versions found" | |
| exit 0 | |
| fi | |
| # Separate stable and pre-release | |
| STABLE=() | |
| PRERELEASE=() | |
| while IFS= read -r v; do | |
| if [[ "$v" == *-* ]]; then | |
| PRERELEASE+=("$v") | |
| else | |
| STABLE+=("$v") | |
| fi | |
| done <<< "$ALL_VERSIONS" | |
| echo "Found ${#STABLE[@]} stable and ${#PRERELEASE[@]} pre-release versions" | |
| if [ ${#PRERELEASE[@]} -eq 0 ]; then | |
| echo "No pre-release versions to clean up" | |
| exit 0 | |
| fi | |
| # Find latest stable | |
| LATEST_STABLE="" | |
| if [ ${#STABLE[@]} -gt 0 ]; then | |
| LATEST_STABLE=$(printf '%s\n' "${STABLE[@]}" | sort -V | tail -n1) | |
| echo "Latest stable: $LATEST_STABLE" | |
| else | |
| echo "No stable versions found, will only trim current pre-releases" | |
| fi | |
| # Categorize pre-releases into superseded vs current | |
| SUPERSEDED=() | |
| CURRENT=() | |
| for v in "${PRERELEASE[@]}"; do | |
| base="${v%%-*}" | |
| if [ -n "$LATEST_STABLE" ]; then | |
| higher=$(printf '%s\n%s' "$base" "$LATEST_STABLE" | sort -V | tail -n1) | |
| if [ "$higher" = "$LATEST_STABLE" ] || [ "$base" = "$LATEST_STABLE" ]; then | |
| SUPERSEDED+=("$v") | |
| else | |
| CURRENT+=("$v") | |
| fi | |
| else | |
| CURRENT+=("$v") | |
| fi | |
| done | |
| echo "Superseded pre-releases: ${#SUPERSEDED[@]}" | |
| echo "Current pre-releases: ${#CURRENT[@]}" | |
| # Build unlist list | |
| TO_UNLIST=() | |
| # All superseded pre-releases | |
| for v in "${SUPERSEDED[@]}"; do | |
| TO_UNLIST+=("$v") | |
| done | |
| # Split current pre-releases by channel (alpha vs beta) | |
| CURRENT_ALPHA=() | |
| CURRENT_BETA=() | |
| for v in "${CURRENT[@]}"; do | |
| if [[ "$v" == *-beta.* ]]; then | |
| CURRENT_BETA+=("$v") | |
| else | |
| CURRENT_ALPHA+=("$v") | |
| fi | |
| done | |
| echo "Current alphas: ${#CURRENT_ALPHA[@]}, betas: ${#CURRENT_BETA[@]}" | |
| # Keep last N per channel (by SemVer sort) | |
| for channel_name in "alpha" "beta"; do | |
| declare -n channel_arr="CURRENT_${channel_name^^}" | |
| if [ ${#channel_arr[@]} -gt "$KEEP_LAST_N" ]; then | |
| mapfile -t sorted < <(printf '%s\n' "${channel_arr[@]}" | sort -V) | |
| remove_count=$(( ${#sorted[@]} - KEEP_LAST_N )) | |
| for ((i=0; i<remove_count; i++)); do | |
| TO_UNLIST+=("${sorted[$i]}") | |
| done | |
| fi | |
| done | |
| echo "" | |
| echo "=== NuGet.org: unlisting ${#TO_UNLIST[@]} versions ===" | |
| for v in "${TO_UNLIST[@]}"; do | |
| echo " Unlisting: $v" | |
| dotnet nuget delete "$PACKAGE_NAME" "$v" \ | |
| --source https://api.nuget.org/v3/index.json \ | |
| --api-key "$NUGET_API_KEY" \ | |
| --non-interactive 2>&1 || echo " (already unlisted or failed)" | |
| sleep 1 | |
| done | |
| echo "NuGet.org cleanup complete" | |
| - name: Cleanup GitHub Packages | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| OWNER="${{ github.repository_owner }}" | |
| echo "Fetching GitHub Packages versions..." | |
| GH_VERSIONS=$(gh api "/orgs/${OWNER}/packages/nuget/${PACKAGE_NAME}/versions" \ | |
| --paginate --jq '.[] | "\(.id)|\(.name)"' 2>/dev/null) \ | |
| || GH_VERSIONS=$(gh api "/users/${OWNER}/packages/nuget/${PACKAGE_NAME}/versions" \ | |
| --paginate --jq '.[] | "\(.id)|\(.name)"' 2>/dev/null) \ | |
| || { echo "::warning::Failed to list GitHub Packages versions"; exit 0; } | |
| if [ -z "$GH_VERSIONS" ]; then | |
| echo "No GitHub Packages versions found" | |
| exit 0 | |
| fi | |
| # Build lookup: version -> id | |
| declare -A VERSION_IDS | |
| while IFS='|' read -r id name; do | |
| VERSION_IDS["$name"]="$id" | |
| done <<< "$GH_VERSIONS" | |
| echo "Found ${#VERSION_IDS[@]} versions on GitHub Packages" | |
| # Separate stable and pre-release | |
| STABLE=() | |
| PRERELEASE=() | |
| for v in "${!VERSION_IDS[@]}"; do | |
| if [[ "$v" == *-* ]]; then | |
| PRERELEASE+=("$v") | |
| else | |
| STABLE+=("$v") | |
| fi | |
| done | |
| # Find latest stable | |
| LATEST_STABLE="" | |
| if [ ${#STABLE[@]} -gt 0 ]; then | |
| LATEST_STABLE=$(printf '%s\n' "${STABLE[@]}" | sort -V | tail -n1) | |
| fi | |
| # Categorize | |
| SUPERSEDED=() | |
| CURRENT=() | |
| for v in "${PRERELEASE[@]}"; do | |
| base="${v%%-*}" | |
| if [ -n "$LATEST_STABLE" ]; then | |
| higher=$(printf '%s\n%s' "$base" "$LATEST_STABLE" | sort -V | tail -n1) | |
| if [ "$higher" = "$LATEST_STABLE" ] || [ "$base" = "$LATEST_STABLE" ]; then | |
| SUPERSEDED+=("$v") | |
| else | |
| CURRENT+=("$v") | |
| fi | |
| else | |
| CURRENT+=("$v") | |
| fi | |
| done | |
| # Build delete list | |
| TO_DELETE=() | |
| for v in "${SUPERSEDED[@]}"; do | |
| TO_DELETE+=("$v") | |
| done | |
| # Split current pre-releases by channel (alpha vs beta) | |
| CURRENT_ALPHA=() | |
| CURRENT_BETA=() | |
| for v in "${CURRENT[@]}"; do | |
| if [[ "$v" == *-beta.* ]]; then | |
| CURRENT_BETA+=("$v") | |
| else | |
| CURRENT_ALPHA+=("$v") | |
| fi | |
| done | |
| echo "Current alphas: ${#CURRENT_ALPHA[@]}, betas: ${#CURRENT_BETA[@]}" | |
| # Keep last N per channel (by SemVer sort) | |
| for channel_name in "alpha" "beta"; do | |
| declare -n channel_arr="CURRENT_${channel_name^^}" | |
| if [ ${#channel_arr[@]} -gt "$KEEP_LAST_N" ]; then | |
| mapfile -t sorted < <(printf '%s\n' "${channel_arr[@]}" | sort -V) | |
| remove_count=$(( ${#sorted[@]} - KEEP_LAST_N )) | |
| for ((i=0; i<remove_count; i++)); do | |
| TO_DELETE+=("${sorted[$i]}") | |
| done | |
| fi | |
| done | |
| echo "" | |
| echo "=== GitHub Packages: deleting ${#TO_DELETE[@]} versions ===" | |
| # Determine API path (org vs user) | |
| API_BASE="/orgs/${OWNER}/packages/nuget/${PACKAGE_NAME}/versions" | |
| gh api "$API_BASE" --silent 2>/dev/null \ | |
| || API_BASE="/users/${OWNER}/packages/nuget/${PACKAGE_NAME}/versions" | |
| for v in "${TO_DELETE[@]}"; do | |
| VID="${VERSION_IDS[$v]:-}" | |
| if [ -z "$VID" ]; then | |
| echo " Skipping $v (version ID not found)" | |
| continue | |
| fi | |
| echo " Deleting: $v (id: $VID)" | |
| gh api -X DELETE "${API_BASE}/${VID}" 2>&1 \ | |
| || echo " (already deleted or failed)" | |
| sleep 1 | |
| done | |
| echo "GitHub Packages cleanup complete" |