diff --git a/_build/_c17.txt b/_build/_c17.txt new file mode 100644 index 0000000..20b16d6 --- /dev/null +++ b/_build/_c17.txt @@ -0,0 +1,37 @@ +加 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 账号里之后才能推。** + 私钥在仓库外,不会进版本库。 diff --git a/_build/ezhub_release.py b/_build/ezhub_release.py new file mode 100644 index 0000000..7f03f40 --- /dev/null +++ b/_build/ezhub_release.py @@ -0,0 +1,219 @@ +# -*- 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()) diff --git a/_build/final_clean_check.sh b/_build/final_clean_check.sh new file mode 100644 index 0000000..2dbd73a --- /dev/null +++ b/_build/final_clean_check.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# 确认线上公开页面干净 + 产物完整 +set -u + +BASE=https://relay.xxmxxs.top +UA='LanRemote/1.8.0' + +echo "════════ 1. 线上官网有没有管理后台字样 ════════" +curl -s --max-time 25 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html +echo " 官网 $(wc -c < /tmp/site.html) 字节" +hits=0 +for kw in zhongzhuanzhan "/admin" 管理后台 管理页 管理入口 admin_password; do + n=$(grep -c "$kw" /tmp/site.html 2>/dev/null || echo 0) + if [ "$n" -gt 0 ]; then + echo " **命中 $kw x$n**" + hits=$((hits + 1)) + fi +done +[ "$hits" = "0" ] && echo " ✓ 六个关键词一个都没有" + +echo +echo " 官网里的所有站内链接:" +grep -o 'href="/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /' + +echo +echo "════════ 2. 三个产物在不在 ════════" +for f in ykxmz-setup-1.8.0.exe ykxmz-server-1.8.0.zip ykxmz-watch-1.8.0.apk; do + hdr=$(curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE/download/$f") + code=$(echo "$hdr" | head -1 | awk '{print $2}') + len=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}') + printf " %-30s HTTP %-4s %s 字节\n" "$f" "$code" "${len:-?}" +done + +echo +echo "════════ 3. 服务端 zip 里有没有 CHANGELOG(不该有)════════" +python3 - <<'PY' +import zipfile, urllib.request +url = "https://relay.xxmxxs.top/download/ykxmz-server-1.8.0.zip" +try: + data = urllib.request.urlopen(url, timeout=60).read() + import io + with zipfile.ZipFile(io.BytesIO(data)) as z: + names = z.namelist() + print(" 条目 %d 个" % len(names)) + bad = [n for n in names + if "CHANGELOG" in n.upper() or "RELEASE" in n.upper()] + print(" %s 没有把更新日志打进包里" + % ("✓" if not bad else "**有 " + str(bad) + "**")) + # 运维文档里提到管理页是允许的 + for n in names: + if n.endswith(".txt"): + try: + t = z.read(n).decode("utf-8", "replace") + except Exception: + continue + c = t.count("管理页") + if c: + print(" (运维文档 %s 提到管理页 %d 次 —— 按约定允许)" + % (n.split("/")[-1], c)) +except Exception as e: + print(" 取包失败: %s" % e) +PY + +echo +echo "════════ 4. 管理页本身还正常吗(不公开提,但得能登录)════════" +PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null) +curl -s -c /tmp/ck.txt -o /dev/null --max-time 20 \ + -d "username=xmz7891&password=$PASS" "$BASE/admin/login" +echo " 登录后访问: HTTP $(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt --max-time 20 -H "User-Agent: $UA" "$BASE/zhongzhuanzhan")" +echo " 未登录: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/zhongzhuanzhan")" + +echo +echo "════════ 5. 中转版本 ════════" +curl -s --max-time 15 "$BASE/v1/health" | head -c 150 +echo diff --git a/_build/release_all.py b/_build/release_all.py new file mode 100644 index 0000000..23ac3c8 --- /dev/null +++ b/_build/release_all.py @@ -0,0 +1,318 @@ +# -*- 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 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 " + " [版本号] [--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())