Files
ykxmz/_build/release_all.py
T
xmz7891 d60dfcf671 发布脚本:提前检查 EzHub 令牌写权限
Gitea 令牌可以只给读权限。只读令牌能读仓库、能列发行版,
看起来一切正常,但一推代码就 403、一建发行版也 403。

加了 check_ezhub_scope():开头就用一个**无副作用**的探针确认权限
(对不存在的发行版发 DELETE —— 403 说明缺 write:repository,
404 说明权限过了只是资源不存在),缺权限就立刻停下并给出
明确的修复指引,不让它走到一半才炸。

文档里也写清了这个坑。
2026-10-07 12:25:12 +08:00

361 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 <gitee令牌> <ezhub令牌> [版本号] [--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 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 <gitee令牌> <ezhub令牌>"
" [版本号] [--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. 推代码 ----
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())