441 lines
17 KiB
Python
441 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""Synchronize allowlisted skills from their latest Gitea Release assets."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import sys
|
||
import tempfile
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import zipfile
|
||
from pathlib import Path, PurePosixPath
|
||
from typing import Any
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_SOURCES = REPO_ROOT / ".dist-sync" / "sources.json"
|
||
DEFAULT_VERSIONS = REPO_ROOT / ".dist-sync" / "versions.json"
|
||
MAX_ASSET_BYTES = 250 * 1024 * 1024
|
||
MAX_API_BYTES = 5 * 1024 * 1024
|
||
MAX_ARCHIVE_FILES = 10_000
|
||
MAX_ARCHIVE_DEPTH = 32
|
||
MAX_ARCHIVE_PATH_LENGTH = 1024
|
||
TAG_PATTERN = re.compile(r"v[0-9]+(?:\.[0-9]+)*")
|
||
|
||
|
||
class SyncError(RuntimeError):
|
||
"""A source exists but cannot be synchronized safely."""
|
||
|
||
|
||
def log(message: str) -> None:
|
||
print(message, flush=True)
|
||
|
||
|
||
def read_json(path: Path) -> Any:
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError) as error:
|
||
raise SyncError(f"无法读取 JSON:{path}: {error}") from error
|
||
|
||
|
||
def write_json_atomic(path: Path, value: Any) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
encoded = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||
temporary: Path | None = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(prefix=f".{path.name}.", dir=path.parent, delete=False) as output:
|
||
temporary = Path(output.name)
|
||
output.write(encoded)
|
||
output.flush()
|
||
os.fsync(output.fileno())
|
||
os.replace(temporary, path)
|
||
except OSError as error:
|
||
if temporary is not None:
|
||
temporary.unlink(missing_ok=True)
|
||
raise SyncError(f"无法原子更新 JSON:{path}: {error}") from error
|
||
|
||
|
||
def validate_source(raw: Any) -> dict[str, str]:
|
||
if not isinstance(raw, dict):
|
||
raise SyncError("sources.json 的每个来源必须是对象")
|
||
|
||
source: dict[str, str] = {}
|
||
for field in ("name", "repository", "asset"):
|
||
value = raw.get(field)
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise SyncError(f"来源缺少有效字段:{field}")
|
||
source[field] = value.strip()
|
||
|
||
name = source["name"]
|
||
repository = source["repository"]
|
||
asset = source["asset"]
|
||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
||
raise SyncError(f"无效 skill 名称:{name}")
|
||
owner, repo = repository.split("/", 1) if "/" in repository else ("", "")
|
||
repository_part = re.compile(r"[A-Za-z0-9_.-]+")
|
||
if not owner or not repo or not repository_part.fullmatch(owner) or not repository_part.fullmatch(repo):
|
||
raise SyncError(f"无效仓库名称:{repository}")
|
||
if Path(asset).name != asset:
|
||
raise SyncError(f"附件名称不能包含路径:{asset}")
|
||
return source
|
||
|
||
|
||
def canonical_origin(url: str) -> tuple[str, str, int | None]:
|
||
parsed = urllib.parse.urlsplit(url)
|
||
scheme = parsed.scheme.lower()
|
||
hostname = parsed.hostname.lower() if parsed.hostname else ""
|
||
try:
|
||
port = parsed.port
|
||
except ValueError as error:
|
||
raise SyncError(f"URL 端口无效:{url}") from error
|
||
if scheme not in {"http", "https"} or not hostname:
|
||
raise SyncError(f"URL 必须是有效的 HTTP(S) 地址:{url}")
|
||
effective_port = port if port is not None else (443 if scheme == "https" else 80)
|
||
return scheme, hostname, effective_port
|
||
|
||
|
||
class SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||
def __init__(self, allowed_origin: tuple[str, str, int | None]) -> None:
|
||
super().__init__()
|
||
self.allowed_origin = allowed_origin
|
||
|
||
def redirect_request(
|
||
self,
|
||
request: urllib.request.Request,
|
||
file_pointer: Any,
|
||
code: int,
|
||
message: str,
|
||
headers: Any,
|
||
new_url: str,
|
||
) -> urllib.request.Request | None:
|
||
if canonical_origin(new_url) != self.allowed_origin:
|
||
raise SyncError(f"拒绝跨源重定向:{request.full_url} -> {new_url}")
|
||
return super().redirect_request(request, file_pointer, code, message, headers, new_url)
|
||
|
||
|
||
def build_opener(server_url: str) -> tuple[urllib.request.OpenerDirector, tuple[str, str, int | None]]:
|
||
origin = canonical_origin(server_url)
|
||
return urllib.request.build_opener(SameOriginRedirectHandler(origin)), origin
|
||
|
||
|
||
def request_json(url: str, token: str, opener: urllib.request.OpenerDirector) -> tuple[int, Any | None]:
|
||
request = urllib.request.Request(
|
||
url,
|
||
headers={
|
||
"Accept": "application/json",
|
||
"Authorization": f"token {token}",
|
||
"User-Agent": "agent-skills-dist-sync",
|
||
},
|
||
)
|
||
try:
|
||
with opener.open(request, timeout=30) as response:
|
||
body = response.read(MAX_API_BYTES + 1)
|
||
if len(body) > MAX_API_BYTES:
|
||
raise SyncError(f"Gitea API 响应超过 {MAX_API_BYTES} bytes:{url}")
|
||
try:
|
||
return response.status, json.loads(body)
|
||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||
raise SyncError(f"Gitea API 返回无效 JSON:{url}: {error}") from error
|
||
except urllib.error.HTTPError as error:
|
||
if error.code == 404:
|
||
return error.code, None
|
||
detail = error.read(2048).decode("utf-8", errors="replace").strip()
|
||
raise SyncError(f"Gitea API 返回 HTTP {error.code}: {detail or url}") from error
|
||
except SyncError:
|
||
raise
|
||
except OSError as error:
|
||
raise SyncError(f"请求 Gitea API 失败:{url}: {error}") from error
|
||
|
||
|
||
def download(
|
||
url: str,
|
||
token: str,
|
||
destination: Path,
|
||
opener: urllib.request.OpenerDirector,
|
||
allowed_origin: tuple[str, str, int | None],
|
||
) -> None:
|
||
if canonical_origin(url) != allowed_origin:
|
||
raise SyncError(f"附件下载地址与 Gitea 不同源,拒绝发送 token:{url}")
|
||
request = urllib.request.Request(
|
||
url,
|
||
headers={
|
||
"Accept": "application/octet-stream",
|
||
"Authorization": f"token {token}",
|
||
"User-Agent": "agent-skills-dist-sync",
|
||
},
|
||
)
|
||
try:
|
||
with opener.open(request, timeout=120) as response, destination.open("wb") as output:
|
||
declared_size = response.headers.get("Content-Length")
|
||
if declared_size:
|
||
try:
|
||
if int(declared_size) > MAX_ASSET_BYTES:
|
||
raise SyncError(f"附件超过 {MAX_ASSET_BYTES} bytes:{url}")
|
||
except ValueError as error:
|
||
raise SyncError(f"附件返回无效 Content-Length:{declared_size!r}") from error
|
||
|
||
copied = 0
|
||
while chunk := response.read(1024 * 1024):
|
||
copied += len(chunk)
|
||
if copied > MAX_ASSET_BYTES:
|
||
raise SyncError(f"附件超过 {MAX_ASSET_BYTES} bytes:{url}")
|
||
output.write(chunk)
|
||
except urllib.error.HTTPError as error:
|
||
detail = error.read(2048).decode("utf-8", errors="replace").strip()
|
||
raise SyncError(f"下载附件返回 HTTP {error.code}: {detail or url}") from error
|
||
except SyncError:
|
||
raise
|
||
except OSError as error:
|
||
raise SyncError(f"下载附件失败:{url}: {error}") from error
|
||
|
||
|
||
def safe_extract(archive_path: Path, destination: Path) -> None:
|
||
try:
|
||
with zipfile.ZipFile(archive_path) as archive:
|
||
files = 0
|
||
total_size = 0
|
||
for info in archive.infolist():
|
||
normalized = info.filename.replace("\\", "/")
|
||
member = PurePosixPath(normalized)
|
||
if member.is_absolute() or ".." in member.parts:
|
||
raise SyncError(f"ZIP 包含不安全路径:{info.filename}")
|
||
if not member.parts:
|
||
continue
|
||
if len(normalized) > MAX_ARCHIVE_PATH_LENGTH or len(member.parts) > MAX_ARCHIVE_DEPTH:
|
||
raise SyncError(f"ZIP 路径过长或目录过深:{info.filename}")
|
||
|
||
mode = info.external_attr >> 16
|
||
if stat.S_ISLNK(mode):
|
||
raise SyncError(f"ZIP 不允许符号链接:{info.filename}")
|
||
|
||
target = destination.joinpath(*member.parts)
|
||
if not info.is_dir():
|
||
files += 1
|
||
if files > MAX_ARCHIVE_FILES:
|
||
raise SyncError(f"ZIP 文件数量超过 {MAX_ARCHIVE_FILES}")
|
||
total_size += info.file_size
|
||
if total_size > MAX_ASSET_BYTES:
|
||
raise SyncError("ZIP 解压后内容过大")
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
with archive.open(info) as source, target.open("wb") as output:
|
||
shutil.copyfileobj(source, output)
|
||
if mode & 0o111:
|
||
target.chmod(target.stat().st_mode | 0o111)
|
||
else:
|
||
target.mkdir(parents=True, exist_ok=True)
|
||
|
||
if files == 0:
|
||
raise SyncError("ZIP 中没有文件")
|
||
except zipfile.BadZipFile as error:
|
||
raise SyncError(f"附件不是有效 ZIP:{archive_path}") from error
|
||
|
||
|
||
def frontmatter_name(skill_file: Path) -> str:
|
||
try:
|
||
lines = skill_file.read_text(encoding="utf-8-sig").splitlines()
|
||
except (OSError, UnicodeDecodeError) as error:
|
||
raise SyncError(f"无法读取 {skill_file}: {error}") from error
|
||
|
||
if not lines or lines[0].strip() != "---":
|
||
raise SyncError(f"SKILL.md 缺少 YAML frontmatter:{skill_file}")
|
||
|
||
for line in lines[1:]:
|
||
if line.strip() == "---":
|
||
break
|
||
if line.startswith("name:"):
|
||
return line.split(":", 1)[1].strip().strip("'\"")
|
||
raise SyncError(f"SKILL.md frontmatter 缺少 name:{skill_file}")
|
||
|
||
|
||
def stage_source(
|
||
source: dict[str, str],
|
||
*,
|
||
server_url: str,
|
||
token: str,
|
||
current_versions: dict[str, str],
|
||
next_versions: dict[str, str],
|
||
temp_root: Path,
|
||
opener: urllib.request.OpenerDirector,
|
||
allowed_origin: tuple[str, str, int | None],
|
||
) -> tuple[str, Path | None]:
|
||
name = source["name"]
|
||
repository = source["repository"]
|
||
encoded_repository = "/".join(urllib.parse.quote(part, safe="") for part in repository.split("/"))
|
||
latest_url = f"{server_url}/api/v1/repos/{encoded_repository}/releases/latest"
|
||
|
||
status, release = request_json(latest_url, token, opener)
|
||
if status == 404:
|
||
log(f"跳过 {name}:仓库不可见或尚无 latest Release")
|
||
return "skipped", None
|
||
if not isinstance(release, dict):
|
||
raise SyncError(f"{repository} 的 latest Release 响应无效")
|
||
|
||
tag = release.get("tag_name")
|
||
assets = release.get("assets")
|
||
if not isinstance(tag, str) or not TAG_PATTERN.fullmatch(tag):
|
||
raise SyncError(f"{repository} 的 latest Release tag 不符合 v* 正式版本约定:{tag!r}")
|
||
if not isinstance(assets, list):
|
||
raise SyncError(f"{repository} 的 latest Release 缺少附件列表")
|
||
|
||
matches = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == source["asset"]]
|
||
if len(matches) != 1:
|
||
raise SyncError(f"{repository}@{tag} 应恰好包含一个附件 {source['asset']},实际为 {len(matches)} 个")
|
||
|
||
target = REPO_ROOT / "skills" / name
|
||
if current_versions.get(name) == tag and target.is_dir():
|
||
log(f"跳过 {name}:已经同步 {tag}")
|
||
return "unchanged", None
|
||
|
||
download_url = matches[0].get("browser_download_url")
|
||
if not isinstance(download_url, str) or not download_url:
|
||
raise SyncError(f"{repository}@{tag} 的附件缺少 browser_download_url")
|
||
|
||
work_dir = temp_root / name
|
||
extract_dir = work_dir / "content"
|
||
archive_path = work_dir / source["asset"]
|
||
extract_dir.mkdir(parents=True)
|
||
download(download_url, token, archive_path, opener, allowed_origin)
|
||
safe_extract(archive_path, extract_dir)
|
||
|
||
skill_file = extract_dir / "SKILL.md"
|
||
if not skill_file.is_file():
|
||
raise SyncError(f"{repository}@{tag} 的 ZIP 根目录缺少 SKILL.md")
|
||
actual_name = frontmatter_name(skill_file)
|
||
if actual_name != name:
|
||
raise SyncError(f"{repository}@{tag} 的 skill name 为 {actual_name!r},预期 {name!r}")
|
||
|
||
next_versions[name] = tag
|
||
log(f"已准备 {name}:{current_versions.get(name, '未记录')} -> {tag}")
|
||
return "updated", extract_dir
|
||
|
||
|
||
def apply_staged_updates(
|
||
staged: list[tuple[str, Path]],
|
||
current_versions: dict[str, str],
|
||
next_versions: dict[str, str],
|
||
versions_path: Path,
|
||
) -> None:
|
||
if not staged:
|
||
return
|
||
|
||
backup_root = Path(tempfile.mkdtemp(prefix=".dist-sync-backup-", dir=REPO_ROOT))
|
||
applied: list[tuple[Path, Path | None]] = []
|
||
try:
|
||
for name, staged_dir in staged:
|
||
destination = REPO_ROOT / "skills" / name
|
||
replacement = destination.with_name(f".{name}.dist-sync-new")
|
||
if replacement.exists():
|
||
shutil.rmtree(replacement)
|
||
shutil.copytree(staged_dir, replacement)
|
||
|
||
backup: Path | None = None
|
||
if destination.exists():
|
||
backup = backup_root / name
|
||
destination.rename(backup)
|
||
try:
|
||
replacement.rename(destination)
|
||
except Exception:
|
||
if backup is not None and backup.exists() and not destination.exists():
|
||
backup.rename(destination)
|
||
raise
|
||
applied.append((destination, backup))
|
||
|
||
write_json_atomic(versions_path, next_versions)
|
||
except Exception as error:
|
||
for destination, backup in reversed(applied):
|
||
if destination.exists():
|
||
shutil.rmtree(destination)
|
||
if backup is not None and backup.exists():
|
||
backup.rename(destination)
|
||
try:
|
||
write_json_atomic(versions_path, current_versions)
|
||
except SyncError as rollback_error:
|
||
raise SyncError(
|
||
f"应用同步结果失败;skill 目录已回滚,但 versions.json 回滚失败:{rollback_error}"
|
||
) from error
|
||
if isinstance(error, SyncError):
|
||
raise
|
||
raise SyncError(f"应用同步结果失败,已回滚 skill 目录:{error}") from error
|
||
finally:
|
||
shutil.rmtree(backup_root, ignore_errors=True)
|
||
for name, _ in staged:
|
||
replacement = REPO_ROOT / "skills" / f".{name}.dist-sync-new"
|
||
if replacement.exists():
|
||
shutil.rmtree(replacement, ignore_errors=True)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--sources", type=Path, default=DEFAULT_SOURCES)
|
||
parser.add_argument("--versions", type=Path, default=DEFAULT_VERSIONS)
|
||
parser.add_argument("--server-url", default=os.environ.get("GITEA_SERVER_URL", ""))
|
||
parser.add_argument("--token", default=os.environ.get("SOURCE_REPOS_TOKEN", ""))
|
||
args = parser.parse_args()
|
||
|
||
server_url = args.server_url.rstrip("/")
|
||
token = args.token.strip()
|
||
if not server_url:
|
||
raise SyncError("缺少 GITEA_SERVER_URL 或 --server-url")
|
||
if not token:
|
||
raise SyncError("缺少 SOURCE_REPOS_TOKEN 或 --token")
|
||
opener, allowed_origin = build_opener(server_url)
|
||
|
||
config = read_json(args.sources)
|
||
raw_sources = config.get("sources") if isinstance(config, dict) else None
|
||
if not isinstance(raw_sources, list) or not raw_sources:
|
||
raise SyncError("sources.json 必须包含非空 sources 数组")
|
||
sources = [validate_source(raw) for raw in raw_sources]
|
||
names = [source["name"] for source in sources]
|
||
if len(names) != len(set(names)):
|
||
raise SyncError("sources.json 包含重复的 skill name")
|
||
|
||
raw_versions = read_json(args.versions) if args.versions.exists() else {}
|
||
if not isinstance(raw_versions, dict) or not all(
|
||
isinstance(name, str) and isinstance(tag, str) and TAG_PATTERN.fullmatch(tag)
|
||
for name, tag in raw_versions.items()
|
||
):
|
||
raise SyncError("versions.json 必须是 skill name 到正式 v* Release tag 的字符串映射")
|
||
current_versions: dict[str, str] = dict(raw_versions)
|
||
next_versions = dict(current_versions)
|
||
|
||
counts = {"updated": 0, "unchanged": 0, "skipped": 0}
|
||
staged: list[tuple[str, Path]] = []
|
||
with tempfile.TemporaryDirectory(prefix="agent-skills-dist-sync-") as temp:
|
||
for source in sources:
|
||
result, staged_dir = stage_source(
|
||
source,
|
||
server_url=server_url,
|
||
token=token,
|
||
current_versions=current_versions,
|
||
next_versions=next_versions,
|
||
temp_root=Path(temp),
|
||
opener=opener,
|
||
allowed_origin=allowed_origin,
|
||
)
|
||
counts[result] += 1
|
||
if staged_dir is not None:
|
||
staged.append((source["name"], staged_dir))
|
||
|
||
apply_staged_updates(staged, current_versions, next_versions, args.versions)
|
||
|
||
log(
|
||
"同步完成:"
|
||
f"更新 {counts['updated']},无变化 {counts['unchanged']},"
|
||
f"无 latest Release {counts['skipped']}"
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
raise SystemExit(main())
|
||
except SyncError as error:
|
||
print(f"同步失败:{error}", file=sys.stderr)
|
||
raise SystemExit(1)
|