diff --git a/.gitea/workflows/cleanup-ghcr.yml b/.gitea/workflows/cleanup-ghcr.yml new file mode 100644 index 0000000..6571cc3 --- /dev/null +++ b/.gitea/workflows/cleanup-ghcr.yml @@ -0,0 +1,124 @@ +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