feat: add verified and resumable Gitea release synchronization

This commit is contained in:
Release Archive
2026-09-22 11:20:16 +08:00
parent 92634afb07
commit 90679f8816
4 changed files with 445 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
name: Daily Release Archive
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Only inspect and report; do not write releases'
default: 'true'
required: true
tag:
description: 'Optional source tag; empty means all published releases'
default: ''
required: false
verify_all:
description: 'Full verification (also performed by default)'
default: 'true'
required: false
concurrency:
group: daily-release-archive
cancel-in-progress: false
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout trusted synchronization code
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Test synchronization logic
run: python3 -m unittest discover -s tests -v
- name: Synchronize and verify releases
env:
SOURCE_GITEA_TOKEN: ${{ secrets.SOURCE_GITEA_TOKEN }}
TARGET_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
ARCHIVE_ANCHOR_SHA: '92634afb073b81216388c9288cbf275ba1e11a2c'
SYNC_DRY_RUN: ${{ github.event.inputs.dry_run }}
SYNC_TAG: ${{ github.event.inputs.tag }}
run: |
set -eu
args=()
if [ "${SYNC_DRY_RUN:-false}" = "true" ]; then args+=(--dry-run); fi
if [ -n "${SYNC_TAG:-}" ]; then args+=(--tag "$SYNC_TAG"); fi
python3 scripts/sync_releases.py "${args[@]}"
- name: Save synchronization report
if: always()
uses: https://github.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: release-sync-report
path: sync-report.json
if-no-files-found: warn
retention-days: 90
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
sync-report.json
*.token
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Archive Gitea releases. Standard library only; never executes source content."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
MAX_BYTES = 512 * 1024 * 1024
MARKER = '<!-- release-sync: git.bunnyruihan.com/ruihan/yidaconnector -->'
class SyncError(Exception):
pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class API:
def __init__(self, base, repo, token):
self.base, self.repo, self.token = base.rstrip('/'), repo, token
self.opener = urllib.request.build_opener(NoRedirect)
def request(self, method, path, body=None, binary=False, content_type=None):
url = self.base + '/api/v1/repos/' + self.repo + path
payload = body if binary else (json.dumps(body).encode() if body is not None else None)
headers = {'Authorization': 'token ' + self.token, 'Accept': 'application/json'}
if payload is not None:
headers['Content-Type'] = content_type or 'application/json'
for attempt in range(3):
try:
with self.opener.open(urllib.request.Request(url, data=payload, headers=headers, method=method), timeout=60) as response:
data = response.read()
return json.loads(data) if data else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == 'GET':
return None
if method == 'GET' and e.code in (429, 500, 502, 503, 504) and attempt < 2:
time.sleep(2 ** attempt)
continue
raise SyncError(f'{method} {path.split("?")[0]} HTTP {e.code}') from None
except (urllib.error.URLError, TimeoutError, OSError):
if method == 'GET' and attempt < 2:
time.sleep(2 ** attempt)
continue
raise SyncError(f'{method} request failed; retry the workflow to reconcile state') from None
def pages(self, path):
result = []
for page in range(1, 10001):
data = self.request('GET', path + ('&' if '?' in path else '?') + f'limit=50&page={page}')
if not isinstance(data, list):
raise SyncError('Expected paginated list: ' + path)
if not data:
return result
result.extend(data)
raise SyncError('Pagination limit exceeded')
def download(self, asset, destination):
url = asset['browser_download_url']
origin = urllib.parse.urlsplit(self.base)
for attempt in range(3):
current = url
try:
for _ in range(5):
parsed = urllib.parse.urlsplit(current)
if parsed.scheme != 'https' or parsed.netloc != origin.netloc or parsed.username:
raise SyncError('Attachment URL or redirect is outside the approved HTTPS origin')
req = urllib.request.Request(current, headers={'Authorization': 'token ' + self.token})
try:
with self.opener.open(req, timeout=60) as response, open(destination, 'wb') as out:
count = 0
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
count += len(chunk)
if count > MAX_BYTES:
raise SyncError('Attachment exceeds 512 MiB limit')
out.write(chunk)
validate_file(destination, asset)
return digest(destination)
except urllib.error.HTTPError as e:
if e.code in (301, 302, 303, 307, 308) and e.headers.get('Location'):
current = urllib.parse.urljoin(current, e.headers['Location'])
continue
if e.code in (401, 403, 404):
raise SyncError(f'Attachment download HTTP {e.code}') from None
raise
raise SyncError('Too many attachment redirects')
except (urllib.error.URLError, TimeoutError, OSError):
if attempt == 2:
raise SyncError('Attachment download failed after retries') from None
time.sleep(2 ** attempt)
def digest(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1024 * 1024), b''):
h.update(block)
return h.hexdigest()
def validate_file(path, asset):
if Path(path).stat().st_size != asset['size']:
raise SyncError('Attachment size mismatch: ' + asset['name'])
if asset['name'].lower().endswith('.zip'):
try:
with zipfile.ZipFile(path) as z:
if sum(i.file_size for i in z.infolist()) > 2 * 1024 ** 3:
raise SyncError('ZIP expanded size exceeds verification limit')
if z.testzip() is not None:
raise SyncError('ZIP CRC mismatch')
except (zipfile.BadZipFile, RuntimeError, NotImplementedError):
raise SyncError('Invalid or unsupported ZIP: ' + asset['name']) from None
def validate_names(assets):
names = [a['name'] for a in assets]
if len(names) != len(set(names)):
raise SyncError('Source has duplicate attachment names')
for a in assets:
if not a['name'] or '/' in a['name'] or '\\' in a['name'] or a['name'].startswith(('.sync-', '.backup-')):
raise SyncError('Unsafe or reserved attachment name')
if a['size'] < 0 or a['size'] > MAX_BYTES:
raise SyncError('Attachment size outside configured limit')
def release_body(source, source_url, hashes, source_sha):
notes = source.get('body') or ''
return notes + '\n\n---\n' + MARKER + '\n' + (
'本仓库仅归档发布附件;自动生成的 Source Code 压缩包不是原项目源码。\n\n'
f'来源:{source_url}\n\n源标签对应提交:`{source_sha}`\n\n'
f'原站发布时间:{source.get("published_at") or source.get("created_at") or "unknown"}\n\n'
'已校验附件 SHA-256\n\n```text\n' +
'\n'.join(h + ' ' + name for name, h in sorted(hashes.items())) + '\n```\n'
)
def sync_one(src, dst, source, anchor, dry_run, directory):
tag = source['tag_name']
key = urllib.parse.quote(tag, safe='')
source_assets = src.pages(f'/releases/{source["id"]}/assets')
validate_names(source_assets)
source_tag = src.request('GET', '/tags/' + key)
if not source_tag or not source_tag.get('commit', {}).get('sha'):
raise SyncError('Source tag commit cannot be resolved')
source_sha = source_tag['commit']['sha']
hashes, files = {}, {}
for a in source_assets:
p = Path(directory) / ('source-' + str(a['id']))
hashes[a['name']] = src.download(a, p)
files[a['name']] = p
fresh = src.pages(f'/releases/{source["id"]}/assets')
identity = lambda rows: sorted((a['id'], a['name'], a['size']) for a in rows)
if identity(fresh) != identity(source_assets):
raise SyncError('Source attachment list changed during download; retry later')
target = dst.request('GET', '/releases/tags/' + key)
source_url = src.base + '/' + src.repo + '/releases/tag/' + key
body = release_body(source, source_url, hashes, source_sha)
desired = {'name': source.get('name') or tag, 'body': body, 'prerelease': source.get('prerelease', False)}
if target and MARKER not in (target.get('body') or ''):
raise SyncError('Target release exists but is not managed by this synchronizer')
result = {'tag': tag, 'source_sha': source_sha, 'created': target is None, 'assets': [], 'metadata_changed': False}
if target is None and not dry_run:
target = dst.request('POST', '/releases', dict(desired, tag_name=tag, target_commitish=anchor, draft=True))
assets = dst.pages(f'/releases/{target["id"]}/assets') if target else []
for source_asset in source_assets:
name, sha = source_asset['name'], hashes[source_asset['name']]
existing = next((a for a in assets if a['name'] == name), None)
old_sha = None
if existing:
old_sha = dst.download(existing, Path(directory) / ('target-' + str(existing['id'])))
if old_sha == sha:
result['assets'].append({'name': name, 'sha256': sha, 'action': 'unchanged'})
continue
action = 'replace' if existing else 'add'
if not dry_run:
rid = target['id']
# Recover an upload accepted by the server before a previous connection failed.
temp_name = '.sync-' + hashlib.sha256((name + sha).encode()).hexdigest()[:24]
staging = next((a for a in assets if a['name'] == temp_name), None)
if staging is None:
staging = dst.request('POST', f'/releases/{rid}/assets?name=' + urllib.parse.quote(temp_name, safe=''),
files[name].read_bytes(), binary=True, content_type='application/octet-stream')
assets.append(staging)
staging_sha = dst.download(staging, Path(directory) / ('staged-' + str(staging['id'])))
if staging_sha != sha:
raise SyncError('Uploaded attachment SHA-256 mismatch')
if existing:
backup_name = '.backup-' + str(existing['id']) + '-' + name
dst.request('PATCH', f'/releases/{rid}/assets/{existing["id"]}', {'name': backup_name})
existing['name'] = backup_name
dst.request('PATCH', f'/releases/{rid}/assets/{staging["id"]}', {'name': name})
staging['name'] = name
result['assets'].append({'name': name, 'sha256': sha, 'action': action})
if target:
changed = target.get('draft', False) or any(target.get(k) != v for k, v in desired.items())
result['metadata_changed'] = changed
if changed and not dry_run:
dst.request('PATCH', f'/releases/{target["id"]}', dict(desired, draft=False))
else:
result['metadata_changed'] = True
return result
def main():
p = argparse.ArgumentParser()
p.add_argument('--dry-run', action='store_true')
p.add_argument('--tag', default='')
p.add_argument('--verify-all', action='store_true', help='All attachments are always fully verified')
p.add_argument('--report', default='sync-report.json')
args = p.parse_args()
report = {'dry_run': args.dry_run, 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'releases': [], 'errors': []}
try:
src = API('https://git.bunnyruihan.com:16666', 'ruihan/yidaconnector', os.environ['SOURCE_GITEA_TOKEN'])
dst = API('https://git.jskdjs.cn', 'ruihan/yidaconnector', os.environ['TARGET_GITEA_TOKEN'])
anchor = os.environ['ARCHIVE_ANCHOR_SHA']
if not re.fullmatch('[0-9a-f]{40}', anchor):
raise SyncError('Invalid archive anchor SHA')
info = dst.request('GET', '')
if not info or info['full_name'] != dst.repo or not info['private']:
raise SyncError('Expected private target archive repository')
releases = [r for r in src.pages('/releases') if not r.get('draft')]
if args.tag:
releases = [r for r in releases if r['tag_name'] == args.tag]
if not releases:
raise SyncError('Requested published source tag not found')
for source in reversed(releases):
try:
with tempfile.TemporaryDirectory(prefix='release-sync-') as directory:
result = sync_one(src, dst, source, anchor, args.dry_run, directory)
report['releases'].append(result)
print(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as e:
# Do not render untrusted response bodies, request headers or tokens.
message = str(e) if isinstance(e, SyncError) else type(e).__name__
report['errors'].append({'tag': source['tag_name'], 'error': message})
print('FAILED ' + source['tag_name'] + ': ' + message, flush=True)
except Exception as e:
report['errors'].append({'error': str(e) if isinstance(e, SyncError) else type(e).__name__})
finally:
report['finished_at'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(f'SUMMARY releases={len(report["releases"])} errors={len(report["errors"])} dry_run={args.dry_run}')
return 1 if report['errors'] else 0
if __name__ == '__main__':
raise SystemExit(main())
+123
View File
@@ -0,0 +1,123 @@
import copy
import io
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
from sync_releases import API, MARKER, SyncError, digest, sync_one, validate_file, validate_names
def zip_bytes(text):
b = io.BytesIO()
with zipfile.ZipFile(b, 'w') as z:
z.writestr('README.txt', text)
return b.getvalue()
class Fake:
base = 'https://source.invalid'
repo = 'owner/repo'
def __init__(self, source=False):
self.assets = []
self.data = {}
self.release = {'id': 1, 'tag_name': 'v1', 'name': 'one', 'body': 'notes', 'draft': False} if source else None
self.writes = []
self.fail_rename = False
def add(self, name, content):
i = len(self.data) + 1
a = {'id': i, 'name': name, 'size': len(content)}
self.assets.append(a)
self.data[i] = content
return copy.deepcopy(a)
def pages(self, path):
return copy.deepcopy(self.assets)
def download(self, a, p):
Path(p).write_bytes(self.data[a['id']])
validate_file(p, a)
return digest(p)
def request(self, method, path, body=None, **kwargs):
if method == 'GET':
return {'commit': {'sha': 'a' * 40}} if path.startswith('/tags/') else copy.deepcopy(self.release)
self.writes.append((method, path))
if method == 'POST' and path == '/releases':
self.release = dict(body, id=2)
return copy.deepcopy(self.release)
if method == 'POST':
from urllib.parse import unquote
return self.add(unquote(path.split('name=')[1]), body)
if '/assets/' in path:
if self.fail_rename and body['name'] == 'app.zip':
self.fail_rename = False
raise SyncError('simulated interrupted rename')
item = next(a for a in self.assets if a['id'] == int(path.rsplit('/', 1)[1]))
item.update(body)
return copy.deepcopy(item)
self.release.update(body)
return copy.deepcopy(self.release)
class Tests(unittest.TestCase):
def setUp(self):
self.src, self.dst = Fake(True), Fake()
self.src.add('app.zip', zip_bytes('version 1'))
def run_sync(self, dry=False):
with tempfile.TemporaryDirectory() as d:
return sync_one(self.src, self.dst, self.src.release, 'a' * 40, dry, d)
def test_dry_run_writes_nothing(self):
self.run_sync(True)
self.assertEqual([], self.dst.writes)
def test_new_release_published_only_after_verified_assets(self):
self.run_sync()
self.assertFalse(self.dst.release['draft'])
self.assertEqual('app.zip', self.dst.assets[0]['name'])
self.assertEqual(('PATCH', '/releases/2'), self.dst.writes[-1])
def test_repeat_is_noop(self):
self.run_sync()
self.dst.writes.clear()
result = self.run_sync()
self.assertEqual([], self.dst.writes)
self.assertEqual('unchanged', result['assets'][0]['action'])
def test_same_name_same_size_different_bytes_is_detected(self):
self.run_sync()
self.src.data[1] = zip_bytes('version 2')
result = self.run_sync()
self.assertEqual('replace', result['assets'][0]['action'])
self.assertTrue(any(a['name'].startswith('.backup-') for a in self.dst.assets))
def test_interrupted_swap_recovers(self):
self.run_sync()
self.src.data[1] = zip_bytes('version 2')
self.dst.fail_rename = True
with self.assertRaises(SyncError): self.run_sync()
self.run_sync()
self.assertEqual(1, sum(a['name'] == 'app.zip' for a in self.dst.assets))
self.assertEqual(2, len(self.dst.assets))
def test_unmanaged_release_never_overwritten(self):
self.dst.release = dict(self.src.release)
with self.assertRaises(SyncError): self.run_sync()
self.assertEqual([], self.dst.writes)
def test_deleted_source_asset_retained(self):
self.run_sync()
self.src.assets.clear()
self.run_sync()
self.assertEqual(1, len(self.dst.assets))
def test_html_error_not_zip(self):
with tempfile.TemporaryDirectory() as d:
p = Path(d) / 'bad'; p.write_bytes(b'Not found.\n')
with self.assertRaises(SyncError): validate_file(p, {'size': 11, 'name': 'bad.zip'})
def test_unsafe_names_rejected(self):
for name in ['../secret', '.sync-x', '.backup-x', 'a\\b']:
with self.assertRaises(SyncError): validate_names([{'name': name, 'size': 1}])
def test_pagination_does_not_assume_server_page_size(self):
api = API('https://example.invalid', 'a/b', '')
pages = iter([[1, 2], [3], []])
api.request = lambda *a: next(pages)
self.assertEqual([1, 2, 3], api.pages('/releases'))
def test_cross_origin_download_rejected_before_request(self):
api = API('https://example.invalid', 'a/b', 'secret')
with self.assertRaises(SyncError):
api.download({'browser_download_url': 'https://other.invalid/file'}, '/tmp/unused')
if __name__ == '__main__': unittest.main()