443 lines
17 KiB
Python
443 lines
17 KiB
Python
# -*- 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()) |