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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,避免重号被拒。
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"):
|
||||||
"""将共享源码工作目录强制同步到指定远程分支。"""
|
"""将共享源码工作目录强制同步到指定远程分支或 Tag(ref_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))
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: '打包失败:请查看详细日志排查原因',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|||||||
Reference in New Issue
Block a user