# -*- coding: utf-8 -*- r"""一次把代码和发行版发到两个平台。 平台: origin gitee.com:xmz7891/ykxmz (Gitee,API v5) ezhub git.261826.xyz/xmz/ykxmz (EzHub = Gitea 1.22,API v1) 用法: python _build/release_all.py [版本号] [--dry] --dry 只看会做什么,不真的推/建 为什么合成一个脚本: 两个平台的产物、更新内容完全一样,分开写会漂移 (比如改了一个忘了改另一个)。这里把"要发什么"算一次, 然后分别喂给各自的接口。 令牌只从命令行读,不落盘、不进仓库。 """ from __future__ import annotations import json import subprocess import sys import urllib.error import urllib.request from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") GIT = r"C:\Program Files\Git\bin\git.exe" SSH = r"C:\Program Files\Git\usr\bin\ssh.exe" PLATFORMS = { "gitee": { "remote": "origin", "web": "https://gitee.com/xmz7891/ykxmz", "kind": "gitee", }, "ezhub": { "remote": "ezhub", "web": "https://git.261826.xyz/xmz/ykxmz", "kind": "gitea", }, } def run_git(args: list, check: bool = False) -> subprocess.CompletedProcess: import os env = dict(os.environ) env["GIT_SSH_COMMAND"] = '"%s"' % SSH return subprocess.run([GIT] + args, cwd=str(ROOT), env=env, capture_output=True, text=True, encoding="utf-8", errors="replace") def read_changelog(version: str) -> str: """取 CHANGELOG 里这个版本那一节 —— 发行版说明只放更新内容。 ⚠ 注意:按 "\\n## " 切分时,**第一块会带着自己的 "## 版本号"** (因为它前面没有换行),后面几块没有。 所以匹配版本号前必须先把开头的 # 去掉 —— 原来只写 startswith("1.8.0"),对第一块永远匹配不上, 全靠"取不到就退回第一块"的兜底碰巧对了。 """ 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()] def strip_heading(block: str) -> str: lines = block.splitlines() if lines and lines[0].lstrip().startswith("#"): lines = lines[1:] return "\n".join(lines).strip() for block in blocks: first = block.splitlines()[0].lstrip("# ").strip() if first == version or first == "v" + version: return strip_heading(block) return strip_heading(blocks[0]) if blocks else "" def artifacts(version: str) -> list: return [ ROOT / "dist" / f"ykxmz-setup-{version}.exe", ROOT / "dist" / f"ykxmz-server-{version}.zip", ROOT / "dist" / f"ykxmz-watch-{version}.apk", ] # ---------------------------------------------------------------- Gitee def gitee_release(token: str, version: str, body: str, files: list) -> bool: import mimetypes import uuid api = "https://gitee.com/api/v5/repos/xmz7891/ykxmz" tag = "v" + version payload = json.dumps({ "access_token": token, "tag_name": tag, "name": "ykxmz " + version, "body": body, "target_commitish": "main", "prerelease": False, }).encode("utf-8") req = urllib.request.Request( api + "/releases", data=payload, method="POST", headers={"Content-Type": "application/json;charset=UTF-8", "User-Agent": "ykxmz-release"}) try: with urllib.request.urlopen(req, timeout=90) as resp: reply = json.loads(resp.read().decode("utf-8", "replace")) rid = reply.get("id") print(" ✓ 发行版已建 id=%s" % rid) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:300] print(" ✗ 建发行版失败 HTTP %s: %s" % (exc.code, detail)) return False ok = 0 for path in files: boundary = "----yk" + uuid.uuid4().hex ctype = (mimetypes.guess_type(path.name)[0] or "application/octet-stream") head = ( "--%s\r\n" 'Content-Disposition: form-data; name="access_token"\r\n\r\n' "%s\r\n" "--%s\r\n" 'Content-Disposition: form-data; name="file"; ' 'filename="%s"\r\n' "Content-Type: %s\r\n\r\n" % (boundary, token, boundary, path.name, ctype) ).encode("utf-8") data = head + path.read_bytes() + \ ("\r\n--%s--\r\n" % boundary).encode("utf-8") req = urllib.request.Request( "%s/releases/%s/attach_files" % (api, rid), data=data, method="POST", headers={"Content-Type": "multipart/form-data; boundary=" + boundary, "Content-Length": str(len(data)), "User-Agent": "ykxmz-release"}) try: with urllib.request.urlopen(req, timeout=900) as resp: if resp.status in (200, 201): print(" ✓ %s" % path.name) ok += 1 except urllib.error.HTTPError as exc: print(" ✗ %s HTTP %s" % (path.name, exc.code)) return ok == len(files) # ---------------------------------------------------------------- Gitea def gitea_release(token: str, version: str, body: str, files: list) -> bool: import mimetypes import urllib.parse import uuid api = "https://git.261826.xyz/api/v1/repos/xmz/ykxmz" tag = "v" + version auth = {"Authorization": "token " + token, "User-Agent": "ykxmz-release"} # 先看标签有没有发行版(重发时复用,别报错) rid = None try: req = urllib.request.Request( "%s/releases/tags/%s" % (api, tag), headers=auth) with urllib.request.urlopen(req, timeout=60) as resp: rid = json.loads(resp.read().decode("utf-8", "replace")).get("id") print(" • 该标签已有发行版 id=%s,复用" % rid) except urllib.error.HTTPError: pass if not rid: payload = json.dumps({ "tag_name": tag, "name": "ykxmz " + version, "body": body, "target_commitish": "main", "draft": False, "prerelease": False, }).encode("utf-8") headers = dict(auth) headers["Content-Type"] = "application/json" req = urllib.request.Request(api + "/releases", data=payload, method="POST", headers=headers) try: with urllib.request.urlopen(req, timeout=90) as resp: reply = json.loads(resp.read().decode("utf-8", "replace")) rid = reply.get("id") print(" ✓ 发行版已建 id=%s" % rid) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:300] print(" ✗ 建发行版失败 HTTP %s: %s" % (exc.code, detail)) return False ok = 0 for path in files: boundary = "----yk" + uuid.uuid4().hex ctype = (mimetypes.guess_type(path.name)[0] or "application/octet-stream") head = ( "--%s\r\n" 'Content-Disposition: form-data; name="attachment"; ' 'filename="%s"\r\n' "Content-Type: %s\r\n\r\n" % (boundary, path.name, ctype) ).encode("utf-8") data = head + path.read_bytes() + \ ("\r\n--%s--\r\n" % boundary).encode("utf-8") url = ("%s/releases/%s/assets?name=%s" % (api, rid, urllib.parse.quote(path.name))) headers = dict(auth) headers["Content-Type"] = "multipart/form-data; boundary=" + boundary headers["Content-Length"] = str(len(data)) req = urllib.request.Request(url, data=data, method="POST", headers=headers) try: with urllib.request.urlopen(req, timeout=900) as resp: if resp.status in (200, 201): print(" ✓ %s" % path.name) ok += 1 except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:200] print(" ✗ %s HTTP %s %s" % (path.name, exc.code, detail)) return ok == len(files) def main() -> int: args = [a for a in sys.argv[1:] if not a.startswith("--")] dry = "--dry" in sys.argv if len(args) < 2: print(" 用法: python _build/release_all.py " " [版本号] [--dry]") return 1 gitee_token, ezhub_token = args[0].strip(), args[1].strip() version = args[2] if len(args) > 2 else "1.8.0" tag = "v" + version print(" 版本: %s" % version) print(" 模式: %s" % ("演练(不真的发)" if dry else "正式发布")) print() # ---- 0. 前置检查 ---- print(" 一、前置检查") files = artifacts(version) missing = [f for f in files if not f.is_file()] for path in files: mark = "✓" if path.is_file() else "✗ 缺失" size = (path.stat().st_size / 1024 / 1024) if path.is_file() else 0 print(" %s %-32s %6.2f MB" % (mark, path.name, size)) if missing: print(" [失败] 产物不全,先构建") return 1 body = read_changelog(version) if not body: print(" [失败] CHANGELOG 里找不到 %s 那一节" % version) return 1 print(" 更新说明 %d 字符(%d 行)" % (len(body), body.count("\n") + 1)) # 工作区必须干净,否则推上去的和测过的不是一份 st = run_git(["status", "--porcelain"]) if (st.stdout or "").strip(): print(" [失败] 工作区不干净,先提交") for line in (st.stdout or "").strip().splitlines()[:5]: print(" " + line) return 1 print(" ✓ 工作区干净") # 对外材料清洁度:绝不能让管理页字眼进发行版说明 for word in ("zhongzhuanzhan", "/admin", "管理后台", "管理页"): if word in body: print(" [失败] 更新说明里出现了「%s」—— 违反对外口径" % word) return 1 print(" ✓ 更新说明里没有管理页字眼") if dry: print() print(" 演练结束。要真发就去掉 --dry。") return 0 # ---- 1. 推代码 ---- print() print(" 二、推代码") for name, info in PLATFORMS.items(): r = run_git(["push", info["remote"], "main"]) ok = r.returncode == 0 tail = ((r.stderr or r.stdout or "").strip().splitlines() or [""])[-1] print(" %-6s %s %s" % (name, "✓" if ok else "✗", tail[:90])) if not ok: print(" (代码没推上去,先修这个再建发行版)") return 1 # ---- 2. 建发行版 ---- print() print(" 三、建发行版") results = {} print(" [Gitee]") results["gitee"] = gitee_release(gitee_token, version, body, files) print(" [EzHub]") results["ezhub"] = gitea_release(ezhub_token, version, body, files) print() print(" ================ 结果 ================") for name, info in PLATFORMS.items(): mark = "✓" if results.get(name) else "✗" print(" %s %-6s %s/releases/tag/%s" % (mark, name, info["web"], tag)) return 0 if all(results.values()) else 1 if __name__ == "__main__": sys.exit(main())