From a7c79ff52c147c196e15e72ff1a3db3b8ceab34b Mon Sep 17 00:00:00 2001 From: shouko Date: Wed, 28 Oct 2026 22:59:39 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E5=8F=91=E7=89=88=E8=84=9A=E6=9C=AC=20r?= =?UTF-8?q?elease.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每次发版新开 Release、新版本号,同名 tag 直接报错退出,绝不覆盖旧 asset。 token 从 GITEA_TOKEN 或 tools/.release-token 读,不入库。 --- .gitignore | 4 + maipulse/tools/release.py | 198 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 maipulse/tools/release.py diff --git a/.gitignore b/.gitignore index f958de3..95d60b8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ astral/config.py .DS_Store Thumbs.db *.swp + +# 发版 token +maipulse/tools/.release-token +tools/.release-token diff --git a/maipulse/tools/release.py b/maipulse/tools/release.py new file mode 100644 index 0000000..d98feb1 --- /dev/null +++ b/maipulse/tools/release.py @@ -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", "user.email=qmy2055885931@gmail.com", + "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()) \ No newline at end of file