feat: 服务端集成自动打包流程
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user