iOSBuildServer/backend/services/distribution.py
2026-07-20 17:09:28 +09:00

260 lines
11 KiB
Python
Raw Permalink 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.

"""服务端 IPA 分发App Store IPA 直传及 Ad Hoc 安装页发布。"""
import json
import plistlib
import posixpath
import re
import shutil
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
import httpx
class DistributionError(Exception):
"""分发配置或上传过程失败。"""
def _artifact_stem(config: dict) -> str:
version = config.get("VERSION", "0").replace(".", "_")
# 历史任务快照没有 SOURCE_BRANCH继续按旧文件名处理确保删除旧记录时能清理到原产物。
if "SOURCE_BRANCH" not in config:
return f"{config.get('APPID', 'app')}_{version}"
branch = re.sub(r"[^A-Za-z0-9._-]+", "_", config.get("SOURCE_BRANCH", "main")).strip("._-")
build_type = "appstore" if config.get("BUILD_TYPE") == "App_Store" else "adhoc"
return f"{config.get('APPID', 'app')}_{version}_{branch or 'main'}_{build_type}"
def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
stem = _artifact_stem(config)
ipa_output = output_dir / f"{stem}.ipa"
manifest = output_dir / f"{stem}.plist"
html = output_dir / f"{stem}.html"
if ipa_path != ipa_output:
shutil.copy2(ipa_path, ipa_output)
return ipa_output, manifest, html
def _write_manifest(config: dict, manifest: Path, ipa_url: str):
data = {
"items": [{
"assets": [{"kind": "software-package", "url": ipa_url}],
"metadata": {
"bundle-identifier": config.get("BUNDLE_ID", ""),
"bundle-version": config.get("VERSION", ""),
"kind": "software",
"title": config.get("APPID_NAME", "iOS App"),
},
}],
}
with manifest.open("wb") as f:
plistlib.dump(data, f)
def _write_download_page(config: dict, html: Path, manifest_url: str):
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&') }"
title = config.get("APPID_NAME", "iOS App")
html.write_text(
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">"
f"<title>{title}</title><body><h2>{title}</h2>"
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>"
"</body></html>",
encoding="utf-8",
)
def _remote_paths(config: dict) -> tuple[str, str, str, str]:
stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
root = posixpath.join(folder, "iOS")
return (
posixpath.join(root, f"{stem}.ipa"),
posixpath.join(root, f"{stem}.plist"),
posixpath.join(root, f"{stem}.html"),
posixpath.join(root, f"{stem}.png"),
)
def _delete_webdav(config: dict, remote_paths: list[str]):
webdav = config.get("webdav", {})
server_url = webdav.get("server_url", "").rstrip("/")
if not server_url or not webdav.get("username"):
raise DistributionError("WebDAV 配置不完整")
base_path = webdav.get("base_path", "/ios-builds").strip("/")
auth = (webdav.get("username", ""), webdav.get("password", ""))
with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client:
for relative_path in remote_paths:
remote_path = posixpath.join(base_path, relative_path)
response = client.delete(f"{server_url}/{remote_path}")
# 404 代表文件已不存在,可视为清理完成。
if response.status_code not in (200, 202, 204, 404):
raise DistributionError(f"WebDAV 删除失败: {relative_path} ({response.status_code})")
def _delete_oss(config: dict, remote_paths: list[str]):
oss = config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required):
raise DistributionError("OSS 配置不完整")
try:
import oss2
except ImportError as exc:
raise DistributionError("未安装 oss2请重新执行 ./deploy.sh build") from exc
bucket = oss2.Bucket(
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"],
oss["bucket_name"],
connect_timeout=30,
)
for relative_path in remote_paths:
result = bucket.delete_object(relative_path)
if result.status // 100 != 2:
raise DistributionError(f"OSS 删除失败: {relative_path}")
def _remote_paths_from_published_url(
build_config: dict, upload_config: dict, published_url: str,
) -> list[str] | None:
"""从已保存的公开链接还原实际对象键,兼容历史命名规则。"""
parsed = urlparse(published_url)
if not parsed.path:
return None
remote_path = unquote(parsed.path).lstrip("/")
# base_url 允许带路径前缀;该前缀是公开地址的一部分,不属于 OSS 对象键。
base_url = upload_config.get("oss", {}).get("base_url", "")
base = urlparse(base_url)
if base_url and base.netloc == parsed.netloc:
base_path = unquote(base.path).strip("/")
if base_path and remote_path.startswith(f"{base_path}/"):
remote_path = remote_path[len(base_path) + 1:]
path = Path(remote_path)
if path.suffix not in {".ipa", ".plist", ".html", ".png"}:
return None
if build_config.get("BUILD_TYPE") == "App_Store":
return [str(path.with_suffix(".ipa"))]
return [str(path.with_suffix(suffix)) for suffix in (".ipa", ".plist", ".html", ".png")]
def delete_published_artifacts(
build_config: dict, upload_config: dict, published_url: str = "",
):
"""删除任务对应的远端分发产物。
优先按任务保存的下载链接还原对象键。这样即使后续升级了文件命名
规则,或历史快照含有新的分支字段,仍会删除当时实际上传的文件。
"""
mode = upload_config.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
remote_paths = None
if mode == "oss" and published_url:
remote_paths = _remote_paths_from_published_url(
build_config, upload_config, published_url,
)
if not remote_paths:
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config)
remote_paths = [ipa_remote]
if build_config.get("BUILD_TYPE") != "App_Store":
remote_paths.extend([manifest_remote, html_remote, qr_remote])
deleter = _delete_oss if mode == "oss" else _delete_webdav
deleter(upload_config, remote_paths)
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
path = ""
for segment in remote_path.strip("/").split("/")[:-1]:
path = f"{path}/{segment}"
response = client.request("MKCOL", f"{server_url.rstrip('/')}{path}")
if response.status_code not in (200, 201, 204, 301, 405):
raise DistributionError(f"创建 WebDAV 目录失败: {path} ({response.status_code})")
def _upload_webdav(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
webdav = config.get("webdav", {})
server_url = webdav.get("server_url", "").rstrip("/")
public_url = (webdav.get("public_url") or server_url).rstrip("/")
if not server_url or not webdav.get("username"):
raise DistributionError("WebDAV 配置不完整")
base_path = webdav.get("base_path", "/ios-builds").strip("/")
auth = (webdav.get("username", ""), webdav.get("password", ""))
urls = {}
with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client:
for local_path, relative_path in files:
remote_path = posixpath.join(base_path, relative_path)
_ensure_webdav_dirs(client, server_url, remote_path)
with local_path.open("rb") as f:
response = client.put(f"{server_url}/{remote_path}", content=f.read())
if response.status_code not in (200, 201, 204):
raise DistributionError(f"WebDAV 上传失败: {local_path.name} ({response.status_code})")
urls[local_path.suffix] = f"{public_url}/{quote(remote_path)}"
return urls
def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
oss = config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required):
raise DistributionError("OSS 配置不完整")
try:
import oss2
except ImportError as exc:
raise DistributionError("未安装 oss2请重新执行 ./deploy.sh build") from exc
bucket = oss2.Bucket(
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"],
oss["bucket_name"],
connect_timeout=30,
)
base_url = (oss.get("base_url") or f"https://{oss['bucket_name']}.{oss['endpoint']}").rstrip("/")
urls = {}
for local_path, relative_path in files:
result = bucket.put_object_from_file(relative_path, str(local_path))
if result.status // 100 != 2:
raise DistributionError(f"OSS 上传失败: {local_path.name}")
urls[local_path.suffix] = f"{base_url}/{quote(relative_path)}"
return urls
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
"""发布 IPA返回下载 URL 与二维码公开 URL。"""
upload = config.get("_upload_config", {})
mode = upload.get("mode", "")
if mode not in {"oss", "webdav"}:
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
output_dir = build_dir / "distribution"
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(config)
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)])
_write_manifest(config, manifest, urls[".ipa"])
urls.update(uploader(upload, [(manifest, manifest_remote)]))
_write_download_page(config, html, urls[".plist"])
urls.update(uploader(upload, [(html, html_remote)]))
qr_path = output_dir / f"{_artifact_stem(config)}.png"
try:
import qrcode
except ImportError as exc:
raise DistributionError("未安装 qrcode请重新执行 ./deploy.sh build") from exc
qrcode.make(urls[".html"]).save(qr_path)
urls.update(uploader(upload, [(qr_path, qr_remote)]))
return urls[".html"], urls[".png"]