#!/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()