add: 发版脚本 release.py

每次发版新开 Release、新版本号,同名 tag 直接报错退出,绝不覆盖旧 asset。
token 从 GITEA_TOKEN 或 tools/.release-token 读,不入库。
This commit is contained in:
shouko committed 2026-10-28 22:59:39 +08:00
1 parent a2f4984ef7
commit a7c79ff52c
2 files changed
+202

No files matched your search

+4
View File
@@ -28,3 +28,7 @@ astral/config.py
.DS_Store
Thumbs.db
*.swp
# 发版 token
maipulse/tools/.release-token
tools/.release-token
+198
View File
@@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
"""发版:改版本号 -> 打包 -> 推仓库 -> 新开一个 Release -> 上传 APK。
★ 每次发版都新建 Release,绝不覆盖旧的那个:
旧版本留着,别人手上的旧包点「检查更新」还能按 tag 找到自己的历史版本,
也能回滚;覆盖 asset 会让同一个 tag 指向不同内容,hash 对不上,排查时非常难缠。
用法:
python tools/release.py 1.0.1 "修了什么"
python tools/release.py 1.0.1 "修了什么" --dry 只打印计划
python tools/release.py 1.0.1 --no-build 复用桌面上已有的 APK
token 从环境变量 GITEA_TOKEN 读,读不到再看 tools/.release-token(该文件不入库)。
"""
from __future__ import annotations
import io
import json
import os
import re
import subprocess
import sys
import urllib.request
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
APP_PY = os.path.join(APP_DIR, "app.py")
REPO_DIR = r"D:\DSH\maimai\_repo"
APK = r"C:\Users\20558\Desktop\maipulse-debug.apk"
BUILD_SCRIPT = os.path.join(APP_DIR, "tools", "build_apk.ps1")
SITE = "https://git.fragrance.moe"
OWNER = "shouko"
REPO = "maipulse"
# Cloudflare 会拦非浏览器 UA
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
def token() -> str:
t = (os.environ.get("GITEA_TOKEN") or "").strip()
if t:
return t
p = os.path.join(APP_DIR, "tools", ".release-token")
if os.path.isfile(p):
return io.open(p, encoding="utf-8").read().strip()
raise SystemExit("没有 token:设置环境变量 GITEA_TOKEN,或写 tools/.release-token")
def api(path: str, method: str = "GET", body=None, raw: bytes = None, ctype: str = "application/json"):
url = "%s/api/v1/repos/%s/%s%s" % (SITE, OWNER, REPO, path)
data = raw if raw is not None else (json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None)
headers = {"Authorization": "token " + token(), "User-Agent": UA, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = ctype
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=60)
txt = resp.read().decode("utf-8")
return json.loads(txt) if txt.strip() else {}
except urllib.error.HTTPError as exc:
raise SystemExit("%s %s -> HTTP %s %s" % (method, path, exc.code, exc.read()[:300]))
def tag_exists(tag: str) -> bool:
"""只问存在性,不抛异常。"""
url = "%s/api/v1/repos/%s/%s/releases/tags/%s" % (SITE, OWNER, REPO, tag)
req = urllib.request.Request(url, headers={
"Authorization": "token " + token(), "User-Agent": UA, "Accept": "application/json"})
try:
urllib.request.urlopen(req, timeout=20)
return True
except urllib.error.HTTPError as exc:
return exc.code != 404
except Exception:
return False
def read_version() -> str:
s = io.open(APP_PY, encoding="utf-8").read()
m = re.search(r'^APP_VERSION\s*=\s*"([^"]+)"', s, re.M)
return m.group(1) if m else "0.0.0"
def set_version(ver: str, build: str):
s = io.open(APP_PY, encoding="utf-8").read()
s = re.sub(r'^APP_VERSION\s*=\s*"[^"]*"', 'APP_VERSION = "%s"' % ver, s, flags=re.M)
s = re.sub(r'^APP_BUILD\s*=\s*"[^"]*"', 'APP_BUILD = "%s"' % build, s, flags=re.M)
io.open(APP_PY, "w", encoding="utf-8", newline="\n").write(s)
def bump(ver: str) -> str:
parts = [int(x) for x in re.findall(r"\d+", ver)[:3]]
while len(parts) < 3:
parts.append(0)
parts[2] += 1
return "%d.%d.%d" % tuple(parts)
def run(cmd, cwd=None, check=True):
print(" $", " ".join(cmd) if isinstance(cmd, list) else cmd)
r = subprocess.run(cmd, cwd=cwd, shell=isinstance(cmd, str),
capture_output=True, text=True, encoding="utf-8", errors="replace")
if check and r.returncode != 0:
raise SystemExit("命令失败:%s\n%s\n%s" % (cmd, r.stdout[-800:], r.stderr[-800:]))
return r
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
dry = "--dry" in sys.argv
no_build = "--no-build" in sys.argv
cur = read_version()
new_ver = args[0].lstrip("vV") if args else bump(cur)
notes = args[1] if len(args) > 1 else ""
import time
build = time.strftime("%Y-%m-%d")
print("当前版本:%s" % cur)
print("新版版本:%s" % new_ver)
print("构建日期:%s" % build)
print("更新说明:%s" % (notes or "(空)"))
# 已有同名 tag 就直接停,绝不复用(404 = 不存在,正常继续)
if tag_exists("v" + new_ver):
raise SystemExit("tag v%s 已存在 —— 每次发版要用新版本号,不能覆盖旧的" % new_ver)
if dry:
print("\n--dry:不写文件、不打包、不推送")
return 0
# 1) 改版本号
print("\n[1/5] 写版本号")
set_version(new_ver, build)
print(" app.py -> %s / %s" % (new_ver, build))
# 2) 打包
if no_build:
print("\n[2/5] 跳过打包,用现有 APK")
else:
print("\n[2/5] 打包")
run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BUILD_SCRIPT])
if not os.path.isfile(APK):
raise SystemExit("没有 APK:%s" % APK)
size = os.path.getsize(APK)
print(" %s %.2f MB" % (APK, size / 1048576))
# 3) 同步进仓库并推送
print("\n[3/5] 推仓库")
import shutil
shutil.copy2(APP_PY, os.path.join(REPO_DIR, "maipulse", "app.py"))
run(["git", "add", "-A"], cwd=REPO_DIR)
run(["git", "-c", "user.name=shouko", "-c", "[email protected]",
"commit", "-m", "release: v%s\n\n%s" % (new_ver, notes or "-")], cwd=REPO_DIR, check=False)
run(["git", "push", "origin", "main"], cwd=REPO_DIR)
# 4) 新开 Release(不是复用)
print("\n[4/5] 新建 Release v%s" % new_ver)
rel = api("/releases", "POST", {
"tag_name": "v" + new_ver,
"target_commitish": "main",
"name": "v" + new_ver,
"body": (notes or "更新到 v%s" % new_ver) + "\n\n构建日期 " + build,
"draft": False,
"prerelease": False,
})
print(" id=%s %s" % (rel.get("id"), rel.get("html_url")))
# 5) 上传 APK 到这个新 Release
print("\n[5/5] 上传 APK")
boundary = "----maipulse" + os.urandom(8).hex()
with open(APK, "rb") as fp:
blob = fp.read()
body_parts = []
body_parts.append(("--%s\r\nContent-Disposition: form-data; name=\"attachment\"; "
"filename=\"maipulse.apk\"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n"
% boundary).encode("utf-8"))
body_parts.append(blob)
body_parts.append(("\r\n--%s--\r\n" % boundary).encode("utf-8"))
payload = b"".join(body_parts)
url = "%s/api/v1/repos/%s/%s/releases/%s/assets?name=maipulse.apk" % (SITE, OWNER, REPO, rel.get("id"))
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": "token " + token(), "User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=" + boundary,
})
asset = json.loads(urllib.request.urlopen(req, timeout=600).read().decode("utf-8"))
print(" %s %d B" % (asset.get("name"), asset.get("size")))
print("\n发版完成")
print(" Release : %s" % rel.get("html_url"))
print(" 下载 : %s/%s/%s/releases/download/v%s/maipulse.apk" % (SITE, OWNER, REPO, new_ver))
print(" 旧版本 : 都保留着,没有被覆盖")
return 0
if __name__ == "__main__":
sys.exit(main())