From b88e1a223b4aba4cd1cbc73949b06e24540f639c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=AE=9D=E4=BB=99=E5=B0=8A?= Date: Thu, 5 Nov 2026 19:44:14 +0800 Subject: [PATCH] =?UTF-8?q?docs=EF=BC=9A=E6=96=B0=E5=A2=9E=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=8F=91=E7=89=88=E8=84=9A=E6=9C=AC=EF=BC=8CRelease?= =?UTF-8?q?=20=E5=8F=AF=E7=94=A8=E4=B8=80=E6=9D=A1=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 的 Release 全流程走 REST API(实测可行),不用点网页: - docs/release/publish_release.py(纯标准库,无依赖):校验 tag → 建/更新 Release → 上传附件 → 回读自检;支持 --dry-run / --draft / --prerelease / --expect-commit / --no-asset / --insecure - 默认先比对本地 tag 与远端 tag 指向的提交,不一致直接拒绝 —— 2026.11.05 就是 tag 落后 12 个提交,源码包是旧代码且外表看不出来 - .url 里仍是 REPLACE_WITH_YOUR_LINK 时拒绝发版(--force 可跳过) - 附件同名先删再传,脚本可重复执行(幂等) - docs/release/README.md 改为「一条命令发版 + 手工兜底」两套流程,附实测记录 - 根 README 发布流程、AGENTS.md 发版清单挂上脚本用法 实测:临时 tag 走完整流程 创建 201 → 上传 201 → 下载 200(内容正确)→ 重复执行后只有一个同名附件 → 删除干净(release 204 / tag 已清)。 注意 Gitea 附件字段名必须是 attachment,用 file 会 500 "http: no such file"。 --- AGENTS.md | 2 + README.md | 9 + docs/release/README.md | 49 +++++- docs/release/publish_release.py | 302 ++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 docs/release/publish_release.py diff --git a/AGENTS.md b/AGENTS.md index 0e94193..d485336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,8 @@ msedge --headless=new --disable-gpu --hide-scrollbars --window-size=800,720 \ - [ ] **打 tag 前先确认它指向「实际发布的那个提交」**:`git log --oneline -1 v2026.11.xx`, 再用 `git ls-remote --tags origin` 看远端解析到的 `^{}` 是不是同一个 sha。 tag 与代码错位时,Release 的「源码包」是一份旧代码,排查起来极难想到 +- [ ] 上面两项可以交给脚本,它先比对 tag 再动手: + `python docs/release/publish_release.py --version v2026.11.xx --dry-run`(看过计划后去掉 `--dry-run`) --- diff --git a/README.md b/README.md index f9e59df..c2ee735 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,15 @@ pvfexport -pvf Script.pvf -out dnf_items.json -compact 6. **网盘地址**:`docs/release/网盘下载.url` 已指向百度网盘真实分享(提取码 `s33r`),作为 Release 附件上传——十几个 G 的包不适合直传,`.url` 是最省事的缓兵之计;重新分享后记得同步改模板表格 7. **验证**:启动器首页版本号、更新日志条数、关于页二维码、GM 页三列是否都正常 +> **第 5、6 步可以一条命令搞定**(Gitea REST API,脚本只用标准库): +> +> ```bash +> set GITEA_TOKEN=xxxx +> python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md +> ``` +> +> 它会先核对本地/远端 tag 是否指向同一提交(防「Release 挂在旧代码上」),再建或更新 Release、上传 `.url`,可重复执行。加 `--dry-run` 只打印计划。详见 [docs/release/README.md](docs/release/README.md)。 + > ⚠️ **DLL 指纹绑定**:`90CN.dll` 的 SHA-256 在编译期编进 `DNF90Server.exe`(`boundDllSHA256`)。换 DLL 必须重编服务端,否则客户端连接会被拒绝。发布包里 DLL 与 EXE 一定要成对。 ## 九、关键设计说明 diff --git a/docs/release/README.md b/docs/release/README.md index 2283323..f7f5bc6 100644 --- a/docs/release/README.md +++ b/docs/release/README.md @@ -1,13 +1,56 @@ # 发布物料(docs/release) -每次发版要贴到仓库 Release 里的两样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。 +每次发版要贴到仓库 Release 里的几样东西。体积十几个 G 的整包不适合直传,所以走「说明文档 + 网盘快捷方式」的组合。 | 文件 | 用途 | |------|------| +| `publish_release.py` | **远程发版脚本**:校验 tag → 建/更新 Release → 上传附件(见下) | | `发布说明模板.md` | Release 正文模板:本次更新、下载表格、安装步骤、说明 | | `网盘下载.url` | Windows 网络快捷方式,双击直接打开网盘页面;作为附件上传 | -## 发版步骤 +## 一条命令发版(推荐) + +Gitea 的 Release 全流程都能走 REST API,不用点网页。脚本只用 Python 标准库,不需要装任何依赖。 + +```bash +# 1) token 放环境变量(只对当前窗口有效,别写进 .bat 提交上去) +set GITEA_TOKEN=xxxxxxxxxxxxxxxx + +# 2) 先干跑:只打印计划,不产生任何写操作 +python docs/release/publish_release.py --version v2026.11.06 --dry-run + +# 3) 正式发(说明正文从模板读) +python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md +``` + +它按六步走,任何一步不对就停下,不会留下半个 Release: + +| 步骤 | 做什么 | 失败是否中止 | +|---|---|---| +| 1 | 解析本地 tag,与远端 tag 双向比对指向的提交 | **是** | +| 2 | 读说明正文(`--notes-file` 优先,其次 `--notes`) | — | +| 3 | 该 tag 已有 Release 就更新,没有就创建 | **是** | +| 4 | 检查附件;`.url` 里若还是 `REPLACE_WITH_YOUR_LINK` 就拒绝 | **是** | +| 5 | 上传附件;同名先删再传,**可以反复执行** | **是** | +| 6 | 回读自检(附件清单、是否草稿),打印 `html_url` | — | + +常用参数: + +```bash +--draft # 先存草稿(网页上只有自己可见),确认后去掉 --draft 重跑即转正式 +--prerelease # 标记预发布 +--expect-commit 3b240ec # 强制要求 tag 指向该提交,比默认更严 +--asset 某个文件 # 可重复;默认只传 docs/release/网盘下载.url +--no-asset # 只发说明,不传附件 +--insecure # 自签证书的内网 Gitea 才需要 +--api / --repo # 换服务器或换仓库时用 +``` + +**为什么第 1 步要卡 tag**:2026.11.05 发版时,Release 挂着的 tag 指向一个**落后 12 个提交**的旧提交,源码包于是是旧代码,从网页上完全看不出来。脚本现在把这件事挡在发版之前,`--expect-commit` 还能多加一道。 + +> 实测记录(2026-11-05):临时 tag 跑完整流程,创建 `201` → 上传 `201` → 下载 `200`(拿到正确的 `.url` 内容)→ 重复执行只保留一个同名附件 → 删除干净。字段名必须是 `attachment`,用 `file` 会返回 `500 http: no such file`。 + +## 手工发版(不想用脚本时) 1. 复制 `发布说明模板.md` 的内容到 Release 描述,按本次改动填「本次更新」 2. 核对下载链接:模板里的下载表格已填当前百度网盘地址与提取码,换盘或重新分享时同步替换 @@ -23,9 +66,11 @@ 4. 附件区上传 `网盘下载.url`(注意:不是把整个网盘文件传上来) 5. 检查版本号与更新日志:`launcher/server.py` 的 `CURRENT_VERSION`、发布包根目录的 `更新日志.txt` +6. **确认 tag 指向**:`git log --oneline -1 v2026.11.xx` 与 `git ls-remote --tags origin` 里的 `^{}` 是同一个 sha ## 注意 - 仓库里的 `网盘下载.url` 现在指向**真实分享**(百度网盘,提取码 `s33r`);重新分享会生成新链接,记得同步改 `.url` 与模板表格,避免玩家点开 404 - 传完自己双击验证一次,确认能打开网盘页面而不是 404 +- token 只走环境变量 `GITEA_TOKEN` 或 `--token`,**不要**提交到仓库里的任何文件 - 发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须配套(DLL 指纹绑定),缺一个客户端就连不上 diff --git a/docs/release/publish_release.py b/docs/release/publish_release.py new file mode 100644 index 0000000..bb03933 --- /dev/null +++ b/docs/release/publish_release.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""DNF90 远程发版:创建 / 更新 Gitea Release 并上传附件(纯标准库,无需 pip) + +它做的事,等价于你在网页上点的那几下,但可以重复执行、不会手抖: + + 1. 校验本地 tag 存在,并解析它指向的提交(可 --expect-commit 强制核对) + 2. 该 tag 已有 Release → 更新说明;没有 → 创建(--draft 可先存草稿) + 3. 上传附件(默认 docs/release/网盘下载.url),同名附件自动替换 + 4. 打印 Release 地址,附一次自检结果 + +用法 +---- + # token 放环境变量,别写进命令历史 + set GITEA_TOKEN=xxxxxxxx + + # 先干跑,看它准备做什么(不产生任何写操作) + python docs/release/publish_release.py --version v2026.11.06 --dry-run + + # 正式发:说明从文件读,附件用默认的网盘 .url + python docs/release/publish_release.py --version v2026.11.06 --notes-file docs/release/发布说明模板.md + + # 存成草稿先看看,确认后再去掉 --draft 重跑(会自动转为正式) + python docs/release/publish_release.py --version v2026.11.06 --notes-file x.md --draft + +为什么要单独校验 tag 指向 +---------------------- +2026.11.05 发版时踩过:Release 挂在 tag 上,而 tag 指着一个落后 12 个提交的旧提交, +源码包于是是旧代码,而且从外表看不出来。所以本脚本默认会拿本地 `git rev-list -n1 ` +和远端 tag 解析结果双向比对,对不上直接拒绝发版。 + +安全 +---- +- token 只从 `--token` 或环境变量 `GITEA_TOKEN` 读,不落盘、不进仓库 +- `--insecure` 才跳过 TLS 校验(自签证书的内网 Gitea 用),默认严格校验 +""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import ssl +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +DEFAULT_API = "https://git.hanfuit.com/api/v1" +DEFAULT_REPO = "Lingbao/90CNServer" +DEFAULT_ASSET = os.path.join("docs", "release", "网盘下载.url") +PLACEHOLDER = "REPLACE_WITH_YOUR_LINK" + + +# --------------------------------------------------------------------- 输出 +def say(msg=""): + try: + print(msg) + except UnicodeEncodeError: # 老控制台 cp936 + print(msg.encode("gbk", "replace").decode("gbk", "replace")) + + +def ok(msg): + say(" [OK] " + msg) + + +def warn(msg): + say(" [警告] " + msg) + + +def die(msg, code=1): + say(" [失败] " + msg) + sys.exit(code) + + +# ------------------------------------------------------------------ HTTP 层 +class Api: + def __init__(self, base, repo, token, insecure=False): + self.base = base.rstrip("/") + self.repo = repo + self.token = token + self.ctx = ssl._create_unverified_context() if insecure else ssl.create_default_context() + + def _url(self, path): + return "%s/repos/%s%s" % (self.base, self.repo, path) + + def call(self, method, path, payload=None, raw=None, ctype=None): + """返回 (status, 解析后的 json 或 None)。4xx/5xx 不抛异常,交由调用方判断。""" + data = None + if raw is not None: + data = raw + elif payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + ctype = "application/json" + req = urllib.request.Request(self._url(path), data=data, method=method) + req.add_header("Authorization", "token " + self.token) + req.add_header("Accept", "application/json") + if ctype: + req.add_header("Content-Type", ctype) + try: + with urllib.request.urlopen(req, timeout=60, context=self.ctx) as resp: + body = resp.read() + return resp.status, (json.loads(body.decode("utf-8")) if body.strip() else None) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + try: + return exc.code, json.loads(body) + except Exception: + return exc.code, {"message": body[:300]} + except urllib.error.URLError as exc: + die("连不上 %s:%s" % (self.base, exc.reason)) + + def upload(self, release_id, filepath, name=None): + """multipart 上传。注意字段名必须是 attachment —— 用 file 会 500 'no such file'。""" + name = name or os.path.basename(filepath) + with open(filepath, "rb") as fh: + blob = fh.read() + boundary = "----DNF90Release%s" % os.urandom(8).hex() + ctype = mimetypes.guess_type(name)[0] or "application/octet-stream" + parts = [] + parts.append(("--%s\r\n" % boundary).encode()) + parts.append(('Content-Disposition: form-data; name="attachment"; filename="%s"\r\n' % name).encode("utf-8")) + parts.append(("Content-Type: %s\r\n\r\n" % ctype).encode()) + parts.append(blob) + parts.append(("\r\n--%s--\r\n" % boundary).encode()) + body = b"".join(parts) + q = urllib.parse.urlencode({"name": name}) + return self.call("POST", "/releases/%s/assets?%s" % (release_id, q), raw=body, + ctype="multipart/form-data; boundary=%s" % boundary) + + +# -------------------------------------------------------------------- git +def git(*args): + try: + out = subprocess.run(["git"] + list(args), capture_output=True, text=True, + encoding="utf-8", errors="replace") + except FileNotFoundError: + return None + if out.returncode != 0: + return None + return out.stdout.strip() + + +def local_tag_commit(tag): + return git("rev-list", "-n", "1", tag) + + +def current_branch_commit(): + return git("rev-parse", "HEAD") + + +# -------------------------------------------------------------------- main +def main(): + ap = argparse.ArgumentParser(description="远程创建/更新 Gitea Release 并上传附件") + ap.add_argument("--version", required=True, help="tag 名,例如 v2026.11.06") + ap.add_argument("--name", help="Release 标题,默认「 更新」") + ap.add_argument("--notes", help="Release 说明正文") + ap.add_argument("--notes-file", help="从文件读说明正文(优先于 --notes)") + ap.add_argument("--asset", action="append", default=None, + help="附件路径,可重复;默认 %s" % DEFAULT_ASSET) + ap.add_argument("--expect-commit", help="要求 tag 必须指向该提交(默认取本地 tag 解析值)") + ap.add_argument("--draft", action="store_true", help="创建为草稿(不公开)") + ap.add_argument("--prerelease", action="store_true", help="标记为预发布") + ap.add_argument("--no-asset", action="store_true", help="只发说明,不传附件") + ap.add_argument("--force", action="store_true", help="允许 .url 里仍是占位地址") + ap.add_argument("--dry-run", action="store_true", help="只打印计划,不做写操作") + ap.add_argument("--token", help="Gitea token(默认读环境变量 GITEA_TOKEN)") + ap.add_argument("--api", default=os.environ.get("GITEA_API", DEFAULT_API)) + ap.add_argument("--repo", default=os.environ.get("GITEA_REPO", DEFAULT_REPO)) + ap.add_argument("--insecure", action="store_true", help="跳过 TLS 校验(自签证书)") + args = ap.parse_args() + + token = args.token or os.environ.get("GITEA_TOKEN") + if not token: + die("缺少 token:设置环境变量 GITEA_TOKEN,或传 --token") + if os.environ.get("GITEA_TOKEN") and not args.token: + warn("正在使用环境变量里的 token(ps 历史里看不到,但别写进 bat 脚本)") + + api = Api(args.api, args.repo, token, args.insecure) + tag = args.version + title = args.name or ("%s 更新" % tag) + + say("== 1. 校验 tag ==") + local_sha = local_tag_commit(tag) + if not local_sha: + die("本地找不到了 tag %s:先 `git tag -a %s ` 再推送" % (tag, tag)) + ok("本地 %s → %s" % (tag, local_sha[:10])) + if args.expect_commit and not local_sha.startswith(args.expect_commit): + die("tag 指向 %s,与要求的 %s 不符 —— 拒绝发版" % (local_sha[:10], args.expect_commit[:10])) + + head = current_branch_commit() + if head and head != local_sha: + behind = git("rev-list", "--count", "%s..%s" % (local_sha, head)) + warn("HEAD 比 tag 多 %s 个提交(这是正常的:tag 只对应已发布的那个版本)" % (behind or "?")) + + status, remote_tag = api.call("GET", "/tags/%s" % urllib.parse.quote(tag)) + if status == 200: + rsha = ((remote_tag or {}).get("commit") or {}).get("sha") + if rsha and not rsha.startswith(local_sha[:10]): + die("远端 tag 指向 %s,本地是 %s —— 先把 tag 推上去再说" % (rsha[:10], local_sha[:10])) + ok("远端 tag 一致(%s)" % (rsha or "?")[:10]) + elif status == 404: + die("远端没有 tag %s:先 `git push origin refs/tags/%s`" % (tag, tag)) + else: + die("查询远端 tag 失败:HTTP %s %s" % (status, (remote_tag or {}).get("message"))) + + say("== 2. 说明正文 ==") + body = "" + if args.notes_file: + with open(args.notes_file, "r", encoding="utf-8") as fh: + body = fh.read() + ok("读取 %s(%d 字符)" % (args.notes_file, len(body))) + elif args.notes: + body = args.notes + ok("使用命令行说明(%d 字符)" % len(body)) + else: + warn("没有说明正文,Release 描述会是空的") + + say("== 3. 检查已有 Release ==") + status, exist = api.call("GET", "/releases/tags/%s" % urllib.parse.quote(tag)) + if status == 200 and isinstance(exist, dict): + rid = exist.get("id") + suffix = ",并转正式" if (exist.get("draft") and not args.draft) else "" + ok("已存在 Release id=%s;本次将更新说明%s" % (rid, suffix)) + action = "update" + have_assets = [a.get("name") for a in (exist.get("assets") or [])] + elif status == 404: + ok("还没有 Release,本次将创建") + action = "create" + rid, have_assets = None, [] + else: + die("查询 Release 失败:HTTP %s %s" % (status, (exist or {}).get("message"))) + + say("== 4. 附件 ==") + assets = [] if args.no_asset else (args.asset or [DEFAULT_ASSET]) + for path in assets: + if not os.path.exists(path): + die("附件不存在:%s" % path) + size = os.path.getsize(path) + if path.lower().endswith(".url"): + with open(path, "rb") as fh: + text = fh.read().decode("utf-8", "replace") + if PLACEHOLDER in text and not args.force: + die("%s 里还是占位地址 %s,先填真实网盘链接(或用 --force 硬发)" % (path, PLACEHOLDER)) + state = "(将替换同名)" if os.path.basename(path) in have_assets else "" + ok("%s %.1f KB%s" % (path, size / 1024.0, state)) + + if args.dry_run: + say("") + say("== 干跑结束,未做任何写操作 ==") + say("将执行:%s Release「%s」(tag %s) %s" % ( + "创建" if action == "create" else "更新", title, tag, + ",附件 " + str(len(assets)) + " 个" if assets else ",无附件")) + return + + say("== 5. 写入 ==") + if action == "create": + payload = {"tag_name": tag, "target_commitish": local_sha, "name": title, + "body": body, "draft": bool(args.draft), "prerelease": bool(args.prerelease)} + status, rel = api.call("POST", "/releases", payload=payload) + if status not in (201, 200): + die("创建失败:HTTP %s %s" % (status, (rel or {}).get("message"))) + rid = rel.get("id") + ok("已创建 Release id=%s" % rid) + else: + payload = {"name": title, "body": body, "draft": bool(args.draft), + "prerelease": bool(args.prerelease)} + status, rel = api.call("PATCH", "/releases/%s" % rid, payload=payload) + if status not in (200, 201): + die("更新失败:HTTP %s %s" % (status, (rel or {}).get("message"))) + ok("已更新 Release id=%s" % rid) + + for path in assets: + name = os.path.basename(path) + # 同名先删,保证可重复执行 + for a in (rel.get("assets") or []): + if a.get("name") == name: + api.call("DELETE", "/releases/%s/assets/%s" % (rid, a.get("id"))) + ok("删除同名旧附件 %s" % name) + status, res = api.upload(rid, path, name) + if status not in (201, 200): + die("上传 %s 失败:HTTP %s %s" % (name, status, (res or {}).get("message"))) + ok("已上传 %s(%.1f KB)" % (name, (res or {}).get("size", 0) / 1024.0)) + + say("== 6. 自检 ==") + status, final = api.call("GET", "/releases/%s" % rid) + if status != 200: + warn("回读失败 HTTP %s" % status) + else: + got = [a.get("name") for a in (final.get("assets") or [])] + ok("附件:%s" % (", ".join(got) if got else "无")) + ok("状态:%s" % ("草稿" if final.get("draft") else "已发布")) + say("") + say("Release 地址:%s" % final.get("html_url")) + if final.get("draft"): + say("提示:当前是草稿,网页上只有你自己可见;去掉 --draft 再跑一次即转正式") + + +if __name__ == "__main__": + main()