Files
90CNServer/docs/release/publish_release.py
T
hanfuit b88e1a223b 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"。
2026-11-05 19:44:14 +08:00

303 lines
13 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.
#!/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()