- 分支管理新增「分支 / Tag」类型选择,Tag 存于 config["tags"],与分支不可重名 - 每个 Tag 与分支一样拥有同名独立版本轨道,默认上架轨 - 打包页按「分支」「Tag」分组选择;任务新增 ref_type 字段记录引用类型 - Tag 打包时 fetch --tags --force 后以游离 HEAD 检出 refs/tags/<tag> - 历史记录与下载页按类型显示「分支」或「Tag」 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""服务端 IPA 分发:App Store IPA 直传及 Ad Hoc 安装页发布。"""
|
|
import json
|
|
import plistlib
|
|
import posixpath
|
|
import re
|
|
import shutil
|
|
from html import escape
|
|
from pathlib import Path
|
|
from urllib.parse import quote, unquote, urlparse
|
|
|
|
import httpx
|
|
|
|
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
|
|
|
|
|
|
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)
|
|
|
|
|
|
DOWNLOAD_PAGE_TEMPLATE = """<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="format-detection" content="telephone=no">
|
|
<title>{title} - 安装</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #f2f3f7;
|
|
--card: #ffffff;
|
|
--text: #1c1c1e;
|
|
--muted: #8a8a8e;
|
|
--line: rgba(60, 60, 67, .12);
|
|
--brand: #50a0ff;
|
|
}}
|
|
@media (prefers-color-scheme: dark) {{
|
|
:root {{
|
|
--bg: #000000;
|
|
--card: #1c1c1e;
|
|
--text: #f5f5f7;
|
|
--muted: #98989d;
|
|
--line: rgba(255, 255, 255, .12);
|
|
}}
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
padding: 10vh 20px calc(32px + env(safe-area-inset-bottom));
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
|
-webkit-text-size-adjust: 100%;
|
|
}}
|
|
.card {{
|
|
width: 100%;
|
|
max-width: 420px;
|
|
background: var(--card);
|
|
border-radius: 20px;
|
|
padding: 28px 24px 24px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, .08);
|
|
text-align: center;
|
|
}}
|
|
.name {{
|
|
margin: 0;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
word-break: break-word;
|
|
}}
|
|
.version {{
|
|
margin: 6px 0 22px;
|
|
color: var(--muted);
|
|
font-size: 15px;
|
|
}}
|
|
.install {{
|
|
display: block;
|
|
padding: 15px;
|
|
border-radius: 14px;
|
|
background: var(--brand);
|
|
color: #fff;
|
|
font-size: 17px;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}}
|
|
.install:active {{ opacity: .75; }}
|
|
.meta {{
|
|
margin: 22px 0 0;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--line);
|
|
text-align: left;
|
|
font-size: 14px;
|
|
}}
|
|
.meta div {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 5px 0;
|
|
}}
|
|
.meta dt {{ color: var(--muted); flex: none; }}
|
|
.meta dd {{
|
|
margin: 0;
|
|
text-align: right;
|
|
word-break: break-all;
|
|
font-variant-numeric: tabular-nums;
|
|
}}
|
|
.tips {{
|
|
margin: 18px 0 0;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
text-align: left;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1 class="name">{title}</h1>
|
|
<p class="version">版本 {version}</p>
|
|
<a class="install" href="{install_url}">安装 App</a>
|
|
<dl class="meta">{meta}</dl>
|
|
<p class="tips">请使用 iPhone 自带的 Safari 浏览器打开本页面安装。<br>
|
|
安装后如提示“未受信任的开发者”,请前往「设置 → 通用 → VPN 与设备管理」信任该描述文件。</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
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") or "iOS App"
|
|
meta_items = [
|
|
("构建版本", config.get("BUILD_VERSION", "")),
|
|
("Bundle ID", config.get("BUNDLE_ID", "")),
|
|
("Tag" if config.get("SOURCE_REF_TYPE") == "tag" else "分支", config.get("SOURCE_BRANCH", "")),
|
|
]
|
|
meta = "".join(
|
|
f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
|
|
for label, value in meta_items
|
|
if value
|
|
)
|
|
html.write_text(
|
|
DOWNLOAD_PAGE_TEMPLATE.format(
|
|
title=escape(title),
|
|
version=escape(str(config.get("VERSION", ""))),
|
|
install_url=escape(install_url, quote=True),
|
|
meta=meta,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _dsym_remote_path(config: dict) -> str:
|
|
stem = _artifact_stem(config)
|
|
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
|
return posixpath.join(folder, "iOS", f"{stem}.dSYM.zip")
|
|
|
|
|
|
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 delete_published_dsym(build_config: dict, upload_config: dict):
|
|
"""删除任务对应的远端 dSYM 压缩包。
|
|
|
|
dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定,
|
|
无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。
|
|
"""
|
|
mode = upload_config.get("mode", "")
|
|
if mode not in {"oss", "webdav"}:
|
|
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
|
|
|
|
deleter = _delete_oss if mode == "oss" else _delete_webdav
|
|
deleter(upload_config, [_dsym_remote_path(build_config)])
|
|
|
|
|
|
def _sign_oss_url(upload_config: dict, remote_path: str) -> str:
|
|
oss = upload_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,
|
|
)
|
|
return bucket.sign_url("GET", remote_path, DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
|
|
|
|
|
|
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
|
|
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
|
|
if upload_config.get("mode") != "oss":
|
|
return published_url
|
|
|
|
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
|
|
if not remote_paths:
|
|
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
|
|
return _sign_oss_url(upload_config, remote_paths[0])
|
|
|
|
|
|
def get_published_dsym_url(build_config: dict, upload_config: dict, dsym_url: str) -> str:
|
|
"""返回 dSYM 受控下载的短时链接;WebDAV 保持原公开链接。
|
|
|
|
dSYM 的远端对象键完全由构建配置决定,直接按命名规则重新计算即可,
|
|
不需要像 IPA 那样从已保存的下载链接反推。
|
|
"""
|
|
if upload_config.get("mode") != "oss":
|
|
return dsym_url
|
|
return _sign_oss_url(upload_config, _dsym_remote_path(build_config))
|
|
|
|
|
|
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"]
|
|
|
|
|
|
def publish_dsym(config: dict, dsym_path: Path, build_dir: Path) -> str:
|
|
"""压缩并上传主 target 的 dSYM,返回下载 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"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
zip_base = output_dir / f"{_artifact_stem(config)}.dSYM"
|
|
zip_path = Path(shutil.make_archive(
|
|
str(zip_base), "zip", root_dir=str(dsym_path.parent), base_dir=dsym_path.name,
|
|
))
|
|
|
|
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
|
urls = uploader(upload, [(zip_path, _dsym_remote_path(config))])
|
|
return urls[".zip"]
|