修复打包签名并支持 App Store IPA 上传
This commit is contained in:
parent
7fd3845447
commit
2661435273
@ -2,7 +2,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import plistlib
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -39,6 +41,67 @@ class BuildError(Exception):
|
|||||||
source_prepare_lock = asyncio.Lock()
|
source_prepare_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_provisioning_profile(profile_path: Path) -> dict:
|
||||||
|
"""沿用 AutoPacking 脚本的 security cms 方式读取描述文件。"""
|
||||||
|
try:
|
||||||
|
plist_xml = subprocess.check_output(
|
||||||
|
["security", "cms", "-D", "-i", str(profile_path)],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# 与原脚本一致,跳过 security 可能输出在 XML 前面的 Apple 标记行。
|
||||||
|
lines = plist_xml.splitlines(keepends=True)
|
||||||
|
xml_start = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, line in enumerate(lines)
|
||||||
|
if line.lstrip().startswith((b"<?xml", b"<!DOCTYPE"))
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return plistlib.loads(b"".join(lines[xml_start:]))
|
||||||
|
except (OSError, subprocess.CalledProcessError, plistlib.InvalidFileException) as exc:
|
||||||
|
raise BuildError(f"读取描述文件失败: {profile_path.name}", category="provisioning") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_provisioning_profile(profile_reference: str) -> tuple[str, str]:
|
||||||
|
"""按 AutoPacking 的规则从描述文件名称提取 Name 和 TeamIdentifier。"""
|
||||||
|
configured_path = Path(profile_reference).expanduser()
|
||||||
|
if configured_path.is_file():
|
||||||
|
profile = _decode_provisioning_profile(configured_path)
|
||||||
|
else:
|
||||||
|
profiles_dir = (
|
||||||
|
Path.home()
|
||||||
|
/ "Library"
|
||||||
|
/ "Developer"
|
||||||
|
/ "Xcode"
|
||||||
|
/ "UserData"
|
||||||
|
/ "Provisioning Profiles"
|
||||||
|
)
|
||||||
|
if not profiles_dir.is_dir():
|
||||||
|
raise BuildError(f"Provisioning Profiles 目录不存在: {profiles_dir}", category="provisioning")
|
||||||
|
|
||||||
|
profile = None
|
||||||
|
for candidate in profiles_dir.iterdir():
|
||||||
|
if candidate.suffix != ".mobileprovision":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate_profile = _decode_provisioning_profile(candidate)
|
||||||
|
except BuildError:
|
||||||
|
continue
|
||||||
|
if candidate_profile.get("Name") == profile_reference:
|
||||||
|
profile = candidate_profile
|
||||||
|
break
|
||||||
|
if profile is None:
|
||||||
|
raise BuildError(f"未找到名称为 [{profile_reference}] 的描述文件", category="provisioning")
|
||||||
|
|
||||||
|
profile_name = profile.get("Name", "")
|
||||||
|
team_ids = profile.get("TeamIdentifier", [])
|
||||||
|
team_id = team_ids[0] if isinstance(team_ids, list) and team_ids else team_ids
|
||||||
|
if not profile_name or not team_id:
|
||||||
|
raise BuildError(f"描述文件缺少 Name 或 TeamIdentifier: {profile_reference}", category="provisioning")
|
||||||
|
return profile_name, team_id
|
||||||
|
|
||||||
|
|
||||||
# 错误分类规则:(关键词列表, category, 友好提示)
|
# 错误分类规则:(关键词列表, category, 友好提示)
|
||||||
_ERROR_RULES = [
|
_ERROR_RULES = [
|
||||||
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
||||||
@ -327,10 +390,11 @@ async def run_build_task(task_id: str):
|
|||||||
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
|
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
|
||||||
await asyncio.to_thread(_db_update, db, task, **update_fields)
|
await asyncio.to_thread(_db_update, db, task, **update_fields)
|
||||||
|
|
||||||
# 6. 上传分发(仅 Ad_Hoc)
|
# 6. 上传产物:Ad Hoc 发布安装页,App Store 上传 IPA 供下载提交。
|
||||||
if task.build_type == "Ad_Hoc":
|
|
||||||
await asyncio.to_thread(_db_update, db, task, current_step="upload")
|
await asyncio.to_thread(_db_update, db, task, current_step="upload")
|
||||||
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path, build_dir)
|
oss_url, qr_code_path = await upload_ipa(
|
||||||
|
task_id, task, config_data, ipa_path, build_dir
|
||||||
|
)
|
||||||
upload_fields = {}
|
upload_fields = {}
|
||||||
if oss_url:
|
if oss_url:
|
||||||
upload_fields["oss_url"] = oss_url
|
upload_fields["oss_url"] = oss_url
|
||||||
@ -526,46 +590,15 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
|||||||
else:
|
else:
|
||||||
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
|
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
|
||||||
|
|
||||||
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME
|
# 严格沿用 AutoPacking/start_build_app.py 的 configure_provisioning:
|
||||||
|
# 描述文件必须成功匹配,并从文件本身提取 Name 与 TeamIdentifier。
|
||||||
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
|
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
|
||||||
if provisioning_profile:
|
if provisioning_profile:
|
||||||
profile_path = provisioning_profile
|
profile_name, team_id = await asyncio.to_thread(
|
||||||
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
|
_resolve_provisioning_profile, provisioning_profile
|
||||||
if not os.path.exists(profile_path):
|
|
||||||
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
|
|
||||||
if os.path.isdir(profiles_dir):
|
|
||||||
import subprocess as _sp
|
|
||||||
import plistlib as _pl
|
|
||||||
for filename in os.listdir(profiles_dir):
|
|
||||||
if not filename.endswith(".mobileprovision"):
|
|
||||||
continue
|
|
||||||
candidate = os.path.join(profiles_dir, filename)
|
|
||||||
try:
|
|
||||||
plist_xml = _sp.check_output(
|
|
||||||
["security", "cms", "-D", "-i", candidate],
|
|
||||||
stderr=_sp.DEVNULL,
|
|
||||||
)
|
)
|
||||||
plist = _pl.loads(plist_xml)
|
config_data["PROVISIONING_NAME"] = profile_name
|
||||||
if plist.get("Name") == provisioning_profile:
|
config_data["TEAM_ID"] = team_id
|
||||||
profile_path = candidate
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if os.path.exists(profile_path):
|
|
||||||
try:
|
|
||||||
import subprocess as _sp
|
|
||||||
import plistlib as _pl
|
|
||||||
plist_xml = _sp.check_output(
|
|
||||||
["security", "cms", "-D", "-i", profile_path],
|
|
||||||
stderr=_sp.DEVNULL,
|
|
||||||
)
|
|
||||||
plist = _pl.loads(plist_xml)
|
|
||||||
team_ids = plist.get("TeamIdentifier", [])
|
|
||||||
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
|
|
||||||
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 关联域名等配置
|
# 关联域名等配置
|
||||||
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
|
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""服务端 Ad_Hoc 分发:OSS/WebDAV 上传、manifest、下载页和二维码。"""
|
"""服务端 IPA 分发:App Store IPA 直传及 Ad Hoc 安装页发布。"""
|
||||||
import json
|
import json
|
||||||
import plistlib
|
import plistlib
|
||||||
import posixpath
|
import posixpath
|
||||||
@ -127,7 +127,7 @@ def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
||||||
"""发布 IPA,返回下载页 URL 与二维码本地路径。"""
|
"""发布 IPA,返回下载 URL 与二维码本地路径。"""
|
||||||
upload = config.get("_upload_config", {})
|
upload = config.get("_upload_config", {})
|
||||||
mode = upload.get("mode", "")
|
mode = upload.get("mode", "")
|
||||||
if mode not in {"oss", "webdav"}:
|
if mode not in {"oss", "webdav"}:
|
||||||
@ -137,8 +137,14 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
|
|||||||
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
||||||
ipa_remote, manifest_remote, html_remote = _remote_paths(config)
|
ipa_remote, manifest_remote, html_remote = _remote_paths(config)
|
||||||
|
|
||||||
# manifest 依赖 IPA URL,先发布 IPA。
|
|
||||||
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
||||||
|
|
||||||
|
# App Store 包只需要提供 IPA 下载地址,不能生成 itms-services 安装页。
|
||||||
|
if config.get("BUILD_TYPE") == "App_Store":
|
||||||
|
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
||||||
|
return urls[".ipa"], ""
|
||||||
|
|
||||||
|
# Ad Hoc manifest 依赖 IPA URL,先发布 IPA。
|
||||||
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
||||||
_write_manifest(config, manifest, urls[".ipa"])
|
_write_manifest(config, manifest, urls[".ipa"])
|
||||||
urls.update(uploader(upload, [(manifest, manifest_remote)]))
|
urls.update(uploader(upload, [(manifest, manifest_remote)]))
|
||||||
|
|||||||
@ -163,6 +163,14 @@ def apply_project_config(build_dir: Path, config: dict) -> list[str]:
|
|||||||
(project, '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" =', f"\t\t\t\t\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\" = {config['PROVISIONING_NAME']};"),
|
(project, '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" =', f"\t\t\t\t\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\" = {config['PROVISIONING_NAME']};"),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# 与 AutoPacking/replace_build_info.py 保持一致:工程内所有构建配置都使用
|
||||||
|
# Distribution identity。只更新 exportOptions.plist 不足以覆盖 Archive 阶段,
|
||||||
|
# 否则 pbxproj 中残留的 iPhone/iOS Developer 会让 Xcode 查找开发证书。
|
||||||
|
replacements.extend([
|
||||||
|
(project, "CODE_SIGN_IDENTITY =", '\t\t\t\tCODE_SIGN_IDENTITY = "iPhone Distribution";'),
|
||||||
|
(project, '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" =', '\t\t\t\t"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";'),
|
||||||
|
])
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
for path, keyword, replacement in replacements:
|
for path, keyword, replacement in replacements:
|
||||||
if _replace_lines(path, keyword, replacement) == 0:
|
if _replace_lines(path, keyword, replacement) == 0:
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import plistlib
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
@ -17,6 +18,7 @@ from backend.services.build_service import (
|
|||||||
build_project,
|
build_project,
|
||||||
generate_config,
|
generate_config,
|
||||||
_cleanup_old_builds,
|
_cleanup_old_builds,
|
||||||
|
_resolve_provisioning_profile,
|
||||||
)
|
)
|
||||||
from backend.services.log_streamer import LogStreamer
|
from backend.services.log_streamer import LogStreamer
|
||||||
|
|
||||||
@ -233,7 +235,11 @@ async def test_generate_config(tmp_path, log_streamer):
|
|||||||
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
|
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
with patch("backend.routers.config.load_config", return_value=mock_config), \
|
||||||
|
patch(
|
||||||
|
"backend.services.build_service._resolve_provisioning_profile",
|
||||||
|
return_value=("pro", "TEAM123"),
|
||||||
|
):
|
||||||
result = await generate_config("t1", task, build_dir)
|
result = await generate_config("t1", task, build_dir)
|
||||||
|
|
||||||
assert result["APPID"] == "guid-123"
|
assert result["APPID"] == "guid-123"
|
||||||
@ -241,6 +247,8 @@ async def test_generate_config(tmp_path, log_streamer):
|
|||||||
assert result["BUILD_TYPE"] == "Ad_Hoc"
|
assert result["BUILD_TYPE"] == "Ad_Hoc"
|
||||||
assert result["BUNDLE_ID"] == "com.test.app"
|
assert result["BUNDLE_ID"] == "com.test.app"
|
||||||
assert result["CERTIFICATE"] == "cert"
|
assert result["CERTIFICATE"] == "cert"
|
||||||
|
assert result["PROVISIONING_NAME"] == "pro"
|
||||||
|
assert result["TEAM_ID"] == "TEAM123"
|
||||||
|
|
||||||
config_file = build_dir / "config_output.json"
|
config_file = build_dir / "config_output.json"
|
||||||
assert config_file.exists()
|
assert config_file.exists()
|
||||||
@ -248,6 +256,15 @@ async def test_generate_config(tmp_path, log_streamer):
|
|||||||
assert saved["APPID"] == "guid-123"
|
assert saved["APPID"] == "guid-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
|
||||||
|
profile_path = tmp_path / "profile.mobileprovision"
|
||||||
|
profile_path.write_bytes(b"signed profile")
|
||||||
|
decoded = plistlib.dumps({"Name": "a4YX061_adHoc", "TeamIdentifier": ["MG4Z7FU83W"]})
|
||||||
|
|
||||||
|
with patch("backend.services.build_service.subprocess.check_output", return_value=decoded):
|
||||||
|
assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W")
|
||||||
|
|
||||||
|
|
||||||
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
||||||
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
||||||
build_dir = tmp_path / "build"
|
build_dir = tmp_path / "build"
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
"""服务端分发产物生成测试。"""
|
"""服务端分发产物生成测试。"""
|
||||||
import plistlib
|
import plistlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from backend.services.distribution import _write_distribution_files, _write_download_page, _write_manifest
|
from backend.services.distribution import (
|
||||||
|
_write_distribution_files,
|
||||||
|
_write_download_page,
|
||||||
|
_write_manifest,
|
||||||
|
publish_ipa,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_distribution_files_use_current_service_config(tmp_path):
|
def test_distribution_files_use_current_service_config(tmp_path):
|
||||||
@ -20,3 +26,27 @@ def test_distribution_files_use_current_service_config(tmp_path):
|
|||||||
assert ipa_file.read_bytes() == b"ipa"
|
assert ipa_file.read_bytes() == b"ipa"
|
||||||
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
|
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
|
||||||
assert "itms-services://" in html.read_text(encoding="utf-8")
|
assert "itms-services://" in html.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_store_distribution_uploads_only_ipa(tmp_path):
|
||||||
|
ipa = tmp_path / "source.ipa"
|
||||||
|
ipa.write_bytes(b"app-store-ipa")
|
||||||
|
config = {
|
||||||
|
"APPID": "100",
|
||||||
|
"VERSION": "2.0.0",
|
||||||
|
"BUILD_TYPE": "App_Store",
|
||||||
|
"OSS_FLODER": "readoor",
|
||||||
|
"_upload_config": {"mode": "oss", "oss": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"backend.services.distribution._upload_oss",
|
||||||
|
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0.ipa"},
|
||||||
|
) as upload:
|
||||||
|
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
|
||||||
|
|
||||||
|
uploaded_files = upload.call_args.args[1]
|
||||||
|
assert len(uploaded_files) == 1
|
||||||
|
assert uploaded_files[0][0].suffix == ".ipa"
|
||||||
|
assert download_url.endswith("100_2_0_0.ipa")
|
||||||
|
assert qr_path == ""
|
||||||
|
|||||||
@ -19,7 +19,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
|||||||
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
|
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
|
||||||
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n'
|
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n'
|
||||||
"PROVISIONING_PROFILE_SPECIFIER = old;\n"
|
"PROVISIONING_PROFILE_SPECIFIER = old;\n"
|
||||||
'"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n',
|
'"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n'
|
||||||
|
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
|
||||||
|
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
(swift / "RDAppConfiguration.swift").write_text(
|
(swift / "RDAppConfiguration.swift").write_text(
|
||||||
@ -49,6 +51,10 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
|||||||
|
|
||||||
assert "工程、签名、版本和分发配置已更新" in messages
|
assert "工程、签名、版本和分发配置已更新" in messages
|
||||||
assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text()
|
assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text()
|
||||||
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in (project / "project.pbxproj").read_text()
|
project_content = (project / "project.pbxproj").read_text()
|
||||||
|
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in project_content
|
||||||
|
assert 'CODE_SIGN_IDENTITY = "iPhone Distribution";' in project_content
|
||||||
|
assert '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";' in project_content
|
||||||
|
assert "iPhone Developer" not in project_content
|
||||||
assert (build_dir / "exportOptions.plist").exists()
|
assert (build_dir / "exportOptions.plist").exists()
|
||||||
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user