docs:新增远程发版脚本,Release 可用一条命令发布

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"。
This commit is contained in:
hanfuit committed 2026-11-05 19:44:14 +08:00
1 parent 0897761c47
commit b88e1a223b
4 files changed
+360 -2

No files matched your search

+2
View File
@@ -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`)
---
+9
View File
@@ -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 一定要成对。
## 九、关键设计说明
+47 -2
View File
@@ -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 指纹绑定),缺一个客户端就连不上
+302
View File
@@ -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>`
和远端 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 标题,默认「<version> 更新」")
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 <commit>` 再推送" % (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()