mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 14:50:45 +00:00
Gitea 令牌可以只给读权限。只读令牌能读仓库、能列发行版, 看起来一切正常,但一推代码就 403、一建发行版也 403。 加了 check_ezhub_scope():开头就用一个**无副作用**的探针确认权限 (对不存在的发行版发 DELETE —— 403 说明缺 write:repository, 404 说明权限过了只是资源不存在),缺权限就立刻停下并给出 明确的修复指引,不让它走到一半才炸。 文档里也写清了这个坑。
361 lines
13 KiB
Python
361 lines
13 KiB
Python
# -*- 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())
|