8 changed files with 11797 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2018 GitHub, Inc. and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,5 @@
Vendored official actions/upload-artifact v3.1.3 (Gitea legacy artifact API).
Source: https://github.com/actions/upload-artifact/tree/a8a3f3ad30e3422c9c7b888a15615d19a852ae32
Only action.yml, LICENSE and the bundled dist/index.js are included.
The action runtime declaration is updated from node16 to node20, using the installed runner runtime.
Vendoring avoids a GitHub network dependency on private-network runners.
+28
View File
@@ -0,0 +1,28 @@
name: 'Upload a Build Artifact'
description: 'Upload a build artifact that can be used by subsequent workflow steps'
author: 'GitHub'
inputs:
name:
description: 'Artifact name'
default: 'artifact'
path:
description: 'A file, directory or wildcard pattern that describes what to upload'
required: true
if-no-files-found:
description: >
The desired behavior if no files are found using the provided path.
Available Options:
warn: Output a warning but do not fail the action
error: Fail the action with an error message
ignore: Do not output any warnings or errors, the action does not fail
default: 'warn'
retention-days:
description: >
Duration after which artifact will expire in days. 0 means using default retention.
Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
runs:
using: 'node20'
main: 'dist/index.js'
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
name: Daily Release Archive
on:
schedule:
- cron: "0 19 * * *" # 03:00 Asia/Shanghai
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: Fetch exact workflow revision from this Gitea
env:
TARGET_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
WORKFLOW_SHA: ${{ github.sha }}
run: |
python3 - <<'PY'
import io, os, re, shutil, tarfile, tempfile, urllib.request
sha = os.environ['WORKFLOW_SHA']
assert re.fullmatch('[0-9a-f]{40}', sha)
url = 'https://git.jskdjs.cn/api/v1/repos/ruihan/yidaconnector/archive/' + sha + '.tar.gz'
req = urllib.request.Request(url, headers={'Authorization': 'token ' + os.environ['TARGET_GITEA_TOKEN']})
with urllib.request.urlopen(req, timeout=60) as r:
payload = r.read()
with tempfile.TemporaryDirectory() as d:
with tarfile.open(fileobj=io.BytesIO(payload), mode='r:gz') as t:
t.extractall(d, filter='data')
root = os.path.join(d, os.listdir(d)[0])
shutil.copytree(root, '.', dirs_exist_ok=True)
PY
- 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: ./.gitea/actions/upload-artifact
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
+268
View File
@@ -0,0 +1,268 @@
#!/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 list_assets(self, release_id):
# Gitea's assets endpoint is NOT paginated; page/limit are ignored.
data = self.request('GET', f'/releases/{release_id}/assets')
if not isinstance(data, list):
raise SyncError('Expected attachment list')
return data
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.list_assets(source['id'])
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.list_assets(source['id'])
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.list_assets(target['id']) 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())
+134
View File
@@ -0,0 +1,134 @@
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 list_assets(self, release_id):
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')
def test_assets_endpoint_is_not_paginated(self):
api = API('https://example.invalid', 'a/b', '')
calls = []
def request(method, path):
calls.append(path)
return [{'id': 1}]
api.request = request
self.assertEqual([{'id': 1}], api.list_assets(2))
self.assertEqual(['/releases/2/assets'], calls)
if __name__ == '__main__': unittest.main()