feat: 服务端集成自动打包流程

This commit is contained in:
shen
2026-07-17 22:34:04 +08:00
parent a2437a039d
commit d1f070b251
221 changed files with 7284 additions and 447 deletions
+86 -182
View File
@@ -9,17 +9,19 @@ from pathlib import Path
from typing import List
from ..config import (
PROJECT_ROOT,
AUTOPACKING_DIR,
BUILD_BASE_DIR,
COPY_ITEMS,
DEFAULT_BUILD_DIR_RETENTION_HOURS,
GIT_REMOTE_URL,
BUILD_TIMEOUT_HOURS,
SKINS_DIR,
AUTOMATION_DIR,
get_source_dir,
get_git_remote_url,
mask_git_remote_url,
)
from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa
from .project_patcher import ProjectPatchError, apply_project_config
class BuildError(Exception):
@@ -135,14 +137,17 @@ def _db_update(db, task, **fields):
async def update_source(task_id: str, source_dir: Path, branch: str):
"""确保分支源码目录存在且为最新"""
remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url)
if not source_dir.exists():
# 首次:从远程 clone
if not GIT_REMOTE_URL:
if not remote_url:
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
source_dir.parent.mkdir(parents=True, exist_ok=True)
await log_streamer.emit(task_id, f"克隆仓库: {GIT_REMOTE_URL}{branch}")
await log_streamer.emit(task_id, f"克隆仓库: {remote_url_masked}{branch}")
process = await asyncio.create_subprocess_exec(
"git", "clone", "-b", branch, "--single-branch", GIT_REMOTE_URL, str(source_dir),
"git", "clone", "--depth", "1", "-b", branch, "--single-branch", remote_url, str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -157,6 +162,15 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
# 已存在:fetch + checkout + pull
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
if remote_url:
process = await asyncio.create_subprocess_exec(
"git", "remote", "set-url", "origin", remote_url,
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
await process.wait()
# fetch
process = await asyncio.create_subprocess_exec(
"git", "fetch", "origin",
@@ -174,7 +188,7 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
# checkout
process = await asyncio.create_subprocess_exec(
"git", "checkout", branch,
"git", "checkout", "-B", branch, f"origin/{branch}",
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
@@ -240,16 +254,16 @@ async def run_build_task(task_id: str):
# 1. 更新分支源码并拷贝
source_dir = get_source_dir(task.branch)
await asyncio.to_thread(_db_update, db, task, current_step="copy")
if GIT_REMOTE_URL:
if get_git_remote_url():
# 配置了远程仓库,从分支目录获取源码
await update_source(task_id, source_dir, task.branch)
elif source_dir.exists():
# 分支目录已存在,直接使用
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
else:
# 未配置远程仓库且分支目录不存在,使用默认源码目录
source_dir = PROJECT_ROOT
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL,使用默认源码: {source_dir}")
raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
)
build_dir = await copy_source_code(task_id, task, source_dir)
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
@@ -270,7 +284,7 @@ async def run_build_task(task_id: str):
# 4. 代码混淆(可选)
if task.obfuscation:
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
await run_obfuscation(task_id, task, config_data, build_dir, source_dir)
await run_obfuscation(task_id, task, config_data, build_dir)
# 5. 构建项目
await asyncio.to_thread(_db_update, db, task, current_step="build")
@@ -330,7 +344,7 @@ async def run_build_task(task_id: str):
await log_streamer.emit_error(task_id, error_msg)
if build_dir and build_dir.exists():
await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}")
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
except asyncio.CancelledError:
await asyncio.to_thread(_db_update, db, task,
@@ -339,6 +353,8 @@ async def run_build_task(task_id: str):
if build_dir and build_dir.exists():
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
await asyncio.to_thread(_cleanup_build_dir, build_dir)
else:
await asyncio.to_thread(log_streamer.save_log, task_id, None)
except Exception as e:
if isinstance(e, BuildError):
@@ -362,7 +378,7 @@ async def run_build_task(task_id: str):
await log_streamer.emit_error(task_id, f"打包失败: {error_msg}")
if build_dir and build_dir.exists():
await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}")
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
finally:
log_streamer.complete(task_id)
@@ -403,41 +419,33 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
config = load_config()
apps = config.get("apps", {})
schemes = config.get("schemes", {})
versions = config.get("versions", {})
app = apps.get(task.app_id, {})
scheme = schemes.get(task.scheme_id, {})
# 读取版本号
start_build_path = AUTOPACKING_DIR / "start_build_app.py"
app_ver = "2.180.0"
build_ver = "2.180.0.0"
if start_build_path.exists():
content = start_build_path.read_text()
for line in content.split("\n"):
if line.startswith("App_Ver"):
app_ver = line.split('"')[1] if '"' in line else app_ver
elif line.startswith("Build_Ver"):
build_ver = line.split('"')[1] if '"' in line else build_ver
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
app_ver = versions.get("app_ver", "2.180.0")
build_ver = versions.get("build_ver", "2.180.0.0")
# App_Store 打包时自动递增 Build_Ver 第四位版本号
if task.build_type == "App_Store" and start_build_path.exists():
if task.build_type == "App_Store":
async with _config_lock:
content = start_build_path.read_text(encoding="utf-8")
new_lines = []
for line in content.split("\n"):
if line.startswith("Build_Ver") and '"' in line:
old_ver = line.split('"')[1]
parts = old_ver.split(".")
if len(parts) >= 4:
parts[3] = str(int(parts[3]) + 1)
else:
parts.append("1")
build_ver = ".".join(parts)
new_lines.append(f'Build_Ver = "{build_ver}"')
else:
new_lines.append(line)
start_build_path.write_text("\n".join(new_lines), encoding="utf-8")
parts = build_ver.split(".")
if len(parts) >= 4:
parts[3] = str(int(parts[3]) + 1)
else:
parts.append("1")
build_ver = ".".join(parts)
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
from ..routers.config import save_config
save_config(config)
await log_streamer.emit(task_id, f"Build_Ver 已递增: {build_ver}")
# 构建配置
@@ -471,33 +479,26 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
theme_value = cert.get("theme", "")
config_data["THEME"] = theme_value
# 解析皮肤:优先识别旧式目录(AutoPacking/ymh),再尝试上传的 ZIP
# 皮肤只从服务端托管的上传包或默认资源读取,不使用分支源码目录。
if theme_value:
theme_resolved = False
# 1) 相对路径:在 build_dir(源码副本)下查找
candidate = build_dir / theme_value
if candidate.is_dir():
config_data["THEME"] = str(candidate)
theme_resolved = True
# 2) 绝对路径:直接使用
if not theme_resolved and os.path.isabs(theme_value) and os.path.isdir(theme_value):
theme_resolved = True
# 3) 上传的 ZIP 皮肤包
if not theme_resolved:
# 皮肤名可能是 "ymh" 或 "AutoPacking/ymh",取最后一段作为 ZIP 名
skin_name = Path(theme_value).name
skin_zip = SKINS_DIR / app.get("upload_key", "") / f"{skin_name}.zip"
if skin_zip.exists():
skin_extract_dir = build_dir / "skin_temp"
skin_extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(skin_zip, "r") as zf:
zf.extractall(skin_extract_dir)
entries = list(skin_extract_dir.iterdir())
if len(entries) == 1 and entries[0].is_dir():
config_data["THEME"] = str(entries[0])
else:
config_data["THEME"] = str(skin_extract_dir)
await log_streamer.emit(task_id, f"皮肤包 {skin_name}.zip 已解压")
skin_name = Path(theme_value).name
skin_zip = SKINS_DIR / app.get("upload_key", "") / f"{skin_name}.zip"
if skin_zip.exists():
skin_extract_dir = build_dir / "skin_temp"
skin_extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(skin_zip, "r") as zf:
zf.extractall(skin_extract_dir)
entries = list(skin_extract_dir.iterdir())
if len(entries) == 1 and entries[0].is_dir():
config_data["THEME"] = str(entries[0])
else:
config_data["THEME"] = str(skin_extract_dir)
await log_streamer.emit(task_id, f"皮肤包 {skin_name}.zip 已解压")
else:
default_theme = AUTOMATION_DIR / "themes" / skin_name
if default_theme.is_dir():
config_data["THEME"] = str(default_theme)
await log_streamer.emit(task_id, f"使用服务默认皮肤: {skin_name}")
else:
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
@@ -561,54 +562,13 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
"""替换项目配置"""
await log_streamer.emit_step(task_id, "替换项目配置")
# 清理 RDAppConfiguration.swift 中的注释配置行,避免 replace_line_by_keyword 替换到注释行
swift_config = build_dir / "readoor" / "3.0" / "AppConfig" / "RDAppConfiguration.swift"
if swift_config.exists():
for keyword in [r"\/\/let RD_APP_GUID:", r"\/\/let RD_API_DOMAIN:"]:
sed_proc = await asyncio.create_subprocess_exec(
"sed", "-i", "", f"/^[[:space:]]*{keyword}/d", str(swift_config),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
await sed_proc.wait()
await log_streamer.emit(task_id, "已清理注释配置行")
# 通过 config_output.json 文件传递配置(避免命令行参数过长截断)
config_file = build_dir / "config_output.json"
mode_flag = "--dict-config-file" if config_data.get("SCHEME") == "readoorDict" else "--config-file"
cmd = [
"python3",
str(AUTOPACKING_DIR / "replace_build_info.py"),
mode_flag,
str(config_file),
]
# 使用 subprocess 执行替换
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
patch_lines = []
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
patch_lines.append(decoded)
await log_streamer.emit(task_id, decoded)
await process.wait()
# 保存替换日志
patch_log_path = build_dir / "patch_result.log"
with open(patch_log_path, "w", encoding="utf-8") as f:
f.write("\n".join(patch_lines))
if process.returncode != 0:
raise Exception("项目配置替换失败")
try:
patch_lines = await asyncio.to_thread(apply_project_config, build_dir, config_data)
except ProjectPatchError as exc:
raise BuildError(str(exc), category="config") from exc
(build_dir / "patch_result.log").write_text("\n".join(patch_lines), encoding="utf-8")
for line in patch_lines:
await log_streamer.emit(task_id, line)
await log_streamer.emit(task_id, "项目配置替换完成")
@@ -636,18 +596,17 @@ async def run_pod_install(task_id: str, build_dir: Path):
await log_streamer.emit(task_id, "依赖安装完成")
async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path, source_root: Path):
async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path):
"""执行代码混淆"""
await log_streamer.emit_step(task_id, "代码混淆")
env = os.environ.copy()
env["TARGET_NAME"] = config_data.get("SCHEME", "")
env["SKIP_OBF_PHASE"] = "1"
# 自检
self_check_script = AUTOPACKING_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
self_check_script = AUTOMATION_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
process = await asyncio.create_subprocess_exec(
"bash", str(self_check_script), str(source_root),
"bash", str(self_check_script), str(build_dir),
cwd=str(build_dir),
env=env,
stdout=asyncio.subprocess.PIPE,
@@ -667,7 +626,7 @@ async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path
await log_streamer.emit(task_id, "混淆自检通过")
# 执行混淆
obfuscate_script = AUTOPACKING_DIR / "obfuscation" / "obfuscate_symbols.py"
obfuscate_script = AUTOMATION_DIR / "obfuscation" / "obfuscate_symbols.py"
process = await asyncio.create_subprocess_exec(
"python3", str(obfuscate_script), "--archive-mode",
cwd=str(build_dir),
@@ -800,67 +759,12 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil
"""上传 IPA,返回 (download_url, qr_code_path)"""
await log_streamer.emit_step(task_id, "上传分发平台")
qr_code_path = None
app_guid = config_data.get("APPID", "")
# 从 config.json 读取上传配置并注入
# 从当前服务的 config.json 读取上传配置,不执行分支源码中的上传脚本。
from ..routers.config import load_config
full_config = load_config()
config_data["_upload_config"] = full_config.get("upload", {})
# 注入路径信息,供 upload_iap.py 使用绝对路径
config_data["IPA_PATH"] = str(ipa_path)
config_data["BUILD_DIR"] = str(build_dir)
config_data["AUTOPACKING_DIR"] = str(AUTOPACKING_DIR)
# 使用现有的 upload_iap 脚本
upload_script = AUTOPACKING_DIR / "upload_iap.py"
if not upload_script.exists():
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
return None, None
# 通过临时文件传递配置
upload_config_file = build_dir / "upload_config.json"
with open(upload_config_file, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2, ensure_ascii=False)
cmd = [
"python3",
str(upload_script),
"--config-file",
str(upload_config_file),
]
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
oss_url = None
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
# 解析上传结果
if decoded.startswith("UPLOAD_RESULT:"):
try:
result = json.loads(decoded[len("UPLOAD_RESULT:"):])
oss_url = result.get("download_url")
qr_code_path = result.get("qr_code_path")
except json.JSONDecodeError:
pass
await process.wait()
# 兜底:查找二维码文件
if not qr_code_path and app_guid:
expected_qr = PROJECT_ROOT / "build" / f"{app_guid}.png"
if expected_qr.exists():
qr_code_path = str(expected_qr)
if process.returncode != 0:
raise BuildError("上传分发平台失败", category="upload")
return oss_url, qr_code_path
try:
await log_streamer.emit(task_id, f"使用 {config_data['_upload_config'].get('mode', '')} 发布 IPA...")
return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir)
except DistributionError as exc:
raise BuildError(str(exc), category="upload") from exc