# -*- coding: utf-8 -*- r"""在 EzHub(Gitea 1.22)上创建发行版并上传三个文件。 用法: python _build/ezhub_release.py <令牌> [版本号] 令牌只从命令行参数读,**不写进任何文件、不进仓库**。 和 Gitee 的差别(Gitea 的接口跟 Gitee 不一样): 项目 Gitee Gitea / EzHub ------------------------------------------------------------------ API 前缀 /api/v5/repos/... /api/v1/repos/... 认证 参数 access_token 请求头 Authorization: token 建发行版 POST /releases POST /releases 传附件 POST /releases/{id}/ POST /releases/{id}/ attach_files assets?name=<文件名> 附件字段名 file attachment 按标签查 /releases/tags/{tag} /releases/tags/{tag}(一样) 草稿字段 (无) draft / prerelease SSH 密钥只能推代码,建发行版必须用令牌。 """ from __future__ import annotations import json import mimetypes import sys import urllib.error import urllib.parse import urllib.request import uuid from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") OWNER = "xmz" REPO = "ykxmz" API = f"https://git.261826.xyz/api/v1/repos/{OWNER}/{REPO}" WEB = f"https://git.261826.xyz/{OWNER}/{REPO}" def api(url: str, payload: dict | None = None, method: str = "GET", token: str = "", timeout: int = 60): """JSON 接口。Gitea 用请求头带令牌,不是查询参数。""" data = None headers = { "User-Agent": "ykxmz-release", "Accept": "application/json", "Authorization": f"token {token}", } if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(request, timeout=timeout) as response: body = response.read().decode("utf-8", "replace") return response.status, (json.loads(body) if body.strip() else {}) def upload_asset(url: str, path: Path, token: str, timeout: int = 900): """multipart 上传。 Gitea 的字段名是 **attachment**(Gitee 是 file), 文件名走查询参数 name=,不是 Content-Disposition。 """ boundary = "----ykxmz" + uuid.uuid4().hex content_type = (mimetypes.guess_type(path.name)[0] or "application/octet-stream") head = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="attachment"; ' f'filename="{path.name}"\r\n' f"Content-Type: {content_type}\r\n\r\n" ).encode("utf-8") tail = f"\r\n--{boundary}--\r\n".encode("utf-8") body = head + path.read_bytes() + tail request = urllib.request.Request( url, data=body, method="POST", headers={ "Content-Type": f"multipart/form-data; boundary={boundary}", "Content-Length": str(len(body)), "Authorization": f"token {token}", "User-Agent": "ykxmz-release", }) with urllib.request.urlopen(request, timeout=timeout) as response: text = response.read().decode("utf-8", "replace") return response.status, (json.loads(text) if text.strip() else {}) def read_changelog(version: str) -> str: """从 _build/CHANGELOG.md 里取这个版本那一节(只放更新内容)。""" note = ROOT / "_build" / "CHANGELOG.md" if not note.is_file(): return "" text = note.read_text(encoding="utf-8") blocks = [b.strip() for b in text.split("\n## ") if b.strip()] for block in blocks: if block.startswith(version) or block.startswith("v" + version): lines = block.splitlines() if lines and lines[0].strip().startswith("#"): lines = lines[1:] return "\n".join(lines).strip() if blocks: lines = blocks[0].splitlines() if lines and lines[0].strip().startswith("#"): lines = lines[1:] return "\n".join(lines).strip() return "" def main() -> int: if len(sys.argv) < 2: print(" 用法: python _build/ezhub_release.py <令牌> [版本号]") return 1 token = sys.argv[1].strip() version = sys.argv[2] if len(sys.argv) > 2 else "1.8.0" tag = f"v{version}" changelog = sys.argv[3] if len(sys.argv) > 3 else "" if not changelog: changelog = read_changelog(version) if not changelog: changelog = f"版本 {version}" files = [ ROOT / "dist" / f"ykxmz-setup-{version}.exe", ROOT / "dist" / f"ykxmz-server-{version}.zip", ROOT / "dist" / f"ykxmz-watch-{version}.apk", ] for path in files: if not path.is_file(): print(f" [失败] 找不到 {path}") return 1 print(" 仓库: %s/%s @ git.261826.xyz" % (OWNER, REPO)) print(" 标签: %s" % tag) print() # ---- 0. 先确认令牌能用 ---- try: _s, me = api(f"https://git.261826.xyz/api/v1/user", token=token) print(" 令牌身份: %s" % me.get("login", "?")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:200] print(" [失败] 令牌无效 HTTP %s: %s" % (exc.code, detail)) return 1 # ---- 1. 建发行版 ---- # 已存在同名标签就先查出来复用(重发时不报错) release_id = None try: _s, existing = api(f"{API}/releases/tags/{tag}", token=token) release_id = existing.get("id") if release_id: print(" 该标签已有发行版 id=%s,复用它" % release_id) except urllib.error.HTTPError: pass if not release_id: print(" 建发行版…") body = changelog.strip() if not body.endswith("\n"): body += "\n" try: status, reply = api( f"{API}/releases", {"tag_name": tag, "name": f"ykxmz {version}", "body": body, "target_commitish": "main", "draft": False, "prerelease": False}, method="POST", token=token) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:400] print(" [失败] HTTP %s: %s" % (exc.code, detail)) return 1 if status not in (200, 201): print(" [失败] HTTP %s: %s" % (status, reply)) return 1 release_id = reply.get("id") print(" ✓ 发行版已建,id=%s" % release_id) print(" %s" % reply.get("html_url", "")) if not release_id: print(" [失败] 没拿到发行版 id") return 1 # ---- 2. 传附件 ---- print() print(" 上传文件:") ok_count = 0 for path in files: size_mb = path.stat().st_size / 1024 / 1024 # Gitea 要求文件名走查询参数 url = ("%s/releases/%s/assets?name=%s" % (API, release_id, urllib.parse.quote(path.name))) try: status, reply = upload_asset(url, path, token) if status in (200, 201): print(" ✓ %s (%.2f MB)" % (path.name, size_mb)) ok_count += 1 else: print(" ✗ %s: HTTP %s %s" % (path.name, status, reply)) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:250] print(" ✗ %s: HTTP %s %s" % (path.name, exc.code, detail)) except Exception as exc: # noqa: BLE001 print(" ✗ %s: %s" % (path.name, exc)) print() if ok_count == len(files): print(" 完成:%s/releases/tag/%s" % (WEB, tag)) return 0 print(" 部分失败(%d/%d)" % (ok_count, len(files))) return 1 if __name__ == "__main__": sys.exit(main())