# -*- 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 time 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 # 先看标签有没有发行版。 # 不先查的话,重发会直接 400「该标签已经存在发行版」—— # 而且没有可复用的 id,后面的附件也就传不上。 rid = None try: with urllib.request.urlopen( "%s/releases/tags/%s?access_token=%s" % (api, tag, token), timeout=60) as resp: # ⚠ Gitee 在"标签存在、但还没有发行版"时返回的是 JSON 字面量 # **null**(不是 404)—— 于是 json.loads() 得到 None, # 再 .get("id") 就是 AttributeError,整个发布脚本崩在半路 # (代码已经推上去了,发行版却建不出来)。 # 实测发 1.9.0 时踩到。所以这里必须容忍 None。 data = json.loads(resp.read().decode("utf-8", "replace")) rid = (data or {}).get("id") if rid: print(" • 该标签已有发行版 id=%s,复用" % rid) except urllib.error.HTTPError: pass if not rid: 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 # 复用已有发行版时先把同名旧附件删掉,否则每跑一次多一份。 # # ⚠ Gitee 的坑:`GET /releases/{id}` 返回的 `assets` **只有 name 和 # download_url,没有 id**,照着它删是删不掉的。 # 必须换成单独这个接口才带 id: # GET /releases/{id}/attach_files -> [{id, name, size, ...}] # 删也是:DELETE /releases/{id}/attach_files/{attach_id} try: with urllib.request.urlopen( "%s/releases/%s/attach_files?access_token=%s" % (api, rid, token), timeout=90) as resp: old_assets = json.loads(resp.read().decode("utf-8", "replace")) if not isinstance(old_assets, list): old_assets = [] wanted = {p.name for p in files} for asset in old_assets: name = asset.get("name") aid = asset.get("id") if name in wanted and aid: del_url = ("%s/releases/%s/attach_files/%s" "?access_token=%s" % (api, rid, aid, token)) dreq = urllib.request.Request(del_url, method="DELETE") for attempt in range(1, 4): try: urllib.request.urlopen(dreq, timeout=90) print(" • 删掉同名旧附件 %s" % name) break except urllib.error.HTTPError as exc: print(" • 删 %s 失败 HTTP %s(继续)" % (name, exc.code)) break except Exception: # noqa: BLE001 if attempt == 3: print(" • 删 %s 网络失败,跳过" % name) else: time.sleep(4) except Exception as exc: # noqa: BLE001 print(" • 清理旧附件时出错(继续): %s" % exc) 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 # 先把同名旧附件删掉。 # 不删的话,复用已有发行版时每跑一次就多传一份 —— # 实测调试期间同一个文件堆了 3 份。 try: req = urllib.request.Request( "%s/releases/%s" % (api, rid), headers=auth) with urllib.request.urlopen(req, timeout=60) as resp: existing = json.loads(resp.read().decode("utf-8", "replace")) wanted = {p.name for p in files} for asset in (existing.get("assets") or []): if asset.get("name") in wanted: del_url = "%s/releases/%s/assets/%s" % (api, rid, asset.get("id")) dreq = urllib.request.Request(del_url, method="DELETE", headers=auth) try: urllib.request.urlopen(dreq, timeout=60) print(" • 删掉同名旧附件 %s" % asset.get("name")) except urllib.error.HTTPError as exc: print(" • 删 %s 失败 HTTP %s(继续)" % (asset.get("name"), exc.code)) except Exception as exc: # noqa: BLE001 print(" • 清理旧附件时出错(继续): %s" % exc) 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 check_ezhub_scope(token: str) -> bool: """提前确认 EzHub 令牌有 write:repository。 为什么要单独查: Gitea 的令牌可以只给读权限。只读令牌能读仓库、能列发行版, 看起来"正常",但一推代码就 403、一建发行版就 403, 而且报错信息藏在一层层输出里。所以开头就探一下。 探法(无副作用):删一个**不存在的**发行版。 403 -> 缺 write:repository 404 -> 有写权限(只是资源不存在) """ url = ("https://git.261826.xyz/api/v1/repos/xmz/ykxmz" "/releases/99999999") req = urllib.request.Request( url, method="DELETE", headers={"Authorization": "token " + token, "User-Agent": "ykxmz-release"}) try: urllib.request.urlopen(req, timeout=30) return True except urllib.error.HTTPError as exc: if exc.code == 404: return True detail = exc.read().decode("utf-8", "replace")[:200] if "write:repository" in detail: print(" ✗ EzHub 令牌缺 write:repository 权限") print(" -> 去 EzHub:设置 → 应用 → 生成令牌," "权限勾上 write:repository") return False print(" ✗ EzHub 令牌检查失败 HTTP %s: %s" % (exc.code, detail)) return False except Exception as exc: # noqa: BLE001 print(" ✗ 连不上 EzHub: %s" % exc) return False 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(" ✓ 工作区干净") # 两个平台的令牌都得有写权限,否则走到一半才失败 if not check_ezhub_scope(ezhub_token): return 1 print(" ✓ EzHub 令牌有写权限") # 对外材料清洁度:绝不能让管理页字眼进发行版说明 for word in ("zhongzhuanzhan", "/admin", "管理后台", "管理页"): if word in body: print(" [失败] 更新说明里出现了「%s」—— 违反对外口径" % word) return 1 print(" ✓ 更新说明里没有管理页字眼") if dry: print() print(" 演练结束。要真发就去掉 --dry。") return 0 # ---- 1. 推代码 ---- # EzHub 的 SSH 密钥在用户那边还没配对成功(服务端拒绝本机公钥), # 但令牌有 push 权限,所以走 HTTPS + 令牌推。 # 令牌只在这次进程里拼进 URL,不写进 .git/config。 print() print(" 二、推代码") for name, info in PLATFORMS.items(): if name == "ezhub": url = ("https://%s:%s@%s/%s/%s.git" % ("xmz", ezhub_token, "git.261826.xyz", "xmz", "ykxmz")) r = subprocess.run([GIT, "push", url, "main"], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900) raw = (r.stderr or r.stdout or "") else: r = run_git(["push", info["remote"], "main"]) raw = (r.stderr or r.stdout or "") ok = r.returncode == 0 # 输出里可能带令牌,打码 safe = raw.replace(ezhub_token, "***") if ezhub_token else raw tail = (safe.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())