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
+83 -7
View File
@@ -108,6 +108,27 @@ MASTER_BRANCH_NAMES = {"master", "main"}
RELEASE_BRANCH_NAMES = MASTER_BRANCH_NAMES | {"develop", "dev"}
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:
"""按分支名推断是否为上架分支。"""
@@ -120,6 +141,13 @@ def _default_kind_for_branch(branch: str) -> str:
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:
"""分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。
@@ -252,7 +280,7 @@ def _ensure_versions(config: dict) -> bool:
branch_track = {}
config["branch_track"] = branch_track
changed = True
for branch in config.get("branches", []) or []:
for branch in _build_refs(config):
name = branch_track.get(branch)
if splitting and name in FIXED_TRACKS:
# 旧的共用轨 → 各分支自己的轨道(master/main 仍留在 release 轨)
@@ -263,7 +291,7 @@ def _ensure_versions(config: dict) -> bool:
branch_track[branch] = name
changed = True
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])
if legacy[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
def resolve_track(config: dict, branch: str, track: str):
def resolve_track(config: dict, branch: str, track: str, default_kind: str = None):
"""把用户传入的轨道值解析为 (轨道名, 轨道类型)。
传类型(release / feature)时用分支自己的轨道,并把它设成该类型;
也可直接传已存在的轨道名,让这个分支与另一个分支共用同一条版本线(类型不变)。
不传时类型取 default_kind,缺省按分支名推断。
"""
value = (track or "").strip()
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:
return _default_track_for_branch(branch), value
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"])
if name in branches:
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"))
branches.append(name)
config["branches"] = branches
@@ -834,7 +865,7 @@ async def add_branch(data: dict):
@router.put("/branches/track")
async def update_branch_track(data: dict):
"""修改分支的版本轨道类型(release=上架,构建号自增;feature=自测,不自增)。
"""修改分支或 Tag 的版本轨道类型(release=上架,构建号自增;feature=自测,不自增)。
每个分支用自己的一条轨道,切换类型只改这条轨道的行为,版本号仍与其它分支互不影响。
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
@@ -845,7 +876,7 @@ async def update_branch_track(data: dict):
async with _config_lock:
config = load_config()
if branch not in config.get("branches", []):
if branch not in _build_refs(config):
raise HTTPException(status_code=404, detail="分支不存在")
track, kind = resolve_track(config, branch, data.get("track"))
config.setdefault("branch_track", {})[branch] = track
@@ -869,6 +900,51 @@ async def delete_branch(branch_name: str):
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")
async def get_build_settings(db: Session = Depends(get_db)):
"""获取打包设置"""
@@ -941,7 +1017,7 @@ async def get_versions():
"primary_track": primary,
"branch_track": {
branch: get_track_name(config, branch)
for branch in config.get("branches", []) or []
for branch in _build_refs(config)
},
}