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:
1 parent
0897761c47
commit
b88e1a223b
4 files changed
+360
-2
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user