ci: verify shared credentials and clean historical GHCR package
GitHub Git mirror / mirror (push) Successful in 6s
GitHub Git mirror / mirror (push) Successful in 6s
This commit is contained in:
1 parent
67b8e64d6a
commit
5674ac0a09
1 file changed
+124
@@ -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
|
||||||
Reference in new issue
Block a user