Files
maipulse/maipulse/tools/release.py
T
maipulse 9b5038818f release: v1.0.2
加固:release 构建 + R8 混淆,清理无用资源,修启动链路
2026-10-30 21:36:23 +08:00

443 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""发版:改版本号 -> 打包 -> 推两个仓 -> 新开一个 Release -> 上传 APK。
两个仓库,两套同步面:
· Gitea 公开仓 shouko/maipulse —— 脱敏:不推设计稿,本机路径/邮箱全部替换成占位符
· GitHub 私有仓 gu-kaf/maimai-backup —— 完整备份,含 keychips 与真实配置,不脱敏
★ 每次发版都新建 Release,绝不覆盖旧的那个:
旧版本留着,别人手上的旧包点「检查更新」还能按 tag 找到自己的历史版本,
也能回滚;覆盖 asset 会让同一个 tag 指向不同内容,hash 对不上,排查时非常难缠。
用法:
python tools/release.py 1.0.2 "修了什么"
python tools/release.py 1.0.2 "修了什么" --dry 只打印计划
python tools/release.py 1.0.2 --no-build 复用桌面上已有的 APK
token 从环境变量 GITEA_TOKEN 读,读不到再看 tools/.release-token(该文件不入库)。
GitHub 的凭据在 _gh_backup 的 remote 里,直接 git push 即可。
"""
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")
ANDROID_DIR = r"<android 工程>"
REPO_DIR = r"<仓库>" # Gitea 公开仓的工作副本
GH_DIR = r"<项目根>\_gh_backup" # GitHub 私有仓的工作副本
APK = r"%USERPROFILE%\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")
# ---- 公开仓同步面 ----
# 设计稿(PLAN*.md)不进公开仓:里面全是开发过程、逆向出处和本机路径
PUB_FILES = ["app.py"]
PUB_DIRS = ["core", "html", "tools", "data"]
# tools 里只带运行与发版必需的,探针/dump 一律不进库
PUB_TOOLS = {"build_apk.ps1", "blank_secrets.py", "blank_paths.py", "apply_update.py",
"fetch_df.py", "dump_master.py", "release.py", "restart.ps1",
"backfill_pack.py", "collect_masterdata.py"}
DATA_KEEP = ["master.json", "master", "icons"]
# ---- 私有仓同步面(完整备份,不脱敏)----
PRIV_FILES = ["app.py", "version.json", "config.filled.json",
"PLAN.md", "PLAN-APP.md", "PLAN-EXT.md"]
PRIV_DIRS = ["core", "html", "tools", "data", "keychips"]
SKIP_NAMES = {"__pycache__", ".release-token", "accounts.json", "shops.json",
"shop_current.json", "covers", "_backup", "logs"}
SKIP_SUFFIX = (".pyc", ".pyo")
def _force_copy(src, dst):
"""覆盖式复制:仓库里旧文件可能带只读属性(html/assets 下就有只读的 mp3/png),
shutil.copy2 会直接 PermissionError。先摘掉只读再写。"""
import shutil
import stat
if os.path.exists(dst):
try:
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD)
except OSError:
pass
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
def _walk_copy(src_dir, dst_dir, only_names=None, skip_names=SKIP_NAMES):
count = 0
for root, dirs, files in os.walk(src_dir):
dirs[:] = [x for x in dirs if x not in skip_names]
rel = os.path.relpath(root, src_dir)
target = dst_dir if rel == "." else os.path.join(dst_dir, rel)
for f in files:
if f in skip_names or f.endswith(SKIP_SUFFIX):
continue
if only_names is not None and f not in only_names:
continue
_force_copy(os.path.join(root, f), os.path.join(target, f))
count += 1
return count
def sync_public():
"""源码面 -> REPO_DIR/maipulse(脱敏版)。返回文件数。"""
import shutil
dst_root = os.path.join(REPO_DIR, "maipulse")
os.makedirs(dst_root, exist_ok=True)
count = 0
for name in PUB_FILES:
src = os.path.join(APP_DIR, name)
if os.path.isfile(src):
_force_copy(src, os.path.join(dst_root, name))
count += 1
for d in ("core", "html", "tools"):
src_dir = os.path.join(APP_DIR, d)
if os.path.isdir(src_dir):
count += _walk_copy(src_dir, os.path.join(dst_root, d),
only_names=PUB_TOOLS if d == "tools" else None)
data_src = os.path.join(APP_DIR, "data")
data_dst = os.path.join(dst_root, "data")
for name in DATA_KEEP:
p = os.path.join(data_src, name)
if os.path.isfile(p):
_force_copy(p, os.path.join(data_dst, name))
count += 1
elif os.path.isdir(p):
count += _walk_copy(p, os.path.join(data_dst, name))
# 公开仓里不放设计稿:上一次推上去的可能是旧的,主动删掉
for stale in ("PLAN.md", "PLAN-APP.md", "PLAN-EXT.md", "config.filled.json"):
p = os.path.join(dst_root, stale)
if os.path.exists(p):
os.remove(p)
print(" 公开仓移除设计稿/私有配置:%s" % stale)
return count
def sync_android_public():
"""Android 工程 -> REPO_DIR/android。返回文件数。"""
dst = os.path.join(REPO_DIR, "android")
if os.path.isdir(dst):
import shutil
shutil.rmtree(dst, ignore_errors=True)
count = 0
src_main = os.path.join(ANDROID_DIR, "app", "src", "main")
# assets 是打包时生成的,仓库里不要
count += _walk_copy(src_main, os.path.join(dst, "app", "src", "main"),
skip_names=SKIP_NAMES | {"assets"})
for f in ("build.gradle", "proguard-rules.pro", "app/build.gradle",
"settings.gradle", "gradle.properties",
"gradle/wrapper/gradle-wrapper.properties"):
s = os.path.join(ANDROID_DIR, f)
if os.path.isfile(s):
_force_copy(s, os.path.join(dst, f))
count += 1
return count
def sync_astro_public():
"""astral 根目录的漂移(config.py 除外,它由 blank_secrets 生成空模板)"""
src = r"<astral>"
dst = os.path.join(REPO_DIR, "astral")
if not os.path.isdir(src):
return 0
count = 0
for f in ("authv1.py", "buy_ticket.py", "netdelivery.py", "reincarnate.py",
"unlockmusic.py", "upsert.py", "use_ticket.py", "README.md",
"config.py.example"):
s = os.path.join(src, f)
if os.path.isfile(s):
_force_copy(s, os.path.join(dst, f))
count += 1
count += _walk_copy(os.path.join(src, "api"), os.path.join(dst, "api"))
count += _walk_copy(os.path.join(src, "tools"), os.path.join(dst, "tools"))
return count
def blank_public():
"""公开仓副本做路径脱敏(本机用户名 / 目录结构 / 邮箱)"""
sys.path.insert(0, os.path.join(APP_DIR, "tools"))
import blank_paths
changed = []
for sub in ("maipulse", "android", "astral"):
p = os.path.join(REPO_DIR, sub)
if os.path.isdir(p):
changed += blank_paths.process(p)
return changed
def sync_private():
"""完整源码 -> GH_DIR/maipulse(不脱敏,保留 keychips 与真实配置)"""
dst_root = os.path.join(GH_DIR, "maipulse")
os.makedirs(dst_root, exist_ok=True)
count = 0
for name in PRIV_FILES:
src = os.path.join(APP_DIR, name)
if os.path.isfile(src):
_force_copy(src, os.path.join(dst_root, name))
count += 1
for d in ("core", "html", "tools"):
src_dir = os.path.join(APP_DIR, d)
if os.path.isdir(src_dir):
count += _walk_copy(src_dir, os.path.join(dst_root, d))
data_src = os.path.join(APP_DIR, "data")
data_dst = os.path.join(dst_root, "data")
for name in DATA_KEEP:
p = os.path.join(data_src, name)
if os.path.isfile(p):
_force_copy(p, os.path.join(data_dst, name))
count += 1
elif os.path.isdir(p):
count += _walk_copy(p, os.path.join(data_dst, name))
# Android 工程也备一份(公开仓删了旧的,私有仓要跟着更新)
if os.path.isdir(ANDROID_DIR):
ah = os.path.join(GH_DIR, "android")
import shutil
if os.path.isdir(ah):
shutil.rmtree(ah, ignore_errors=True)
count += _walk_copy(os.path.join(ANDROID_DIR, "app", "src", "main"),
os.path.join(ah, "app", "src", "main"),
skip_names=SKIP_NAMES | {"assets"})
for f in ("build.gradle", "proguard-rules.pro", "app/build.gradle",
"settings.gradle", "gradle.properties",
"gradle/wrapper/gradle-wrapper.properties"):
s = os.path.join(ANDROID_DIR, f)
if os.path.isfile(s):
_force_copy(s, os.path.join(ah, f))
count += 1
return count
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, tries: int = 5):
"""Gitea 走 Cloudflare,偶发 [SSL: UNEXPECTED_EOF] 直接断连,重试几次就好。"""
import ssl
import time
url = "%s/api/v1/repos/%s/%s%s" % (SITE, OWNER, REPO, path)
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None
headers = {"Authorization": "token " + token(), "User-Agent": UA,
"Accept": "application/json", "Content-Type": "application/json"}
last = None
for attempt in range(tries):
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=90)
txt = resp.read().decode("utf-8")
return json.loads(txt) if txt.strip() else {}
except urllib.error.HTTPError as exc:
detail = exc.read()[:400]
# 4xx 是请求本身的问题,重试没意义,直接抛
if 400 <= exc.code < 500:
raise SystemExit("%s %s -> HTTP %s %s" % (method, path, exc.code, detail))
last = "HTTP %s %s" % (exc.code, detail)
except (urllib.error.URLError, ssl.SSLError, OSError) as exc:
last = "%s: %s" % (type(exc).__name__, exc)
wait = 2 * (attempt + 1)
print(" 第 %d 次失败(%s),%ds 后重试" % (attempt + 1, last, wait))
time.sleep(wait)
raise SystemExit("%s %s 连续 %d 次失败:%s" % (method, path, tries, last))
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):
shown = " ".join(cmd) if isinstance(cmd, list) else cmd
# 绝不把带 token 的 URL 打出来
shown = re.sub(r'://[^@\s]*@', '://***@', shown)
print(" $", shown)
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:
out = (r.stdout or "")[-800:]
err = (r.stderr or "")[-800:]
raise SystemExit("命令失败:%s\n%s\n%s" % (shown, out, err))
return r
def git_commit_push(where, msg):
run(["git", "add", "-A"], cwd=where)
r = run(["git", "status", "--porcelain"], cwd=where, check=False)
if (r.stdout or "").strip():
run(["git", "-c", "user.name=maipulse", "-c", "[email protected]",
"commit", "-m", msg], cwd=where, check=False)
else:
print(" 工作区无变更")
# 不管这次有没有新提交,都推一次:
# 上一次可能 commit 了但 push 失败,工作区是干净的,只看 status 会误判成"没变化"。
run(["git", "push", "origin", "HEAD:main"], cwd=where)
return True
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 "(空)"))
if tag_exists("v" + new_ver):
raise SystemExit("tag v%s 已存在 —— 每次发版要用新版本号,不能覆盖旧的" % new_ver)
if dry:
print("\n--dry:不写文件、不打包、不推送")
return 0
msg = "release: v%s\n\n%s" % (new_ver, notes or "-")
print("\n[1/7] 写版本号")
set_version(new_ver, build)
print(" app.py -> %s / %s" % (new_ver, build))
print("\n[2/7] 打包")
if no_build:
print(" 跳过,用现有 APK")
else:
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))
print("\n[3/7] 同步公开仓(脱敏)")
n = sync_public()
n += sync_android_public()
n += sync_astro_public()
print(" 源码 %d 个文件" % n)
changed = blank_public()
print(" 路径脱敏 %d 个文件" % len(changed))
print("\n[4/7] 推 Gitea")
pushed_pub = git_commit_push(REPO_DIR, msg)
print("\n[5/7] 同步 GitHub 私有仓(完整,不脱敏)")
n2 = sync_private()
print(" 源码 %d 个文件" % n2)
pushed_priv = git_commit_push(GH_DIR, msg)
print("\n[6/7] 新建 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")))
print("\n[7/7] 上传 APK")
boundary = "----maipulse" + os.urandom(8).hex()
with open(APK, "rb") as fp:
blob = fp.read()
head = ("--%s\r\nContent-Disposition: form-data; name=\"attachment\"; "
"filename=\"maipulse-%s.apk\"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n"
% (boundary, new_ver)).encode("utf-8")
payload = head + blob + ("\r\n--%s--\r\n" % boundary).encode("utf-8")
url = "%s/api/v1/repos/%s/%s/releases/%s/assets?name=maipulse-%s.apk" % (
SITE, OWNER, REPO, rel.get("id"), new_ver)
asset = None
import ssl
import time
for attempt in range(5):
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": "token " + token(), "User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=" + boundary,
})
try:
asset = json.loads(urllib.request.urlopen(req, timeout=600).read().decode("utf-8"))
break
except urllib.error.HTTPError as exc:
raise SystemExit("上传失败 HTTP %s %s" % (exc.code, exc.read()[:400]))
except (urllib.error.URLError, ssl.SSLError, OSError) as exc:
print(" 第 %d 次上传失败(%s),重试" % (attempt + 1, exc))
time.sleep(3 * (attempt + 1))
if asset is None:
raise SystemExit("上传 APK 连续失败")
print(" %s %d B" % (asset.get("name"), asset.get("size")))
print("\n发版完成")
print(" 公开仓 : %s/%s/%s (%s)" % (SITE, OWNER, REPO, "已推送" if pushed_pub else "无变更"))
print(" 私有仓 : github.com/gu-kaf/maimai-backup (%s)" % ("已推送" if pushed_priv else "无变更"))
print(" Release : %s" % rel.get("html_url"))
print(" 下载 : %s/%s/%s/releases/download/v%s/maipulse-%s.apk" % (
SITE, OWNER, REPO, new_ver, new_ver))
return 0
if __name__ == "__main__":
sys.exit(main())