mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Adds a new workflow to cleanup image tags which no longer have an associated branch
This commit is contained in:
		
							
								
								
									
										210
									
								
								.github/scripts/cleanup-tags.py
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										210
									
								
								.github/scripts/cleanup-tags.py
									
									
									
									
										vendored
									
									
										Executable file
									
								
							| @@ -0,0 +1,210 @@ | |||||||
|  | #!/usr/bin/env python3 | ||||||
|  | """ | ||||||
|  | When a feature branch is created, a new GitHub container is built and tagged | ||||||
|  | with the feature branch name.  When a feature branch is deleted, either through | ||||||
|  | a merge or deletion, the old image tag will still exist. | ||||||
|  |  | ||||||
|  | Though this isn't a problem for storage size, etc, it does lead to a long list | ||||||
|  | of tags which are no longer relevant and the last released version is pushed | ||||||
|  |  further and further down that list. | ||||||
|  |  | ||||||
|  | This script utlizes the GitHub API (through the gh cli application) to list the | ||||||
|  | package versions (aka tags) and the repository branches.  Then it removes feature | ||||||
|  | tags which have no matching branch | ||||||
|  |  | ||||||
|  | This pruning is applied to the primary package, the frontend builder package and the | ||||||
|  | frontend build cache package. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import argparse | ||||||
|  | import logging | ||||||
|  | import os.path | ||||||
|  | import pprint | ||||||
|  | from typing import Dict | ||||||
|  | from typing import Final | ||||||
|  | from typing import List | ||||||
|  |  | ||||||
|  | from common import get_log_level | ||||||
|  | from ghapi.all import GhApi | ||||||
|  | from ghapi.all import paged | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _get_feature_packages( | ||||||
|  |     logger: logging.Logger, | ||||||
|  |     api: GhApi, | ||||||
|  |     is_org_repo: bool, | ||||||
|  |     repo_owner: str, | ||||||
|  |     package_name: str, | ||||||
|  | ) -> Dict: | ||||||
|  |     """ | ||||||
|  |     Uses the GitHub packages API endpoint data filter to containers | ||||||
|  |     which have a tag starting with "feature-" | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     # Get all package versions | ||||||
|  |     pkg_versions = [] | ||||||
|  |     if is_org_repo: | ||||||
|  |  | ||||||
|  |         for pkg_version in paged( | ||||||
|  |             api.packages.get_all_package_versions_for_package_owned_by_org, | ||||||
|  |             org=repo_owner, | ||||||
|  |             package_type="container", | ||||||
|  |             package_name=package_name, | ||||||
|  |         ): | ||||||
|  |             pkg_versions.extend(pkg_version) | ||||||
|  |     else: | ||||||
|  |         for pkg_version in paged( | ||||||
|  |             api.packages.get_all_package_versions_for_package_owned_by_authenticated_user,  # noqa: E501 | ||||||
|  |             package_type="container", | ||||||
|  |             package_name=package_name, | ||||||
|  |         ): | ||||||
|  |             pkg_versions.extend(pkg_version) | ||||||
|  |  | ||||||
|  |     logger.debug(f"Found {len(pkg_versions)} package versions for {package_name}") | ||||||
|  |  | ||||||
|  |     # Filter to just those containers tagged "feature-" | ||||||
|  |     feature_versions = {} | ||||||
|  |  | ||||||
|  |     for item in pkg_versions: | ||||||
|  |         is_feature_version = False | ||||||
|  |         feature_tag_name = None | ||||||
|  |         if ( | ||||||
|  |             "metadata" in item | ||||||
|  |             and "container" in item["metadata"] | ||||||
|  |             and "tags" in item["metadata"]["container"] | ||||||
|  |         ): | ||||||
|  |             for tag in item["metadata"]["container"]["tags"]: | ||||||
|  |                 if tag.startswith("feature-"): | ||||||
|  |                     feature_tag_name = tag | ||||||
|  |                     is_feature_version = True | ||||||
|  |         if is_feature_version: | ||||||
|  |             logger.info( | ||||||
|  |                 f"Located feature tag: {feature_tag_name} for image {package_name}", | ||||||
|  |             ) | ||||||
|  |             # logger.debug(pprint.pformat(item, indent=2)) | ||||||
|  |             feature_versions[feature_tag_name] = item | ||||||
|  |         else: | ||||||
|  |             logger.debug(f"Filtered {pprint.pformat(item, indent=2)}") | ||||||
|  |  | ||||||
|  |     logger.info( | ||||||
|  |         f"Found {len(feature_versions)} package versions for" | ||||||
|  |         f" {package_name} with feature tags", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     return feature_versions | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _main(): | ||||||
|  |  | ||||||
|  |     parser = argparse.ArgumentParser( | ||||||
|  |         description="Using the GitHub API locate and optionally delete container" | ||||||
|  |         " tags which no longer have an associated feature branch", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--delete", | ||||||
|  |         action="store_true", | ||||||
|  |         default=False, | ||||||
|  |         help="If provided, actually delete the container tags", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--loglevel", | ||||||
|  |         default="info", | ||||||
|  |         help="Configures the logging level", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     args = parser.parse_args() | ||||||
|  |  | ||||||
|  |     logging.basicConfig( | ||||||
|  |         level=get_log_level(args), | ||||||
|  |         datefmt="%Y-%m-%d %H:%M:%S", | ||||||
|  |         format="%(asctime)s %(levelname)-8s %(message)s", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     logger = logging.getLogger("cleanup-tags") | ||||||
|  |  | ||||||
|  |     repo: Final[str] = os.environ["GITHUB_REPOSITORY"] | ||||||
|  |     repo_owner: Final[str] = os.environ["GITHUB_REPOSITORY_OWNER"] | ||||||
|  |  | ||||||
|  |     is_org_repo: Final[bool] = repo_owner == "paperless-ngx" | ||||||
|  |     dry_run: Final[bool] = not args.delete | ||||||
|  |  | ||||||
|  |     logger.debug(f"Org Repo? {is_org_repo}") | ||||||
|  |     logger.debug(f"Dry Run? {dry_run}") | ||||||
|  |  | ||||||
|  |     api = GhApi( | ||||||
|  |         owner=repo_owner, | ||||||
|  |         repo=os.path.basename(repo), | ||||||
|  |         token=os.environ["GITHUB_TOKEN"], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     pkg_list: Final[List[str]] = [ | ||||||
|  |         "paperless-ngx", | ||||||
|  |         # TODO: It would be nice to cleanup additional packages, but we can't | ||||||
|  |         # see https://github.com/fastai/ghapi/issues/84 | ||||||
|  |         # "builder/frontend", | ||||||
|  |         # "builder-frontend-cache", | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     # Get the list of current "feature-" branches | ||||||
|  |     feature_branch_info = api.list_branches(prefix="feature-") | ||||||
|  |     feature_branch_names = [] | ||||||
|  |     for branch in feature_branch_info: | ||||||
|  |         name_only = branch["ref"].removeprefix("refs/heads/") | ||||||
|  |         logger.info(f"Located feature branch: {name_only}") | ||||||
|  |         feature_branch_names.append(name_only) | ||||||
|  |  | ||||||
|  |     logger.info(f"Located {len(feature_branch_names)} feature branches") | ||||||
|  |  | ||||||
|  |     # TODO The deletion doesn't yet actually work | ||||||
|  |     # See https://github.com/fastai/ghapi/issues/132 | ||||||
|  |     # This would need to be updated to use gh cli app or requests or curl | ||||||
|  |     # or something | ||||||
|  |     if is_org_repo: | ||||||
|  |         endpoint = ( | ||||||
|  |             "https://api.github.com/orgs/{ORG}/packages/container/{name}/versions/{id}" | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         endpoint = "https://api.github.com/user/packages/container/{name}/{id}" | ||||||
|  |  | ||||||
|  |     for package_name in pkg_list: | ||||||
|  |  | ||||||
|  |         logger.info(f"Processing image {package_name}") | ||||||
|  |  | ||||||
|  |         # Get the list of images tagged with "feature-" | ||||||
|  |         feature_packages = _get_feature_packages( | ||||||
|  |             logger, | ||||||
|  |             api, | ||||||
|  |             is_org_repo, | ||||||
|  |             repo_owner, | ||||||
|  |             package_name, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Get the set of container tags without matching feature branches | ||||||
|  |         to_delete = list(set(feature_packages.keys()) - set(feature_branch_names)) | ||||||
|  |  | ||||||
|  |         for container_tag in to_delete: | ||||||
|  |             container_info = feature_packages[container_tag] | ||||||
|  |  | ||||||
|  |             formatted_endpoint = endpoint.format( | ||||||
|  |                 ORG=repo_owner, | ||||||
|  |                 name=package_name, | ||||||
|  |                 id=container_info["id"], | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if dry_run: | ||||||
|  |                 logger.info( | ||||||
|  |                     f"Would delete {package_name}:{container_tag} with" | ||||||
|  |                     f" id: {container_info['id']}", | ||||||
|  |                 ) | ||||||
|  |                 # logger.debug(formatted_endpoint) | ||||||
|  |             else: | ||||||
|  |                 logger.info( | ||||||
|  |                     f"Deleting {package_name}:{container_tag} with" | ||||||
|  |                     f" id: {container_info['id']}", | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     _main() | ||||||
							
								
								
									
										17
									
								
								.github/scripts/common.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								.github/scripts/common.py
									
									
									
									
										vendored
									
									
								
							| @@ -1,4 +1,6 @@ | |||||||
| #!/usr/bin/env python3 | #!/usr/bin/env python3 | ||||||
|  | import logging | ||||||
|  | from argparse import ArgumentError | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_image_tag( | def get_image_tag( | ||||||
| @@ -25,3 +27,18 @@ def get_cache_image_tag( | |||||||
|     rebuilds, generally almost instant for the same version |     rebuilds, generally almost instant for the same version | ||||||
|     """ |     """ | ||||||
|     return f"ghcr.io/{repo_name}/builder/cache/{pkg_name}:{pkg_version}" |     return f"ghcr.io/{repo_name}/builder/cache/{pkg_name}:{pkg_version}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_log_level(args) -> int: | ||||||
|  |     levels = { | ||||||
|  |         "critical": logging.CRITICAL, | ||||||
|  |         "error": logging.ERROR, | ||||||
|  |         "warn": logging.WARNING, | ||||||
|  |         "warning": logging.WARNING, | ||||||
|  |         "info": logging.INFO, | ||||||
|  |         "debug": logging.DEBUG, | ||||||
|  |     } | ||||||
|  |     level = levels.get(args.loglevel.lower()) | ||||||
|  |     if level is None: | ||||||
|  |         raise ArgumentError(f"{args.loglevel} is not a valid level") | ||||||
|  |     return level | ||||||
|   | |||||||
							
								
								
									
										48
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | name: Cleanup Image Tags | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   schedule: | ||||||
|  |     - cron: '0 0 * * SAT' | ||||||
|  |   delete: | ||||||
|  |   pull_request: | ||||||
|  |     types: | ||||||
|  |       - closed | ||||||
|  |   push: | ||||||
|  |     paths: | ||||||
|  |       - ".github/workflows/cleanup-tags.yml" | ||||||
|  |       - ".github/scripts/cleanup-tags.py" | ||||||
|  |       - ".github/scripts/common.py" | ||||||
|  |  | ||||||
|  | env: | ||||||
|  |   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   cleanup: | ||||||
|  |     name: Cleanup Image Tags | ||||||
|  |     runs-on: ubuntu-20.04 | ||||||
|  |     permissions: | ||||||
|  |       packages: write | ||||||
|  |     steps: | ||||||
|  |       - | ||||||
|  |         name: Checkout | ||||||
|  |         uses: actions/checkout@v3 | ||||||
|  |       - | ||||||
|  |         name: Login to Github Container Registry | ||||||
|  |         uses: docker/login-action@v1 | ||||||
|  |         with: | ||||||
|  |           registry: ghcr.io | ||||||
|  |           username: ${{ github.actor }} | ||||||
|  |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |       - | ||||||
|  |         name: Set up Python | ||||||
|  |         uses: actions/setup-python@v3 | ||||||
|  |         with: | ||||||
|  |           python-version: "3.9" | ||||||
|  |       - | ||||||
|  |         name: Install fastai GitHub API | ||||||
|  |         run: | | ||||||
|  |           python -m pip install ghapi requests | ||||||
|  |       - | ||||||
|  |         name: Cleanup feature tags | ||||||
|  |         run: | | ||||||
|  |           python ${GITHUB_WORKSPACE}/.github/scripts/cleanup-tags.py --loglevel info | ||||||
		Reference in New Issue
	
	Block a user
	 Trenton Holmes
					Trenton Holmes