Files
ykxmz/_build/release_all.py
T
xmz7891 4de2255ee4 加 EzHub(Gitea)发布支持 + 一次发两个平台的脚本
用户要求:除了 Gitee,还要发到朋友自建的代码托管平台 EzHub
(ssh://git@git.261826.xyz/xmz/ykxmz.git),release 也要建。

探测结果:EzHub 是 **Gitea 1.22.0**
(/api/v1/version 返回 16.0.5+gitea-1.22.0),
仓库 xmz/ykxmz 存在,目前还没有任何发行版。

Gitea 和 Gitee 的接口差别(踩过的点):
  项目           Gitee                  Gitea / EzHub
  API 前缀       /api/v5/repos/...      /api/v1/repos/...
  认证           参数 access_token      请求头 Authorization: token
  传附件路径     /releases/{id}/        /releases/{id}/
                 attach_files           assets?name=<文件名>
  附件字段名     file                   attachment
  草稿字段       无                     draft / prerelease

新增:
  * _build/ezhub_release.py   单独发 EzHub 用
  * _build/release_all.py     一次发两个平台(推荐)
      - 前置检查:产物齐不齐、更新说明能不能取到、工作区干不干净
      - **对外口径检查**:更新说明里出现管理页字眼直接拒绝发布
      - 推代码到两个 remote,再分别在两边建发行版 + 传三个附件
      - 支持 --dry 演练(不真的发),已经过通

修掉的 bug:
  CHANGELOG 解析。按 "\n## " 切分时第一块带着自己的 "## 版本号"
  (前面没换行),后面几块没有 —— 原来只写 startswith("1.8.0"),
  对第一块永远匹配不上,全靠"取不到就退回第一块"的兜底碰巧对了。
  现在先把开头的 # 去掉再比。

SSH:
  为 EzHub 单独生成了一把密钥(~/.ssh/id_ed25519_ezhub),
  不复用 Gitee 那把,权限隔离。已写好 ~/.ssh/config 的 Host 段。
  **公钥需要用户加到 EzHub 账号里之后才能推。**
  私钥在仓库外,不会进版本库。
2026-10-07 12:19:58 +08:00

319 lines
11 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 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(" ✓ 工作区干净")
# 对外材料清洁度:绝不能让管理页字眼进发行版说明
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())