add: 发版脚本 release.py
每次发版新开 Release、新版本号,同名 tag 直接报错退出,绝不覆盖旧 asset。 token 从 GITEA_TOKEN 或 tools/.release-token 读,不入库。
This commit is contained in:
1 parent
a2f4984ef7
commit
a7c79ff52c
2 files changed
+202
No files matched your search
@@ -28,3 +28,7 @@ astral/config.py
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
|
||||
# 发版 token
|
||||
maipulse/tools/.release-token
|
||||
tools/.release-token
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user