Files
ykxmz/_build/ezhub_release.py
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

220 lines
8.0 KiB
Python
Raw Permalink 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"""在 EzHub(Gitea 1.22)上创建发行版并上传三个文件。
用法:
python _build/ezhub_release.py <令牌> [版本号]
令牌只从命令行参数读,**不写进任何文件、不进仓库**。
和 Gitee 的差别(Gitea 的接口跟 Gitee 不一样):
项目 Gitee Gitea / EzHub
------------------------------------------------------------------
API 前缀 /api/v5/repos/... /api/v1/repos/...
认证 参数 access_token 请求头 Authorization: token
建发行版 POST /releases POST /releases
传附件 POST /releases/{id}/ POST /releases/{id}/
attach_files assets?name=<文件名>
附件字段名 file attachment
按标签查 /releases/tags/{tag} /releases/tags/{tag}(一样)
草稿字段 (无) draft / prerelease
SSH 密钥只能推代码,建发行版必须用令牌。
"""
from __future__ import annotations
import json
import mimetypes
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
OWNER = "xmz"
REPO = "ykxmz"
API = f"https://git.261826.xyz/api/v1/repos/{OWNER}/{REPO}"
WEB = f"https://git.261826.xyz/{OWNER}/{REPO}"
def api(url: str, payload: dict | None = None, method: str = "GET",
token: str = "", timeout: int = 60):
"""JSON 接口。Gitea 用请求头带令牌,不是查询参数。"""
data = None
headers = {
"User-Agent": "ykxmz-release",
"Accept": "application/json",
"Authorization": f"token {token}",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers,
method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read().decode("utf-8", "replace")
return response.status, (json.loads(body) if body.strip() else {})
def upload_asset(url: str, path: Path, token: str, timeout: int = 900):
"""multipart 上传。
Gitea 的字段名是 **attachment**(Gitee 是 file),
文件名走查询参数 name=,不是 Content-Disposition。
"""
boundary = "----ykxmz" + uuid.uuid4().hex
content_type = (mimetypes.guess_type(path.name)[0]
or "application/octet-stream")
head = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; '
f'filename="{path.name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = head + path.read_bytes() + tail
request = urllib.request.Request(
url, data=body, method="POST",
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
"Authorization": f"token {token}",
"User-Agent": "ykxmz-release",
})
with urllib.request.urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8", "replace")
return response.status, (json.loads(text) if text.strip() else {})
def read_changelog(version: str) -> str:
"""从 _build/CHANGELOG.md 里取这个版本那一节(只放更新内容)。"""
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()]
for block in blocks:
if block.startswith(version) or block.startswith("v" + version):
lines = block.splitlines()
if lines and lines[0].strip().startswith("#"):
lines = lines[1:]
return "\n".join(lines).strip()
if blocks:
lines = blocks[0].splitlines()
if lines and lines[0].strip().startswith("#"):
lines = lines[1:]
return "\n".join(lines).strip()
return ""
def main() -> int:
if len(sys.argv) < 2:
print(" 用法: python _build/ezhub_release.py <令牌> [版本号]")
return 1
token = sys.argv[1].strip()
version = sys.argv[2] if len(sys.argv) > 2 else "1.8.0"
tag = f"v{version}"
changelog = sys.argv[3] if len(sys.argv) > 3 else ""
if not changelog:
changelog = read_changelog(version)
if not changelog:
changelog = f"版本 {version}"
files = [
ROOT / "dist" / f"ykxmz-setup-{version}.exe",
ROOT / "dist" / f"ykxmz-server-{version}.zip",
ROOT / "dist" / f"ykxmz-watch-{version}.apk",
]
for path in files:
if not path.is_file():
print(f" [失败] 找不到 {path}")
return 1
print(" 仓库: %s/%s @ git.261826.xyz" % (OWNER, REPO))
print(" 标签: %s" % tag)
print()
# ---- 0. 先确认令牌能用 ----
try:
_s, me = api(f"https://git.261826.xyz/api/v1/user", token=token)
print(" 令牌身份: %s" % me.get("login", "?"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:200]
print(" [失败] 令牌无效 HTTP %s: %s" % (exc.code, detail))
return 1
# ---- 1. 建发行版 ----
# 已存在同名标签就先查出来复用(重发时不报错)
release_id = None
try:
_s, existing = api(f"{API}/releases/tags/{tag}", token=token)
release_id = existing.get("id")
if release_id:
print(" 该标签已有发行版 id=%s,复用它" % release_id)
except urllib.error.HTTPError:
pass
if not release_id:
print(" 建发行版…")
body = changelog.strip()
if not body.endswith("\n"):
body += "\n"
try:
status, reply = api(
f"{API}/releases",
{"tag_name": tag, "name": f"ykxmz {version}", "body": body,
"target_commitish": "main", "draft": False,
"prerelease": False},
method="POST", token=token)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:400]
print(" [失败] HTTP %s: %s" % (exc.code, detail))
return 1
if status not in (200, 201):
print(" [失败] HTTP %s: %s" % (status, reply))
return 1
release_id = reply.get("id")
print(" ✓ 发行版已建,id=%s" % release_id)
print(" %s" % reply.get("html_url", ""))
if not release_id:
print(" [失败] 没拿到发行版 id")
return 1
# ---- 2. 传附件 ----
print()
print(" 上传文件:")
ok_count = 0
for path in files:
size_mb = path.stat().st_size / 1024 / 1024
# Gitea 要求文件名走查询参数
url = ("%s/releases/%s/assets?name=%s"
% (API, release_id, urllib.parse.quote(path.name)))
try:
status, reply = upload_asset(url, path, token)
if status in (200, 201):
print(" ✓ %s (%.2f MB)" % (path.name, size_mb))
ok_count += 1
else:
print(" ✗ %s: HTTP %s %s" % (path.name, status, reply))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:250]
print(" ✗ %s: HTTP %s %s" % (path.name, exc.code, detail))
except Exception as exc: # noqa: BLE001
print(" ✗ %s: %s" % (path.name, exc))
print()
if ok_count == len(files):
print(" 完成:%s/releases/tag/%s" % (WEB, tag))
return 0
print(" 部分失败(%d/%d)" % (ok_count, len(files)))
return 1
if __name__ == "__main__":
sys.exit(main())