feat: 支持按 Tag 打包,分支管理可添加 Tag

- 分支管理新增「分支 / 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>
This commit is contained in:
shenlei
2026-09-17 11:59:18 +09:00
co-authored by Claude Opus 5
parent 6867a3cb01
commit 5582aadc09
18 changed files with 347 additions and 50 deletions
+1
View File
@@ -27,6 +27,7 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu
- `frontend/src/views/` — BuildView, HistoryView, ConfigView - `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI - `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI
- Versions are per-branch: every branch owns a track in `config["versions"]["tracks"]`, named after the branch (master/main keeps the historical name `release`); `config["branch_track"]` maps branch → track name, and each track's `kind` is `release` (build number auto-increments on App_Store) or `feature` (never increments) - Versions are per-branch: every branch owns a track in `config["versions"]["tracks"]`, named after the branch (master/main keeps the historical name `release`); `config["branch_track"]` maps branch → track name, and each track's `kind` is `release` (build number auto-increments on App_Store) or `feature` (never increments)
- Tags are buildable refs too: `config["tags"]` (names never collide with branches), tracked in `branch_track` like branches (default kind `release`); tasks store the name in `branch` plus `ref_type` (`branch`/`tag`), and tag builds check out `refs/tags/<tag>` detached
## Conventions ## Conventions
+2 -1
View File
@@ -187,7 +187,7 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 | | App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
| Scheme 管理 | 打包 Scheme 配置 | | Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 | | 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表,以及每个分支的版本轨道类型(上架 / 自测) | | 分支管理 | 可打包的代码分支和 Tag 列表,以及各自的版本轨道类型(上架 / 自测);Tag 打包时以游离 HEAD 检出 `refs/tags/<tag>` |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) | | 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 | | 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
@@ -212,6 +212,7 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
``` ```
- 新增分支时自动建一条同名轨道,类型按分支名推断,可在「分支管理」页逐个改(只改构建号是否自增,版本号仍各自独立)。 - 新增分支时自动建一条同名轨道,类型按分支名推断,可在「分支管理」页逐个改(只改构建号是否自增,版本号仍各自独立)。
- Tag 存在 `config["tags"]`,与分支不可重名,同样在 `branch_track` 中各有一条同名轨道,新增时默认为上架轨。
- 「打包设置」页按分支分块填写版本号。 - 「打包设置」页按分支分块填写版本号。
- 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。 - 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。
- App Store 的构建号唯一性按 App_Ver 全局判定,因此两个上架分支若临时用了同一个 App_Ver,递增时会跨轨道取最大值 +1,避免重号被拒。 - App Store 的构建号唯一性按 App_Ver 全局判定,因此两个上架分支若临时用了同一个 App_Ver,递增时会跨轨道取最大值 +1,避免重号被拒。
+1
View File
@@ -23,6 +23,7 @@ def _migrate_db():
"""简单的数据库迁移:为旧表添加缺失的列""" """简单的数据库迁移:为旧表添加缺失的列"""
migrations = [ migrations = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "ref_type", "VARCHAR DEFAULT 'branch'"),
("tasks", "error_category", "VARCHAR"), ("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"), ("tasks", "superseded_at", "TIMESTAMP"),
("tasks", "dsym_url", "VARCHAR"), ("tasks", "dsym_url", "VARCHAR"),
+2 -1
View File
@@ -15,7 +15,8 @@ class Task(Base):
scheme_id = Column(String, nullable=False) scheme_id = Column(String, nullable=False)
scheme_name = Column(String, nullable=False) scheme_name = Column(String, nullable=False)
obfuscation = Column(Boolean, default=False) obfuscation = Column(Boolean, default=False)
branch = Column(String, default="main") branch = Column(String, default="main") # 分支名或 Tag 名,由 ref_type 区分
ref_type = Column(String, default="branch") # branch/tag
# 状态 # 状态
status = Column(String, default="pending") # pending/running/completed/failed/cancelled status = Column(String, default="pending") # pending/running/completed/failed/cancelled
+7 -1
View File
@@ -1,7 +1,7 @@
"""打包选择 API""" """打包选择 API"""
from fastapi import APIRouter from fastapi import APIRouter
from .config import load_config from .config import get_tags, load_config
router = APIRouter(prefix="/api", tags=["apps"]) router = APIRouter(prefix="/api", tags=["apps"])
@@ -44,3 +44,9 @@ async def get_branches_for_build():
"""获取分支列表(供打包选择)""" """获取分支列表(供打包选择)"""
config = load_config() config = load_config()
return config.get("branches", ["main"]) return config.get("branches", ["main"])
@router.get("/tags")
async def get_tags_for_build():
"""获取 Tag 列表(供打包选择)"""
return get_tags(load_config())
+83 -7
View File
@@ -108,6 +108,27 @@ MASTER_BRANCH_NAMES = {"master", "main"}
RELEASE_BRANCH_NAMES = MASTER_BRANCH_NAMES | {"develop", "dev"} RELEASE_BRANCH_NAMES = MASTER_BRANCH_NAMES | {"develop", "dev"}
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-") RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-")
# 可打包的代码引用类型:分支在 config["branches"]Tag 在 config["tags"],两者名字不可重复,
# 因此任务里只存名字即可反查类型;分支与 Tag 共用 branch_track 映射各自的版本轨道。
REF_BRANCH = "branch"
REF_TAG = "tag"
def get_tags(config: dict) -> list:
"""取可打包的 Tag 列表。"""
tags = config.get("tags")
return tags if isinstance(tags, list) else []
def get_ref_type(config: dict, name: str) -> str:
"""按名字判断是分支还是 Tag。"""
return REF_TAG if name in get_tags(config) else REF_BRANCH
def _build_refs(config: dict) -> list:
"""所有可打包的引用(分支在前,Tag 在后)。"""
return list(config.get("branches", []) or []) + get_tags(config)
def _is_release_branch(branch: str) -> bool: def _is_release_branch(branch: str) -> bool:
"""按分支名推断是否为上架分支。""" """按分支名推断是否为上架分支。"""
@@ -120,6 +141,13 @@ def _default_kind_for_branch(branch: str) -> str:
return KIND_RELEASE if _is_release_branch(branch) else KIND_FEATURE return KIND_RELEASE if _is_release_branch(branch) else KIND_FEATURE
def _default_kind_for_ref(config: dict, name: str) -> str:
"""新建轨道时的缺省类型:Tag 一般用于重打已发布的版本,默认上架轨;分支按名字推断。"""
if get_ref_type(config, name) == REF_TAG:
return KIND_RELEASE
return _default_kind_for_branch(name)
def _default_track_for_branch(branch: str) -> str: def _default_track_for_branch(branch: str) -> str:
"""分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。 """分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。
@@ -252,7 +280,7 @@ def _ensure_versions(config: dict) -> bool:
branch_track = {} branch_track = {}
config["branch_track"] = branch_track config["branch_track"] = branch_track
changed = True changed = True
for branch in config.get("branches", []) or []: for branch in _build_refs(config):
name = branch_track.get(branch) name = branch_track.get(branch)
if splitting and name in FIXED_TRACKS: if splitting and name in FIXED_TRACKS:
# 旧的共用轨 → 各分支自己的轨道(master/main 仍留在 release 轨) # 旧的共用轨 → 各分支自己的轨道(master/main 仍留在 release 轨)
@@ -263,7 +291,7 @@ def _ensure_versions(config: dict) -> bool:
branch_track[branch] = name branch_track[branch] = name
changed = True changed = True
if name not in tracks: if name not in tracks:
kind = _default_kind_for_branch(branch) kind = _default_kind_for_ref(config, branch)
tracks[name] = _seed_track(tracks, kind, legacy[kind]) tracks[name] = _seed_track(tracks, kind, legacy[kind])
if legacy[kind]: if legacy[kind]:
split_kinds.add(kind) split_kinds.add(kind)
@@ -336,15 +364,16 @@ def next_build_no(tracks: dict, app_ver: str) -> int:
return max(used, default=0) + 1 return max(used, default=0) + 1
def resolve_track(config: dict, branch: str, track: str): def resolve_track(config: dict, branch: str, track: str, default_kind: str = None):
"""把用户传入的轨道值解析为 (轨道名, 轨道类型)。 """把用户传入的轨道值解析为 (轨道名, 轨道类型)。
传类型(release / feature)时用分支自己的轨道,并把它设成该类型; 传类型(release / feature)时用分支自己的轨道,并把它设成该类型;
也可直接传已存在的轨道名,让这个分支与另一个分支共用同一条版本线(类型不变)。 也可直接传已存在的轨道名,让这个分支与另一个分支共用同一条版本线(类型不变)。
不传时类型取 default_kind,缺省按分支名推断。
""" """
value = (track or "").strip() value = (track or "").strip()
if not value: if not value:
return _default_track_for_branch(branch), _default_kind_for_branch(branch) return _default_track_for_branch(branch), default_kind or _default_kind_for_branch(branch)
if value in KINDS: if value in KINDS:
return _default_track_for_branch(branch), value return _default_track_for_branch(branch), value
if value in ((config.get("versions") or {}).get("tracks") or {}): if value in ((config.get("versions") or {}).get("tracks") or {}):
@@ -823,6 +852,8 @@ async def add_branch(data: dict):
branches = config.get("branches", ["main"]) branches = config.get("branches", ["main"])
if name in branches: if name in branches:
raise HTTPException(status_code=400, detail="分支已存在") raise HTTPException(status_code=400, detail="分支已存在")
if name in get_tags(config):
raise HTTPException(status_code=400, detail="已存在同名 Tag")
track, kind = resolve_track(config, name, data.get("track")) track, kind = resolve_track(config, name, data.get("track"))
branches.append(name) branches.append(name)
config["branches"] = branches config["branches"] = branches
@@ -834,7 +865,7 @@ async def add_branch(data: dict):
@router.put("/branches/track") @router.put("/branches/track")
async def update_branch_track(data: dict): async def update_branch_track(data: dict):
"""修改分支的版本轨道类型(release=上架,构建号自增;feature=自测,不自增)。 """修改分支或 Tag 的版本轨道类型(release=上架,构建号自增;feature=自测,不自增)。
每个分支用自己的一条轨道,切换类型只改这条轨道的行为,版本号仍与其它分支互不影响。 每个分支用自己的一条轨道,切换类型只改这条轨道的行为,版本号仍与其它分支互不影响。
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。 分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
@@ -845,7 +876,7 @@ async def update_branch_track(data: dict):
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
if branch not in config.get("branches", []): if branch not in _build_refs(config):
raise HTTPException(status_code=404, detail="分支不存在") raise HTTPException(status_code=404, detail="分支不存在")
track, kind = resolve_track(config, branch, data.get("track")) track, kind = resolve_track(config, branch, data.get("track"))
config.setdefault("branch_track", {})[branch] = track config.setdefault("branch_track", {})[branch] = track
@@ -869,6 +900,51 @@ async def delete_branch(branch_name: str):
return {"message": "分支已删除"} return {"message": "分支已删除"}
# Tag 管理 API:打包时检出指定 Tag 的代码,版本轨道与分支一样按名字各自独立
@router.get("/tags")
async def get_tags_list():
"""获取 Tag 列表"""
return get_tags(load_config())
@router.post("/tags")
async def add_tag(data: dict):
"""新增 Tag,可指定版本轨道类型(缺省为上架轨,App_Store 打包构建号自增)。"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag 名称不能为空")
async with _config_lock:
config = load_config()
tags = get_tags(config)
if name in tags:
raise HTTPException(status_code=400, detail="Tag 已存在")
if name in config.get("branches", []):
raise HTTPException(status_code=400, detail="已存在同名分支")
track, kind = resolve_track(config, name, data.get("track"), default_kind=KIND_RELEASE)
tags.append(name)
config["tags"] = tags
config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track, kind)
save_config(config)
return {"message": "Tag 已添加", "track": track}
@router.delete("/tags/{tag_name:path}")
async def delete_tag(tag_name: str):
"""删除 Tag"""
async with _config_lock:
config = load_config()
tags = get_tags(config)
if tag_name not in tags:
raise HTTPException(status_code=404, detail="Tag 不存在")
tags.remove(tag_name)
config["tags"] = tags
config.get("branch_track", {}).pop(tag_name, None)
save_config(config)
return {"message": "Tag 已删除"}
@router.get("/build") @router.get("/build")
async def get_build_settings(db: Session = Depends(get_db)): async def get_build_settings(db: Session = Depends(get_db)):
"""获取打包设置""" """获取打包设置"""
@@ -941,7 +1017,7 @@ async def get_versions():
"primary_track": primary, "primary_track": primary,
"branch_track": { "branch_track": {
branch: get_track_name(config, branch) branch: get_track_name(config, branch)
for branch in config.get("branches", []) or [] for branch in _build_refs(config)
}, },
} }
+6 -1
View File
@@ -55,6 +55,10 @@ async def create_task(task: TaskCreate, request: Request, user: dict = Depends(g
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}", detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
) )
# 分支与 Tag 不允许重名,按名字即可确定要检出的引用类型
from .config import get_ref_type
ref_type = get_ref_type(config, task.branch)
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
db_task = Task( db_task = Task(
id=task_id, id=task_id,
@@ -65,12 +69,13 @@ async def create_task(task: TaskCreate, request: Request, user: dict = Depends(g
scheme_name=scheme.get("displayName") or scheme_name, scheme_name=scheme.get("displayName") or scheme_name,
obfuscation=task.obfuscation, obfuscation=task.obfuscation,
branch=task.branch, branch=task.branch,
ref_type=ref_type,
status="pending", status="pending",
) )
db.add(db_task) db.add(db_task)
from ..services.audit_log import record_operation from ..services.audit_log import record_operation
record_operation(db, user, "build_created", request=request, task=db_task, record_operation(db, user, "build_created", request=request, task=db_task,
detail={"branch": task.branch, "build_type": task.build_type}) detail={"branch": task.branch, "ref_type": ref_type, "build_type": task.build_type})
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
+1
View File
@@ -22,6 +22,7 @@ class TaskResponse(BaseModel):
scheme_name: str scheme_name: str
obfuscation: bool obfuscation: bool
branch: str branch: str
ref_type: Optional[str] = "branch"
status: str status: str
current_step: Optional[str] current_step: Optional[str]
created_at: Optional[datetime] created_at: Optional[datetime]
+24 -11
View File
@@ -291,8 +291,8 @@ def _db_update(db, task, **fields):
db.commit() db.commit()
async def update_source(task_id: str, source_dir: Path, branch: str): async def update_source(task_id: str, source_dir: Path, branch: str, ref_type: str = "branch"):
"""将共享源码工作目录强制同步到指定远程分支。""" """将共享源码工作目录强制同步到指定远程分支或 Tagref_type="tag" 时 branch 为 Tag 名)"""
remote_url = get_git_remote_url(with_credentials=True) remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url) remote_url_masked = mask_git_remote_url(remote_url)
git_env = _git_env() git_env = _git_env()
@@ -321,7 +321,8 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if not cloned and not (source_dir / ".git").exists(): if not cloned and not (source_dir / ".git").exists():
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}") raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}") is_tag = ref_type == "tag"
await log_streamer.emit(task_id, f"同步共享源码到{'Tag' if is_tag else '分支'}: {branch}")
if remote_url: if remote_url:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@@ -333,12 +334,22 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
if process.returncode != 0: if process.returncode != 0:
raise Exception("git remote set-url 失败") raise Exception("git remote set-url 失败")
commands = [ if is_tag:
(("git", "fetch", "origin"), "git fetch 失败"), # Tag 检出为游离 HEAD--force 让远端被移动过的 Tag 覆盖本地旧 Tag
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"), tag_ref = f"refs/tags/{branch}"
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"), commands = [
(("git", "clean", "-ffdx"), "git clean 失败"), (("git", "fetch", "origin", "--tags", "--force"), "git fetch 失败"),
] (("git", "checkout", "--detach", tag_ref), f"git checkout Tag {branch} 失败"),
(("git", "reset", "--hard", tag_ref), f"git reset Tag {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
else:
commands = [
(("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
for command, error_message in commands: for command, error_message in commands:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*_GIT_NO_CREDENTIAL_HELPER, *command[1:], env=git_env, *_GIT_NO_CREDENTIAL_HELPER, *command[1:], env=git_env,
@@ -375,7 +386,7 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
source_dir = get_source_dir(task.branch) source_dir = get_source_dir(task.branch)
if not source_dir.exists(): if not source_dir.exists():
raise Exception( raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL" f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支(或 Tag代码,或配置 GIT_REMOTE_URL"
) )
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}") await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
return await copy_source_code(task_id, task, source_dir), "" return await copy_source_code(task_id, task, source_dir), ""
@@ -384,7 +395,8 @@ async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
await log_streamer.emit(task_id, "等待共享源码准备队列...") await log_streamer.emit(task_id, "等待共享源码准备队列...")
async with source_prepare_lock: async with source_prepare_lock:
await log_streamer.emit(task_id, "开始准备共享源码快照") await log_streamer.emit(task_id, "开始准备共享源码快照")
await update_source(task_id, source_dir, task.branch) ref_type = "tag" if task.ref_type == "tag" else "branch"
await update_source(task_id, source_dir, task.branch, ref_type)
commit = await get_source_commit(source_dir) commit = await get_source_commit(source_dir)
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}") await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
build_dir = await copy_source_code(task_id, task, source_dir) build_dir = await copy_source_code(task_id, task, source_dir)
@@ -433,6 +445,7 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, current_step="config") await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir) config_data = await generate_config(task_id, task, build_dir)
config_data["SOURCE_BRANCH"] = task.branch config_data["SOURCE_BRANCH"] = task.branch
config_data["SOURCE_REF_TYPE"] = "tag" if task.ref_type == "tag" else "branch"
config_data["SOURCE_COMMIT"] = source_commit config_data["SOURCE_COMMIT"] = source_commit
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False)) config_json=json.dumps(config_data, ensure_ascii=False))
+1 -1
View File
@@ -174,7 +174,7 @@ def _write_download_page(config: dict, html: Path, manifest_url: str):
meta_items = [ meta_items = [
("构建版本", config.get("BUILD_VERSION", "")), ("构建版本", config.get("BUILD_VERSION", "")),
("Bundle ID", config.get("BUNDLE_ID", "")), ("Bundle ID", config.get("BUNDLE_ID", "")),
("分支", config.get("SOURCE_BRANCH", "")), ("Tag" if config.get("SOURCE_REF_TYPE") == "tag" else "分支", config.get("SOURCE_BRANCH", "")),
] ]
meta = "".join( meta = "".join(
f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>" f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
+40
View File
@@ -73,6 +73,46 @@ describe('BuildView.vue', () => {
expect(branchSelect).toBeTruthy() expect(branchSelect).toBeTruthy()
}) })
it('分支和 Tag 分组显示,可选择 Tag 提交', async () => {
fetch.mockImplementation((url, opts) => {
if (url === '/api/tasks' && opts?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'tag-task', status: 'pending' }) })
}
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tags': ['v3.2.0'],
'/api/tasks': [],
}
return Promise.resolve({ json: () => Promise.resolve(responses[url] || {}) })
})
const wrapper = mountBuildView()
await flushPromises()
const groups = wrapper.findAll('optgroup')
expect(groups.map(g => g.attributes('label'))).toEqual(['分支', 'Tag'])
expect(groups[1].findAll('option').map(o => o.text())).toEqual(['v3.2.0'])
wrapper.vm.selectedServer = '测试环境'
await wrapper.vm.$nextTick()
wrapper.vm.form.app_id = '1'
wrapper.vm.form.branch = 'v3.2.0'
await wrapper.vm.$nextTick()
await wrapper.find('.btn-primary').trigger('click')
await flushPromises()
const post = fetch.mock.calls.find(([url, opts]) => url === '/api/tasks' && opts?.method === 'POST')
expect(JSON.parse(post[1].body).branch).toBe('v3.2.0')
})
it('没有 Tag 时不显示 Tag 分组', async () => {
const wrapper = mountBuildView()
await flushPromises()
expect(wrapper.findAll('optgroup').map(g => g.attributes('label'))).toEqual(['分支'])
})
it('默认值正确', async () => { it('默认值正确', async () => {
const wrapper = mountBuildView() const wrapper = mountBuildView()
await flushPromises() await flushPromises()
+40 -1
View File
@@ -23,6 +23,7 @@ function mockFetch(url) {
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } }, '/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'], '/api/config/branches': ['main', 'dev'],
'/api/config/tags': ['v3.2.0'],
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' }, '/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
'/api/config/versions': { '/api/config/versions': {
app_ver: '2.195.0', app_ver: '2.195.0',
@@ -31,8 +32,9 @@ function mockFetch(url) {
tracks: { tracks: {
release: { kind: 'release', app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} }, release: { kind: 'release', app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} },
dev: { kind: 'feature', app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} }, dev: { kind: 'feature', app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
'v3.2.0': { kind: 'release', app_ver: '3.2.0', build_ver: '3.2.0.0', build_map: {} },
}, },
branch_track: { main: 'release', dev: 'dev' }, branch_track: { main: 'release', dev: 'dev', 'v3.2.0': 'v3.2.0' },
}, },
'/api/config': { apps: {}, schemes: {}, branches: ['main'] }, '/api/config': { apps: {}, schemes: {}, branches: ['main'] },
} }
@@ -253,4 +255,41 @@ describe('ConfigView.vue', () => {
expect(calls).toEqual([{ branch: 'main', track: 'feature' }]) expect(calls).toEqual([{ branch: 'main', track: 'feature' }])
}) })
it('分支管理可添加和删除 Tag', async () => {
window.confirm = vi.fn(() => true)
const calls = []
fetch.mockImplementation((url, opts) => {
if (opts?.method === 'POST' || opts?.method === 'DELETE') {
calls.push([opts.method, url, opts.body && JSON.parse(opts.body)])
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
}
return mockFetch(url)
})
const wrapper = mountConfigView()
await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
await branchesMenu.trigger('click')
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('.config-table tbody tr')
expect(rows.map(r => r.findAll('td').slice(0, 2).map(td => td.text()))).toEqual([
['main', '分支'], ['dev', '分支'], ['v3.2.0', 'Tag'],
])
await wrapper.find('select').setValue('tag')
await wrapper.find('input[placeholder="输入 Tag 名称"]').setValue('v3.3.0')
await wrapper.findAll('.btn-primary').find(b => b.text() === '添加 Tag').trigger('click')
await flushPromises()
await rows[2].find('.action-btn.delete').trigger('click')
await flushPromises()
expect(calls).toEqual([
['POST', '/api/config/tags', { name: 'v3.3.0' }],
['DELETE', '/api/config/tags/v3.2.0', undefined],
])
})
}) })
+13 -4
View File
@@ -41,9 +41,14 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>代码分支</label> <label>代码分支 / Tag</label>
<select v-model="form.branch"> <select v-model="form.branch">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option> <optgroup label="分支">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
</optgroup>
<optgroup v-if="tags.length" label="Tag">
<option v-for="t in tags" :key="`tag:${t}`" :value="t">{{ t }}</option>
</optgroup>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -167,6 +172,7 @@ const authFetch = (url, options = {}) => {
const apps = ref({}) const apps = ref({})
const schemes = ref({}) const schemes = ref({})
const branches = ref(['main']) const branches = ref(['main'])
const tags = ref([])
const tasks = ref([]) const tasks = ref([])
const selectedServer = ref('') const selectedServer = ref('')
const serverEnvironments = computed(() => { const serverEnvironments = computed(() => {
@@ -314,15 +320,18 @@ onUnmounted(() => {
}) })
onMounted(async () => { onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([ const [appsRes, schemesRes, branchesRes, tagsRes, tasksRes] = await Promise.all([
authFetch('/api/apps'), authFetch('/api/apps'),
authFetch('/api/schemes'), authFetch('/api/schemes'),
authFetch('/api/branches'), authFetch('/api/branches'),
authFetch('/api/tags'),
authFetch('/api/tasks'), authFetch('/api/tasks'),
]) ])
apps.value = await appsRes.json() apps.value = await appsRes.json()
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
branches.value = await branchesRes.json() branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
const allTasks = await tasksRes.json() const allTasks = await tasksRes.json()
// 从历史页跳转过来时,读取 taskId 查询参数 // 从历史页跳转过来时,读取 taskId 查询参数
@@ -436,7 +445,7 @@ const errorCategoryHint = (cat) => {
provisioning: '描述文件问题:描述文件过期、不匹配或未安装', provisioning: '描述文件问题:描述文件过期、不匹配或未安装',
compilation: '代码编译错误:请检查代码语法和类型引用', compilation: '代码编译错误:请检查代码语法和类型引用',
dependency: '依赖问题:Pod 依赖缺失或版本不兼容', dependency: '依赖问题:Pod 依赖缺失或版本不兼容',
git: '代码拉取失败:请检查分支是否存在', git: '代码拉取失败:请检查分支或 Tag 是否存在',
config: '配置替换失败:请检查打包配置', config: '配置替换失败:请检查打包配置',
build: '构建配置错误:请检查 Scheme 和 Workspace 配置', build: '构建配置错误:请检查 Scheme 和 Workspace 配置',
unknown: '打包失败:请查看详细日志排查原因', unknown: '打包失败:请查看详细日志排查原因',
+39 -20
View File
@@ -152,36 +152,42 @@
<div v-if="tab === 'branches'"> <div v-if="tab === 'branches'">
<h2>分支管理</h2> <h2>分支管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;"> <p style="color: #666; margin-bottom: 16px; font-size: 14px;">
管理可打包的代码分支打包时从对应分支目录获取源码 管理可打包的代码分支 Tag打包时从对应分支 Tag 获取源码
版本轨道决定该分支使用哪一套版本号上架master/develop 共用App_Store 打包构建号自增自测feature 构建号不自增 每个分支 / Tag 各有一套独立的版本号版本轨道决定构建号行为上架App_Store 打包构建号自增自测构建号不自增新增 Tag 默认为上架
</p> </p>
<div style="display: flex; gap: 12px; margin-bottom: 16px;"> <div style="display: flex; gap: 12px; margin-bottom: 16px;">
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;"> <select v-model="newRefType" style="width: auto; padding: 10px 12px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button> <option value="branch">分支</option>
<option value="tag">Tag</option>
</select>
<input v-model="newBranch" type="text" :placeholder="newRefType === 'tag' ? '输入 Tag 名称' : '输入分支名称'" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">{{ newRefType === 'tag' ? '添加 Tag' : '添加分支' }}</button>
</div> </div>
<table class="config-table"> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>分支名称</th> <th>名称</th>
<th>类型</th>
<th>版本轨道</th> <th>版本轨道</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="b in branches" :key="b"> <tr v-for="r in buildRefs" :key="`${r.type}:${r.name}`">
<td><strong>{{ b }}</strong></td> <td><strong>{{ r.name }}</strong></td>
<td>{{ r.type === 'tag' ? 'Tag' : '分支' }}</td>
<td> <td>
<select :value="branchKind(b)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)"> <select :value="branchKind(r.name)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(r.name, $event.target.value)">
<option value="release">上架App_Store 打包构建号自增</option> <option value="release">上架App_Store 打包构建号自增</option>
<option value="feature">自测构建号不自增</option> <option value="feature">自测构建号不自增</option>
</select> </select>
</td> </td>
<td class="action-btns"> <td class="action-btns">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button> <button class="action-btn delete" @click="deleteBranch(r)">删除</button>
</td> </td>
</tr> </tr>
<tr v-if="!branches.length"> <tr v-if="!buildRefs.length">
<td colspan="3" style="text-align: center; color: #999;">暂无分支配置</td> <td colspan="4" style="text-align: center; color: #999;">暂无分支配置</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -297,7 +303,7 @@
<label>App_Ver应用版本</label> <label>App_Ver应用版本</label>
<input type="text" v-model="versions[t.name].app_ver" placeholder="2.180.0" @input="onAppVerInput(t.name)"> <input type="text" v-model="versions[t.name].app_ver" placeholder="2.180.0" @input="onAppVerInput(t.name)">
<div style="font-size: 12px; color: #999; margin-top: 4px;"> <div style="font-size: 12px; color: #999; margin-top: 4px;">
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }} 分支轨道 {{ t.name }}与其它分支互不影响 格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }}轨道 {{ t.name }}与其它分支 / Tag 互不影响
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -647,7 +653,14 @@ const apps = ref({})
const schemes = ref({}) const schemes = ref({})
const servers = ref({}) const servers = ref({})
const branches = ref([]) const branches = ref([])
const tags = ref([])
const newBranch = ref('') const newBranch = ref('')
const newRefType = ref('branch') // 新增的是分支还是 Tag
// 分支管理表格:分支在前,Tag 在后
const buildRefs = computed(() => [
...branches.value.map(name => ({ name, type: 'branch' })),
...tags.value.map(name => ({ name, type: 'tag' })),
])
const buildSettings = ref({}) const buildSettings = ref({})
// 版本轨道:每个分支一条独立轨道,轨道名即分支名(master/main 沿用历史名 release)。 // 版本轨道:每个分支一条独立轨道,轨道名即分支名(master/main 沿用历史名 release)。
// 轨道类型 kind 决定构建号行为:release=上架、App_Store 打包自增;feature=自测、不自增。 // 轨道类型 kind 决定构建号行为:release=上架、App_Store 打包自增;feature=自测、不自增。
@@ -678,13 +691,14 @@ const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'fe
// 打包设置里按分支分块展示版本号;多个分支共用一条轨道时只显示一块 // 打包设置里按分支分块展示版本号;多个分支共用一条轨道时只显示一块
const branchTracks = computed(() => { const branchTracks = computed(() => {
const seen = new Set() const seen = new Set()
return branches.value const refs = [...branches.value, ...tags.value]
return refs
.map(b => branchTrack.value[b]) .map(b => branchTrack.value[b])
.filter(name => name && versions.value[name] && !seen.has(name) && seen.add(name)) .filter(name => name && versions.value[name] && !seen.has(name) && seen.add(name))
.map(name => ({ .map(name => ({
name, name,
kind: trackKind.value[name] || 'release', kind: trackKind.value[name] || 'release',
branches: branches.value.filter(b => branchTrack.value[b] === name), branches: refs.filter(b => branchTrack.value[b] === name),
})) }))
}) })
@@ -747,11 +761,12 @@ const loadData = async () => {
return return
} }
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([ const [appsRes, schemesRes, serversRes, branchesRes, tagsRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'), authFetch('/api/config/apps'),
authFetch('/api/config/schemes'), authFetch('/api/config/schemes'),
authFetch('/api/config/servers'), authFetch('/api/config/servers'),
authFetch('/api/config/branches'), authFetch('/api/config/branches'),
authFetch('/api/config/tags'),
authFetch('/api/config/build'), authFetch('/api/config/build'),
authFetch('/api/config/upload'), authFetch('/api/config/upload'),
authFetch('/api/config'), authFetch('/api/config'),
@@ -762,6 +777,8 @@ const loadData = async () => {
schemes.value = await schemesRes.json() schemes.value = await schemesRes.json()
servers.value = await serversRes.json() servers.value = await serversRes.json()
branches.value = await branchesRes.json() branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
buildSettings.value = await buildRes.json() buildSettings.value = await buildRes.json()
uploadConfig.value = await uploadRes.json() uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2) jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
@@ -1178,12 +1195,12 @@ const deleteScheme = async (id) => {
await loadData() await loadData()
} }
// 分支管理 // 分支管理(分支与 Tag 共用一张表)
const addBranch = async () => { const addBranch = async () => {
const name = newBranch.value.trim() const name = newBranch.value.trim()
if (!name) return if (!name) return
try { try {
const res = await authFetch('/api/config/branches', { const res = await authFetch(newRefType.value === 'tag' ? '/api/config/tags' : '/api/config/branches', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
@@ -1218,10 +1235,12 @@ const setBranchTrack = async (name, track) => {
} }
} }
const deleteBranch = async (name) => { const deleteBranch = async ({ name, type }) => {
if (!confirm(`确定删除分支「${name}」?`)) return const isTag = type === 'tag'
if (!confirm(`确定删除${isTag ? ' Tag ' : '分支'}${name}」?`)) return
try { try {
const res = await authFetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' }) const base = isTag ? '/api/config/tags' : '/api/config/branches'
const res = await authFetch(`${base}/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败') if (!res.ok) throw new Error('删除失败')
await loadData() await loadData()
} catch (e) { } catch (e) {
+1 -1
View File
@@ -110,7 +110,7 @@
<span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span> <span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<span class="meta-label">分支</span> <span class="meta-label">{{ logTask?.ref_type === 'tag' ? 'Tag' : '分支' }}</span>
<span class="meta-value">{{ logTask?.branch || '-' }}</span> <span class="meta-value">{{ logTask?.branch || '-' }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
+34
View File
@@ -345,3 +345,37 @@ def test_delete_branch_with_slash_in_name(client, tmp_config):
resp = client.delete("/api/config/branches/feature%2Flogin") resp = client.delete("/api/config/branches/feature%2Flogin")
assert resp.status_code == 200 assert resp.status_code == 200
assert "feature/login" not in client.get("/api/config/branches").json() assert "feature/login" not in client.get("/api/config/branches").json()
def test_tags_crud_and_default_release_track(client, tmp_config):
"""Tag 与分支分开存放,默认建一条同名上架轨,可切换类型、可删除"""
assert client.get("/api/config/tags").json() == []
resp = client.post("/api/config/tags", json={"name": "v3.2.0"})
assert resp.status_code == 200
assert resp.json()["track"] == "v3.2.0"
assert client.get("/api/config/tags").json() == ["v3.2.0"]
assert client.get("/api/tags").json() == ["v3.2.0"]
assert "v3.2.0" not in client.get("/api/config/branches").json()
data = client.get("/api/config/versions").json()
assert data["branch_track"]["v3.2.0"] == "v3.2.0"
assert data["tracks"]["v3.2.0"]["kind"] == "release"
# 主上架轨仍按分支判断,Tag 不会抢占
assert data["primary_track"] == "release"
assert client.put("/api/config/branches/track", json={"branch": "v3.2.0", "track": "feature"}).status_code == 200
assert client.get("/api/config/versions").json()["tracks"]["v3.2.0"]["kind"] == "feature"
assert client.delete("/api/config/tags/v3.2.0").status_code == 200
assert client.get("/api/config/tags").json() == []
assert "v3.2.0" not in client.get("/api/config/versions").json()["branch_track"]
assert client.delete("/api/config/tags/v3.2.0").status_code == 404
def test_tag_and_branch_names_must_not_collide(client, tmp_config):
assert client.post("/api/config/tags", json={"name": ""}).status_code == 400
assert client.post("/api/config/tags", json={"name": "main"}).status_code == 400
assert client.post("/api/config/tags", json={"name": "release/v1"}).status_code == 200
assert client.post("/api/config/tags", json={"name": "release/v1"}).status_code == 400
assert client.post("/api/config/branches", json={"name": "release/v1"}).status_code == 400
assert client.delete("/api/config/tags/release%2Fv1").status_code == 200
+17
View File
@@ -38,6 +38,23 @@ def test_create_task_default_branch(mock_queue, client, tmp_config):
}) })
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["branch"] == "main" assert resp.json()["branch"] == "main"
assert resp.json()["ref_type"] == "branch"
@patch("backend.services.build_queue.build_queue")
def test_create_task_from_tag(mock_queue, client, tmp_config):
"""选择已配置的 Tag 打包时,任务记录为 tag 类型"""
mock_queue.submit = AsyncMock()
assert client.post("/api/config/tags", json={"name": "v3.2.0"}).status_code == 200
resp = client.post("/api/tasks", json={
"app_id": "1",
"build_type": "Ad_Hoc",
"scheme_id": "1",
"branch": "v3.2.0",
})
assert resp.status_code == 200
assert resp.json()["branch"] == "v3.2.0"
assert resp.json()["ref_type"] == "tag"
@patch("backend.services.build_queue.build_queue") @patch("backend.services.build_queue.build_queue")
+35 -1
View File
@@ -112,6 +112,25 @@ async def test_update_source_existing(tmp_path, log_streamer):
assert call_count == 4 # fetch, checkout, reset, clean assert call_count == 4 # fetch, checkout, reset, clean
async def test_update_source_tag_checks_out_detached(tmp_path, log_streamer):
"""Tag 打包:拉取 Tag 后以游离 HEAD 检出 refs/tags/<tag>,不依赖同名远程分支"""
source_dir = tmp_path / "workspace"
source_dir.mkdir()
(source_dir / ".git").mkdir()
with patch("backend.services.build_service.get_git_remote_url", return_value=""):
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
await update_source("t1", source_dir, "v3.2.0", "tag")
git_args = [call.args[3:] for call in mock_exec.call_args_list]
assert git_args == [
("fetch", "origin", "--tags", "--force"),
("checkout", "--detach", "refs/tags/v3.2.0"),
("reset", "--hard", "refs/tags/v3.2.0"),
("clean", "-ffdx"),
]
async def test_update_source_fetch_failure(tmp_path, log_streamer): async def test_update_source_fetch_failure(tmp_path, log_streamer):
"""fetch 失败时抛异常""" """fetch 失败时抛异常"""
source_dir = tmp_path / "branches" / "dev" source_dir = tmp_path / "branches" / "dev"
@@ -136,12 +155,27 @@ async def test_prepare_source_snapshot_serializes_shared_source(tmp_dirs, log_st
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \ with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \ patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \ patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \ patch("backend.services.build_service.update_source", new_callable=AsyncMock) as mock_update, \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"): patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
build_dir, commit = await prepare_source_snapshot("t1", task) build_dir, commit = await prepare_source_snapshot("t1", task)
assert build_dir.exists() assert build_dir.exists()
assert commit == "abc123" assert commit == "abc123"
assert mock_update.call_args.args[2:] == ("main", "branch")
async def test_prepare_source_snapshot_passes_tag_ref_type(tmp_dirs, log_streamer):
source_dir, build_dir_parent = tmp_dirs
task = MagicMock(id="tag-task", branch="v3.2.0", ref_type="tag")
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
patch("backend.services.build_service.update_source", new_callable=AsyncMock) as mock_update, \
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
await prepare_source_snapshot("t1", task)
assert mock_update.call_args.args[2:] == ("v3.2.0", "tag")
# ---- copy_source_code ---- # ---- copy_source_code ----