Skip to content

Instantly share code, notes, and snippets.

@widnyana
Created February 10, 2026 05:49
Show Gist options
  • Select an option

  • Save widnyana/8853cef9cdbdf3d8264f012ea53431c7 to your computer and use it in GitHub Desktop.

Select an option

Save widnyana/8853cef9cdbdf3d8264f012ea53431c7 to your computer and use it in GitHub Desktop.
List GitHub container package versions (images) created in the last N days & delete it
#!/usr/bin/env python3
"""
List GitHub container package versions (images) created in the last N days.
"""
import argparse
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
import requests
def get_package_versions(token: str, org: str, package_name: str, days: int | None = 5) -> List[Dict[str, Any]]:
"""
Fetch container package versions from GitHub Packages API.
Args:
token: GitHub personal access token
org: Organization name
package_name: Container package name
days: Number of days to look back (None = no filter)
Returns:
List of package versions with tag, id, and age
"""
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {token}",
}
url = f"https://api.github.com/orgs/{org}/packages/container/{package_name}/versions"
response = requests.get(url, headers=headers)
response.raise_for_status()
all_versions = response.json()
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days) if days else None
filtered_versions = []
for version in all_versions:
created_at = datetime.strptime(version["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
if cutoff_date is None or created_at >= cutoff_date:
age = datetime.now(timezone.utc) - created_at
# Get container tags (image tags)
tags = version.get("metadata", {}).get("container", {}).get("tags", [])
if isinstance(tags, list):
tag_list = [t if isinstance(t, str) else str(t) for t in tags]
tag_str = ", ".join(tag_list) if tag_list else "(no tags)"
else:
tag_str = "(no tags)"
filtered_versions.append(
{
"id": version["id"],
"tags": tag_str,
"tag_list": tag_list if isinstance(tags, list) and tags else [],
"created_at": version["created_at"],
"age": age,
}
)
return filtered_versions
def delete_package_version(token: str, org: str, package_name: str, version_id: int) -> bool:
"""
Delete a specific package version.
Args:
token: GitHub personal access token
org: Organization name
package_name: Container package name
version_id: Version ID to delete
Returns:
True if deleted successfully
"""
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {token}",
}
url = f"https://api.github.com/orgs/{org}/packages/container/{package_name}/versions/{version_id}"
response = requests.delete(url, headers=headers)
response.raise_for_status()
return response.status_code == 204
def format_age(timedelta_obj: timedelta) -> str:
"""Format timedelta into human-readable string."""
total_seconds = int(timedelta_obj.total_seconds())
days_count = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
if days_count > 0:
return f"{days_count}d {hours}h"
elif hours > 0:
return f"{hours}h {minutes}m"
else:
return f"{minutes}m"
def main():
parser = argparse.ArgumentParser(description="List GitHub container package versions")
parser.add_argument("package", help="Container package name (e.g., appreal-backend)")
parser.add_argument("--org", default="project-appreal", help="Organization name (default: project-appreal)")
parser.add_argument("--all", action="store_true", help="Show all versions, no date filter")
parser.add_argument("--days", type=int, default=5, help="Number of days to look back (default: 5)")
parser.add_argument("--delete", action="store_true", help="Delete the versions found by the query")
args = parser.parse_args()
token = os.getenv("GITHUB_TOKEN", "")
if not token:
raise ValueError("GITHUB_TOKEN must be defined.")
days = None if args.all else args.days
try:
versions = get_package_versions(token, args.org, args.package, days)
if not versions:
if args.all:
print(f"No versions found for package '{args.package}'.")
else:
print(f"No versions found in the last {days} days for package '{args.package}'.")
return
if args.all:
print(f"All versions of '{args.package}':")
else:
print(f"Versions of '{args.package}' updated in the last {days} days:")
print("-" * 80)
print(f"{'ID':<12} {'Tags':<30} {'Age':<12} {'Created At'}")
print("-" * 80)
for v in sorted(versions, key=lambda x: x["id"]):
print(f"{v['id']:<12} {v['tags']:<30} {format_age(v['age']):<12} {v['created_at']}")
# Delete mode
if args.delete:
print()
confirm = input(f"Delete {len(versions)} version(s)? [y/N]: ")
if confirm.lower() == "y":
for v in sorted(versions, key=lambda x: x["id"]):
try:
print(f"Deleting version {v['id']} ({v['tags']})...", end=" ")
if delete_package_version(token, args.org, args.package, v["id"]):
print("OK")
else:
print("FAILED")
except requests.exceptions.HTTPError as e:
status = e.response.status_code
reason = ""
if status == 401:
reason = " - Unauthorized: Invalid token or insufficient permissions"
elif status == 403:
reason = " - Forbidden: Token missing delete:packages scope or not an admin"
elif status == 404:
reason = " - Not found: Version may have been already deleted"
print(f"FAILED - HTTP {status}{reason}")
except Exception as e:
print(f"FAILED - {e}")
else:
print("Aborted.")
except requests.exceptions.HTTPError as e:
print(f"HTTP Error: {e}")
if e.response.status_code == 401:
print("Invalid token. Please check your GitHub personal access token.")
elif e.response.status_code == 404:
print(f"Package '{args.package}' not found in organization '{args.org}'.")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment