name: Authorized RawMark GHCR cleanup on: workflow_dispatch: permissions: contents: read jobs: cleanup: runs-on: docker timeout-minutes: 15 container: image: docker:29-cli volumes: - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark steps: - name: Install Python run: apk add --no-cache python3 - name: Verify shared token and remove only the authorized RawMark package env: ALL_GITHUB_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }} run: | python3 - <<'PYTHON' import fcntl import json import os import urllib.error import urllib.request from datetime import datetime, timezone TOKEN = os.environ['ALL_GITHUB_TOKEN'] if not TOKEN: raise RuntimeError('Missing account-level ALL_GITHUB_TOKEN') BASE = 'https://api.github.com' def api(path, method='GET'): request = urllib.request.Request(BASE + path, method=method, headers={ 'Authorization': 'Bearer ' + TOKEN, 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'RawMark-authorized-package-cleanup', }) try: with urllib.request.urlopen(request, timeout=45) as response: raw = response.read() return response.status, response.headers, json.loads(raw) if raw else None except urllib.error.HTTPError as error: raise RuntimeError(f'GitHub {method} {path} failed: HTTP {error.code}') from None def pages(path): rows = [] for page in range(1, 1001): separator = '&' if '?' in path else '?' status, _, batch = api(f'{path}{separator}per_page=100&page={page}') if status != 200 or not isinstance(batch, list): raise RuntimeError('Invalid paginated GitHub response') rows.extend(batch) if len(batch) < 100: return rows raise RuntimeError('Pagination exceeded the safe limit') def targets(): return [package for package in pages('/user/packages?package_type=container') if package['name'].lower() == 'rawmark'] def snapshot(package): if package['owner']['login'].lower() != 'eezd' or package['package_type'] != 'container': raise RuntimeError('Unexpected package owner or type') repository = package.get('repository') if repository and repository['full_name'].lower() != 'eezd/rawmark': raise RuntimeError('Package is linked to an unexpected repository') return { 'id': package['id'], 'name': package['name'], 'visibility': package['visibility'], 'versions': [{'id': version['id'], 'name': version['name'], 'tags': version.get('metadata', {}).get('container', {}).get('tags', [])} for version in pages('/user/packages/container/rawmark/versions')], } with open('/docker/gitea/mirrors/RawMark/.mirror.lock', 'a') as lock: fcntl.flock(lock, fcntl.LOCK_EX) _, headers, user = api('/user') if user['login'].lower() != 'eezd': raise RuntimeError('ALL_GITHUB_TOKEN must belong to eezd') scopes = {scope.strip() for scope in headers.get('X-OAuth-Scopes', '').split(',')} permissions = { 'repository_scope': 'repo' in scopes, 'workflow_scope': 'workflow' in scopes, 'read_packages': bool(scopes & {'read:packages', 'write:packages'}), 'write_packages': 'write:packages' in scopes, 'delete_packages': 'delete:packages' in scopes, } print('Verified token identity and declared permissions: ' + json.dumps({'login': user['login'], **permissions}), flush=True) if not permissions['read_packages'] or not permissions['delete_packages']: raise RuntimeError('GHCR cleanup requires read:packages and delete:packages') _, _, repository = api('/repos/eezd/RawMark') if not repository.get('permissions', {}).get('push'): raise RuntimeError('ALL_GITHUB_TOKEN lacks RawMark repository write access') packages = targets() if len(packages) > 1: raise RuntimeError('Multiple matching packages require investigation') before = snapshot(packages[0]) if packages else None print('Authorized GHCR cleanup inventory: ' + json.dumps(before), flush=True) deleted = False if before: latest = targets() if len(latest) != 1 or snapshot(latest[0]) != before: raise RuntimeError('Package inventory changed before deletion') status, _, _ = api('/user/packages/container/rawmark', 'DELETE') if status != 204: raise RuntimeError('Package deletion did not return HTTP 204') deleted = True if targets(): raise RuntimeError('RawMark GHCR package still appears after cleanup') releases = pages('/repos/eezd/RawMark/releases') tags = pages('/repos/eezd/RawMark/tags') if releases or tags: raise RuntimeError('Unexpected RawMark releases or tags remain') result = { 'checked_at': datetime.now(timezone.utc).isoformat(), 'owner': user['login'], 'package': 'rawmark', 'permissions': permissions, 'repository_write_verified': True, 'before': before, 'deleted': deleted, 'remaining_packages': 0, 'releases': 0, 'tags': 0, } print('RAWMARK_CLEANUP_RESULT=' + json.dumps(result, sort_keys=True), flush=True) PYTHON