feat: 版本轨道按分支彻底分开,自测分支不再共用 feature 轨

每个分支各有一条独立轨道(轨道名即分支名,master/main 沿用历史名 release),
轨道类型 kind 只决定构建号行为:release 上架自增、feature 自测不自增。

- branch_track 映射分支 → 轨道名,新增分支自动建同名轨道
- 迁移:旧的共用 release / feature 轨按分支拆开并复制已用构建号,
  拆完没有分支引用的旧共用轨直接删除;无自测分支时保留 feature 轨作模板
- 分支管理页的下拉只改轨道类型(是否自增),不再影响版本号归属
- 打包设置页按分支分块填写版本号

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJaZ913U5GVdTkYHiRpvDU
This commit is contained in:
shenlei
2026-09-02 10:51:35 +09:00
co-authored by Claude Opus 5
parent 9b4667cab5
commit 6867a3cb01
8 changed files with 216 additions and 163 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu
- `backend/config.py` — loads .env, defines all config constants - `backend/config.py` — loads .env, defines all config constants
- `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-track: each release branch has its own track (master/main keeps the historical `release` track, others are named after the branch, e.g. `develop`; build number auto-increments on App_Store), and all self-test branches share the `feature` track (never increments); each branch's track name lives in `config["branch_track"]`, track records in `config["versions"]["tracks"]` carry `kind` (release/feature) - 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)
## Conventions ## Conventions
+21 -10
View File
@@ -187,25 +187,36 @@ 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 等 |
| 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道(上架分支各自独立 | | 分支管理 | 可打包的代码分支列表,以及每个分支的版本轨道类型(上架 / 自测 |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) | | 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 | | 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
### 版本号与版本轨道 ### 版本号与版本轨道
版本号按 **轨道(track** 分开管理,各轨道的 App_Ver 各自独立填写、互不影响 **每个分支各有一条版本轨道(track**,App_Ver 与构建号完全独立、互不影响。轨道名即分支名(master / main 沿用历史名称 `release`),轨道类型 `kind` 决定构建号行为
| 轨道 | 适用分支 | 构建号行为 | | 轨道类型 | 默认适用分支 | 构建号行为 |
|------|----------|-----------| |----------|--------------|-----------|
| `release`(上架) | master / main | App_Store 打包时按当前 App_Ver 自动 +1 | | `release`(上架) | mastermain、develop、dev、release-*、hotfix-* | App_Store 打包时按当前 App_Ver 自动 +1 |
| `develop` 等以分支名命名的上架轨 | develop、release-*、hotfix-* 等其它上架分支,每个分支一条 | 同上,与 `release` 轨互不影响 | | `feature`(自测) | 其余分支 | 永不自增,始终使用配置页填写的值(默认 `.0` |
| `feature`(自测) | feature/* 等其他分支,共用一条 | 永不自增,始终使用配置页填写的值(默认 `.0` |
- 分支归属默认按名字推断(master/main → `release` 轨,develop/dev/release-*/hotfix-* → 以分支名命名的轨,其余 → `feature`),可在「分支管理」页逐个改;改成「上架」即为该分支建一条独立轨道。 ```jsonc
- 「打包设置」页按上架分支分块填写版本号,最后一块是共用的自测版本号。 "versions": {
"tracks": {
"release": { "kind": "release", "app_ver": "2.195.0", "build_map": { "2.195.0": 7 } }, // master
"develop": { "kind": "release", "app_ver": "2.196.0", "build_map": {} },
"feature/login": { "kind": "feature", "app_ver": "2.100.0", "build_map": {} }
}
},
"branch_track": { "master": "release", "develop": "develop", "feature/login": "feature/login" }
```
- 新增分支时自动建一条同名轨道,类型按分支名推断,可在「分支管理」页逐个改(只改构建号是否自增,版本号仍各自独立)。
- 「打包设置」页按分支分块填写版本号。
- 每条轨道内的构建号按 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,避免重号被拒。
- 旧配置(master/develop 共用的 `release` 轨)首次启动时自动拆开:master 留在 `release` 轨,develop 新建 `develop`并复制一份已用构建号。 - 旧配置(master/develop 共用的 `release` 轨、各自测分支共用的 `feature` 轨)首次启动时自动拆开:master 留在 `release` 轨,其余分支新建同名轨道并复制一份已用构建号。
- 需要两个分支共用同一条版本线时,可直接把 `branch_track` 里的值指向另一条轨道名。
### 服务端自动化资源 ### 服务端自动化资源
+114 -82
View File
@@ -93,16 +93,17 @@ DEFAULT_SERVERS = {
DEFAULT_APP_VER = "2.180.0" DEFAULT_APP_VER = "2.180.0"
# 版本轨道:每个上架分支各有一条独立轨道,App_Store 打包时构建号自增; # 版本轨道:每个分支各有一条独立轨道,轨道名即分支名(master/main 沿用历史名称 release),
# 所有自测分支共用一条 feature 轨,构建号永不自增。各轨道的 App_Ver 互不影响。 # 各分支的 App_Ver 与构建号互不影响。轨道类型(kind)决定构建号行为:
# 轨道名:master/main 沿用历史名称 release,其余上架分支以分支名命名(develop、hotfix-x…) # release(上架)— App_Store 打包时自增;feature(自测)— 永不自增
KIND_RELEASE = "release" KIND_RELEASE = "release"
KIND_FEATURE = "feature" KIND_FEATURE = "feature"
TRACK_RELEASE = "release" # master/main 的上架轨(沿用旧名,历史构建号原地保留) KINDS = (KIND_RELEASE, KIND_FEATURE)
TRACK_FEATURE = "feature" # 自测轨的固定轨道名 TRACK_RELEASE = "release" # master/main 的轨道名(沿用旧名,历史构建号原地保留)
TRACK_FEATURE = "feature" # 旧版所有自测分支共用的轨道名,迁移时按分支拆开
FIXED_TRACKS = (TRACK_RELEASE, TRACK_FEATURE) FIXED_TRACKS = (TRACK_RELEASE, TRACK_FEATURE)
# 按名字推断轨道的默认规则;config["branch_track"] 中的显式配置优先 # 按名字推断轨道类型的默认规则;轨道记录里的 kind 为准,此处只用于新建轨道
MASTER_BRANCH_NAMES = {"master", "main"} 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-")
@@ -114,16 +115,19 @@ def _is_release_branch(branch: str) -> bool:
return name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES) return name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES)
def _release_track_name(branch: str) -> str: def _default_kind_for_branch(branch: str) -> str:
"""上架分支的轨道名:master/main 为 release,其余即分支名(与固定轨重名时避让)""" """新建轨道时按分支名推断轨道类型"""
if (branch or "").strip().lower() in MASTER_BRANCH_NAMES: return KIND_RELEASE if _is_release_branch(branch) else KIND_FEATURE
return TRACK_RELEASE
return f"release:{branch}" if branch in FIXED_TRACKS else branch
def _default_track_for_branch(branch: str) -> str: def _default_track_for_branch(branch: str) -> str:
"""分支名推断所属版本轨道名。""" """分支自己的轨道名:master/main 沿用历史名 release,其余即分支名。
return _release_track_name(branch) if _is_release_branch(branch) else TRACK_FEATURE
分支恰好叫 release / feature且不是 master时加前缀避让免得撞上固定轨道名
"""
if (branch or "").strip().lower() in MASTER_BRANCH_NAMES:
return TRACK_RELEASE
return f"branch:{branch}" if branch in FIXED_TRACKS else branch
def _new_track(app_ver: str = DEFAULT_APP_VER, kind: str = KIND_RELEASE) -> dict: def _new_track(app_ver: str = DEFAULT_APP_VER, kind: str = KIND_RELEASE) -> dict:
@@ -133,28 +137,40 @@ def _new_track(app_ver: str = DEFAULT_APP_VER, kind: str = KIND_RELEASE) -> dict
return {"kind": kind, "app_ver": app_ver, "build_map": {}} return {"kind": kind, "app_ver": app_ver, "build_map": {}}
def _seed_release_track(tracks: dict, legacy: dict = None) -> dict: def _seed_track(tracks: dict, kind: str, legacy: dict = None) -> dict:
"""新建上架轨的初值:优先沿用旧共用轨(含已用构建号),否则沿用任一上架轨的 App_Ver。""" """新建轨的初值:优先沿用被拆分的旧共用轨(含已用构建号),
否则沿用任一同类型轨道的 App_Ver都没有则用默认版本号"""
if legacy: if legacy:
return {"kind": KIND_RELEASE, "app_ver": legacy["app_ver"], "build_map": dict(legacy["build_map"])} return {"kind": kind, "app_ver": legacy["app_ver"], "build_map": dict(legacy["build_map"])}
for track in tracks.values(): for track in tracks.values():
if track.get("kind") == KIND_RELEASE: if isinstance(track, dict) and track.get("kind") == kind:
return _new_track(track.get("app_ver") or DEFAULT_APP_VER) return _new_track(track.get("app_ver") or DEFAULT_APP_VER, kind)
return _new_track() return _new_track(kind=kind)
def _ensure_track(config: dict, track_name: str) -> dict: def _ensure_track(config: dict, track_name: str, kind: str = None) -> dict:
"""确保轨道存在新建的上架轨沿用已有上架轨的 App_Ver,构建号从 .0 起""" """确保轨道存在新建时沿用同类型轨道的 App_Ver,构建号从 .0 起);
显式传 kind 时同时把轨道类型改成它"""
tracks = config.setdefault("versions", {}).setdefault("tracks", {}) tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track = tracks.get(track_name) track = tracks.get(track_name)
if not isinstance(track, dict): if not isinstance(track, dict):
track = tracks[track_name] = ( track = tracks[track_name] = _seed_track(
_new_track(kind=KIND_FEATURE) if track_name == TRACK_FEATURE tracks, kind or _default_kind_for_track(track_name)
else _seed_release_track(tracks)
) )
elif kind:
track["kind"] = kind
return track return track
def _default_kind_for_track(track_name: str) -> str:
"""轨道记录缺 kind 时按轨道名推断(轨道名即分支名)。"""
if track_name == TRACK_RELEASE:
return KIND_RELEASE
if track_name == TRACK_FEATURE:
return KIND_FEATURE
return _default_kind_for_branch(track_name)
def _legacy_single_track(versions: dict) -> dict: def _legacy_single_track(versions: dict) -> dict:
"""把最旧的单轨结构读成 {app_ver, build_map}。""" """把最旧的单轨结构读成 {app_ver, build_map}。"""
app_ver = versions.get("app_ver") or DEFAULT_APP_VER app_ver = versions.get("app_ver") or DEFAULT_APP_VER
@@ -173,13 +189,25 @@ def _legacy_single_track(versions: dict) -> dict:
return {"app_ver": app_ver, "build_map": build_map} return {"app_ver": app_ver, "build_map": build_map}
def _ensure_versions(config: dict) -> bool: def _legacy_shared_track(tracks: dict, name: str) -> dict:
"""确保 versions 为「每个上架分支一条轨 + 一条 feature 轨」结构,并迁移旧配置。 """取出待拆分的旧共用轨(迁移后的轨道都带 kind,没有 kind 的即旧共用轨)。"""
track = tracks.get(name)
if not isinstance(track, dict) or "kind" in track:
return None
build_map = track.get("build_map")
return {
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_map": build_map if isinstance(build_map, dict) else {},
}
旧式单轨 ``{"app_ver": ..., "build_map": ...}`` 和旧的 master/develop 共用
``release`` 都会按上架分支拆开master/main 留在 release 历史构建号原地 def _ensure_versions(config: dict) -> bool:
保留develop 等其它上架分支新建以分支名命名的轨道并复制一份已用构建号 """确保 versions 为「每个分支一条轨道」结构,并迁移旧配置。
使拆分后每个分支都从原来的构建号继续递增
旧式单轨 ``{"app_ver": ..., "build_map": ...}``旧的 master/develop 共用
``release`` 轨和所有自测分支共用的 ``feature`` 都会按分支拆开
master/main 留在 release 历史构建号原地保留其余分支新建以分支名命名的
轨道并复制一份已用构建号使拆分后每个分支都从原来的构建号继续
""" """
versions = config.get("versions") versions = config.get("versions")
changed = False changed = False
@@ -188,36 +216,28 @@ def _ensure_versions(config: dict) -> bool:
config["versions"] = versions config["versions"] = versions
changed = True changed = True
legacy = None # 需要拆分到各上架分支的旧共用轨 # 拆分到各分支的旧共用轨(按类型各一条)
legacy = {KIND_RELEASE: None, KIND_FEATURE: None}
tracks = versions.get("tracks") tracks = versions.get("tracks")
if not isinstance(tracks, dict): if not isinstance(tracks, dict):
legacy = _legacy_single_track(versions) legacy[KIND_RELEASE] = _legacy_single_track(versions)
legacy[KIND_FEATURE] = dict(legacy[KIND_RELEASE], build_map={})
tracks = {} tracks = {}
versions["tracks"] = tracks versions["tracks"] = tracks
changed = True changed = True
else: else:
# 迁移后的轨道都带 kind;没有 kind 的 release 轨即旧的 master/develop 共用轨, # release 轨拆分后继续作为 master/main 的轨道,feature 轨拆分后成为无人引用的模板
# 它继续作为 master/main 的轨道,同时作为其它上架分支新轨的初值。 legacy[KIND_RELEASE] = _legacy_shared_track(tracks, TRACK_RELEASE)
shared = tracks.get(TRACK_RELEASE) legacy[KIND_FEATURE] = _legacy_shared_track(tracks, TRACK_FEATURE)
if isinstance(shared, dict) and "kind" not in shared: splitting = any(legacy.values())
build_map = shared.get("build_map") split_kinds = set() # 实际被拆分出去的旧共用轨类型
legacy = {
"app_ver": shared.get("app_ver") or DEFAULT_APP_VER,
"build_map": build_map if isinstance(build_map, dict) else {},
}
feature = tracks.get(TRACK_FEATURE)
if not isinstance(feature, dict):
tracks[TRACK_FEATURE] = _new_track((legacy or {}).get("app_ver") or DEFAULT_APP_VER, KIND_FEATURE)
changed = True
for name, track in tracks.items(): for name, track in tracks.items():
if not isinstance(track, dict): if not isinstance(track, dict):
track = tracks[name] = _new_track() track = tracks[name] = _new_track(kind=_default_kind_for_track(name))
changed = True changed = True
kind = KIND_FEATURE if name == TRACK_FEATURE else KIND_RELEASE if track.get("kind") not in KINDS:
if track.get("kind") != kind: track["kind"] = _default_kind_for_track(name)
track["kind"] = kind
changed = True changed = True
if not track.get("app_ver"): if not track.get("app_ver"):
track["app_ver"] = DEFAULT_APP_VER track["app_ver"] = DEFAULT_APP_VER
@@ -226,7 +246,7 @@ def _ensure_versions(config: dict) -> bool:
track["build_map"] = {} track["build_map"] = {}
changed = True changed = True
# 分支归属:值为轨道名,缺省分支名规则推断 # 分支归属:值为轨道名,缺省分支自己的轨道
branch_track = config.get("branch_track") branch_track = config.get("branch_track")
if not isinstance(branch_track, dict): if not isinstance(branch_track, dict):
branch_track = {} branch_track = {}
@@ -234,31 +254,44 @@ def _ensure_versions(config: dict) -> bool:
changed = True changed = True
for branch in config.get("branches", []) or []: for branch in config.get("branches", []) or []:
name = branch_track.get(branch) name = branch_track.get(branch)
if name == TRACK_RELEASE and legacy is not None: if splitting and name in FIXED_TRACKS:
# 旧的共用上架轨 → master/main 留在 release 轨,其余分支拆到自己的轨 # 旧的共用轨 → 各分支自己的轨道(master/main 留在 release 轨
name = _release_track_name(branch) name = _default_track_for_branch(branch)
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
name = _default_track_for_branch(branch) name = _default_track_for_branch(branch)
if branch_track.get(branch) != name: if branch_track.get(branch) != name:
branch_track[branch] = name branch_track[branch] = name
changed = True changed = True
if name not in tracks: if name not in tracks:
tracks[name] = _seed_release_track(tracks, legacy) kind = _default_kind_for_branch(branch)
tracks[name] = _seed_track(tracks, kind, legacy[kind])
if legacy[kind]:
split_kinds.add(kind)
changed = True
# 内容已复制到各分支轨道、又没有分支引用的旧共用轨,直接删掉,免得配置里留下无用条目
for name in FIXED_TRACKS:
kind = _default_kind_for_track(name)
if kind in split_kinds and name in tracks and name not in branch_track.values():
tracks.pop(name)
changed = True changed = True
return changed return changed
def _primary_track_name(config: dict) -> str: def _primary_track_name(config: dict) -> str:
"""主上架轨:第一个上架分支所属的轨道;没有上架分支时退回 feature 轨 """主上架轨:第一个上架分支kind=release所属的轨道;没有则取第一个分支的轨道
仅用于顶层 app_ver/build_ver 的兼容字段和未指定轨道时的缺省值 仅用于顶层 app_ver/build_ver 的兼容字段和未指定轨道时的缺省值
""" """
for branch in config.get("branches", []) or []: fallback = TRACK_RELEASE
for index, branch in enumerate(config.get("branches", []) or []):
name = get_track_name(config, branch) name = get_track_name(config, branch)
if name != TRACK_FEATURE: if index == 0:
fallback = name
if is_release_track(get_track(config, name)):
return name return name
return TRACK_FEATURE return fallback
def get_track_name(config: dict, branch: str) -> str: def get_track_name(config: dict, branch: str) -> str:
@@ -273,14 +306,14 @@ def get_track_name(config: dict, branch: str) -> str:
def get_track(config: dict, track_name: str) -> dict: def get_track(config: dict, track_name: str) -> dict:
"""取某条轨道的版本记录副本(不存在时返回默认值;只读,不写回配置)。 """取某条轨道的版本记录副本(不存在时返回默认值;只读,不写回配置)。
未迁移的旧记录没有 kind按轨道名补上只有固定的 feature 轨是自测轨 未迁移的旧记录没有 kind按轨道名即分支名推断补上
""" """
tracks = (config.get("versions") or {}).get("tracks") or {} tracks = (config.get("versions") or {}).get("tracks") or {}
kind = KIND_FEATURE if track_name == TRACK_FEATURE else KIND_RELEASE
track = tracks.get(track_name) track = tracks.get(track_name)
kind = _default_kind_for_track(track_name)
if not isinstance(track, dict): if not isinstance(track, dict):
return _new_track(kind=kind) return _new_track(kind=kind)
return {**track, "kind": track.get("kind") or kind} return {**track, "kind": track.get("kind") if track.get("kind") in KINDS else kind}
def is_release_track(track: dict) -> bool: def is_release_track(track: dict) -> bool:
@@ -303,21 +336,19 @@ 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) -> str: def resolve_track(config: dict, branch: str, track: str):
"""把用户传入的轨道值解析为轨道名。 """把用户传入的轨道值解析为 (轨道名, 轨道类型)
兼容按类型指定``release`` 表示该分支自己的上架``feature`` 表示共用自测轨 传类型release / feature时用分支自己的轨并把它设成该类型
也可直接传已存在的轨道名个分支共用同一条版本线 也可直接传已存在的轨道名让这个分支与另一个分支共用同一条版本线类型不变
""" """
value = (track or "").strip() value = (track or "").strip()
if not value: if not value:
return _default_track_for_branch(branch) return _default_track_for_branch(branch), _default_kind_for_branch(branch)
if value == KIND_FEATURE: if value in KINDS:
return TRACK_FEATURE return _default_track_for_branch(branch), value
if value == KIND_RELEASE:
return _release_track_name(branch)
if value in ((config.get("versions") or {}).get("tracks") or {}): if value in ((config.get("versions") or {}).get("tracks") or {}):
return value return value, None
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}") raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
@@ -777,9 +808,10 @@ async def get_branches():
@router.post("/branches") @router.post("/branches")
async def add_branch(data: dict): async def add_branch(data: dict):
"""新增分支,可指定所属版本轨道(缺省按分支名推断)。 """新增分支,可指定版本轨道类型(缺省按分支名推断)。
track release 表示这个分支自己独立的上架版本号feature 表示共用自测版本号 个分支都有自己独立的一条轨道轨道名即分支名track release 表示上架用
App_Store 打包时构建号自增feature 表示自测用构建号不自增
也可直接传已有轨道名让该分支与另一个分支共用同一条版本线 也可直接传已有轨道名让该分支与另一个分支共用同一条版本线
""" """
name = data.get("name", "").strip() name = data.get("name", "").strip()
@@ -791,21 +823,21 @@ 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="分支已存在")
track = 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
config.setdefault("branch_track", {})[name] = track config.setdefault("branch_track", {})[name] = track
_ensure_track(config, track) _ensure_track(config, track, kind)
save_config(config) save_config(config)
return {"message": "分支已添加", "track": track} return {"message": "分支已添加", "track": track}
@router.put("/branches/track") @router.put("/branches/track")
async def update_branch_track(data: dict): async def update_branch_track(data: dict):
"""修改分支所属的版本轨道(release=该分支独立的上架版本号,feature=共用自测版本号)。 """修改分支的版本轨道类型release=上架,构建号自增;feature=自测,不自增)。
改成 release 时会为这个分支建一条以分支名命名的轨道初值沿用已有上架轨的 App_Ver 每个分支用自己的一条轨道切换类型只改这条轨道的行为版本号仍与其它分支互不影响
此后它的版本号与其它分支互不影响分支名可能含 / feature/login因此走请求体而非路径参数 分支名可能含 / feature/login因此走请求体而非路径参数
""" """
branch = (data.get("branch") or "").strip() branch = (data.get("branch") or "").strip()
if not (data.get("track") or "").strip(): if not (data.get("track") or "").strip():
@@ -815,9 +847,9 @@ async def update_branch_track(data: dict):
config = load_config() config = load_config()
if branch not in config.get("branches", []): if branch not in config.get("branches", []):
raise HTTPException(status_code=404, detail="分支不存在") raise HTTPException(status_code=404, detail="分支不存在")
track = 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
_ensure_track(config, track) _ensure_track(config, track, kind)
save_config(config) save_config(config)
return {"message": "分支版本轨道已更新", "branch": branch, "track": track} return {"message": "分支版本轨道已更新", "branch": branch, "track": track}
@@ -894,7 +926,7 @@ async def get_versions():
config = load_config() config = load_config()
tracks = { tracks = {
name: { name: {
"kind": KIND_FEATURE if name == TRACK_FEATURE else KIND_RELEASE, "kind": track.get("kind") if track.get("kind") in KINDS else _default_kind_for_track(name),
"app_ver": track.get("app_ver") or DEFAULT_APP_VER, "app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_ver": track_build_ver(track), "build_ver": track_build_ver(track),
"build_map": track.get("build_map") or {}, "build_map": track.get("build_map") or {},
+7 -3
View File
@@ -47,7 +47,7 @@
} }
}, },
"next_app_id_prefix": 3, "next_app_id_prefix": 3,
"branches": ["master", "develop"], "branches": ["master", "develop", "feature/示例分支"],
"upload": { "upload": {
"mode": "webdav", "mode": "webdav",
"oss": { "oss": {
@@ -76,8 +76,12 @@
"tracks": { "tracks": {
"release": { "kind": "release", "app_ver": "2.180.0", "build_map": {} }, "release": { "kind": "release", "app_ver": "2.180.0", "build_map": {} },
"develop": { "kind": "release", "app_ver": "2.180.0", "build_map": {} }, "develop": { "kind": "release", "app_ver": "2.180.0", "build_map": {} },
"feature": { "kind": "feature", "app_ver": "2.180.0", "build_map": {} } "feature/示例分支": { "kind": "feature", "app_ver": "2.180.0", "build_map": {} }
} }
}, },
"branch_track": { "master": "release", "develop": "develop" } "branch_track": {
"master": "release",
"develop": "develop",
"feature/示例分支": "feature/示例分支"
}
} }
+13 -11
View File
@@ -30,8 +30,7 @@ function mockFetch(url) {
primary_track: 'release', primary_track: 'release',
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: 'release', app_ver: '2.196.0', build_ver: '2.196.0.0', build_map: {} }, dev: { kind: 'feature', app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
feature: { kind: 'feature', app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
}, },
branch_track: { main: 'release', dev: 'dev' }, branch_track: { main: 'release', dev: 'dev' },
}, },
@@ -193,7 +192,7 @@ describe('ConfigView.vue', () => {
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps']) expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps'])
}) })
it('每个上架分支与自测轨各自独立展示与保存', async () => { it('每个分支的版本号各自独立展示与保存', async () => {
const calls = [] const calls = []
fetch.mockImplementation((url, opts) => { fetch.mockImplementation((url, opts) => {
if (url === '/api/config/versions' && opts?.method === 'PUT') { if (url === '/api/config/versions' && opts?.method === 'PUT') {
@@ -214,17 +213,20 @@ describe('ConfigView.vue', () => {
await buildMenu.trigger('click') await buildMenu.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const inputs = wrapper.findAll('input[type="text"]') // 每个分支一块:main(上架,release 轨)、dev(自测,dev 轨)
expect(inputs[0].element.value).toBe('2.195.0') // main 分支(release 轨)App_Ver expect(wrapper.text()).toContain('上架版本号(main')
expect(inputs[2].element.value).toBe('2.196.0') // dev 分支(dev 轨)App_Ver expect(wrapper.text()).toContain('自测版本号(dev')
expect(inputs[4].element.value).toBe('2.100.0') // feature App_Ver
await inputs[4].setValue('2.101.0') const inputs = wrapper.findAll('input[type="text"]')
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存自测版本号') expect(inputs[0].element.value).toBe('2.195.0') // main 分支 App_Ver
expect(inputs[2].element.value).toBe('2.100.0') // dev 分支 App_Ver
await inputs[2].setValue('2.101.0')
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存 dev 版本号')
await saveBtn.trigger('click') await saveBtn.trigger('click')
await flushPromises() await flushPromises()
expect(calls).toEqual([{ track: 'feature', app_ver: '2.101.0', build_ver: '2.101.0.0' }]) expect(calls).toEqual([{ track: 'dev', app_ver: '2.101.0', build_ver: '2.101.0.0' }])
}) })
it('分支管理可切换版本轨道', async () => { it('分支管理可切换版本轨道', async () => {
@@ -245,7 +247,7 @@ describe('ConfigView.vue', () => {
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const select = wrapper.find('.config-table select') const select = wrapper.find('.config-table select')
expect(select.element.value).toBe('release') expect(select.element.value).toBe('release') // main → release 轨(上架)
await select.setValue('feature') await select.setValue('feature')
await flushPromises() await flushPromises()
+20 -28
View File
@@ -172,8 +172,8 @@
<td><strong>{{ b }}</strong></td> <td><strong>{{ b }}</strong></td>
<td> <td>
<select :value="branchKind(b)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)"> <select :value="branchKind(b)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)">
<option value="release">上架本分支独立版本号</option> <option value="release">上架App_Store 打包构建号自增</option>
<option value="feature">自测共用 feature 版本号</option> <option value="feature">自测构建号不自增</option>
</select> </select>
</td> </td>
<td class="action-btns"> <td class="action-btns">
@@ -289,39 +289,30 @@
<div v-if="tab === 'build'"> <div v-if="tab === 'build'">
<h2>打包设置</h2> <h2>打包设置</h2>
<div style="max-width: 500px;"> <div style="max-width: 500px;">
<template v-for="t in releaseTracks" :key="t.name"> <template v-for="t in branchTracks" :key="t.name">
<h4 class="section-title">上架版本号{{ t.branches.join(' / ') }}</h4> <h4 class="section-title">
{{ t.kind === 'release' ? '上架版本号' : '自测版本号' }}{{ t.branches.join(' / ') }}
</h4>
<div class="form-group"> <div class="form-group">
<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;">格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }} 分支轨道 {{ t.name }}与其它分支互不影响</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }} 分支轨道 {{ t.name }}与其它分支互不影响
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Build_Ver构建号</label> <label>Build_Ver构建号</label>
<input type="text" v-model="versions[t.name].build_ver" placeholder="2.180.0.0"> <input type="text" v-model="versions[t.name].build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;"> <div v-if="t.kind === 'release'" style="font-size: 12px; color: #999; margin-top: 4px;">
四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号 四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号
每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改 每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改
</div> </div>
</div> <div v-else style="font-size: 12px; color: #999; margin-top: 4px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions(t.name)">保存 {{ t.branches.join(' / ') }} 版本号</button> 四段数字前三位须与 App_Ver 一致自测轨打包时<strong>不会自动递增</strong>
</template> 始终使用这里填写的值默认 .0需要区分时请手动修改
<template v-if="versions.feature">
<h4 class="section-title">自测版本号feature 分支</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<input type="text" v-model="versions.feature.app_ver" placeholder="2.180.0" @input="onAppVerInput('feature')">
<div style="font-size: 12px; color: #999; margin-top: 4px;">所有自测分支共用与上架版本号各自独立互不影响</div>
</div>
<div class="form-group">
<label>Build_Ver构建号</label>
<input type="text" v-model="versions.feature.build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅供自测打包时<strong>不会自动递增</strong>始终使用这里填写的值默认 .0需要区分时请手动修改
</div> </div>
</div> </div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('feature')">保存自测版本号</button> <button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions(t.name)">保存 {{ t.branches.join(' / ') }} 版本号</button>
</template> </template>
<template v-if="isAdmin"> <template v-if="isAdmin">
@@ -658,8 +649,8 @@ const servers = ref({})
const branches = ref([]) const branches = ref([])
const newBranch = ref('') const newBranch = ref('')
const buildSettings = ref({}) const buildSettings = ref({})
// masterdevelop // master/main 沿 release
// feature // kind release=App_Store feature=
const versions = ref({}) // -> { app_ver, build_ver } const versions = ref({}) // -> { app_ver, build_ver }
const buildMap = ref({}) // -> { App_Ver: } const buildMap = ref({}) // -> { App_Ver: }
const trackKind = ref({}) // -> release | feature const trackKind = ref({}) // -> release | feature
@@ -684,14 +675,15 @@ const applyVersions = (data) => {
// //
const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'feature' const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'feature'
// //
const releaseTracks = computed(() => { const branchTracks = computed(() => {
const seen = new Set() const seen = new Set()
return branches.value return branches.value
.map(b => branchTrack.value[b]) .map(b => branchTrack.value[b])
.filter(name => name && trackKind.value[name] === 'release' && !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',
branches: branches.value.filter(b => branchTrack.value[b] === name), branches: branches.value.filter(b => branchTrack.value[b] === name),
})) }))
}) })
+34 -23
View File
@@ -229,19 +229,20 @@ def test_update_versions_rejects_invalid_app_version(client, tmp_config):
def test_versions_tracks_are_independent(client, tmp_config): def test_versions_tracks_are_independent(client, tmp_config):
# 每个上架分支一条轨道(main/master 为 release,其余即分支名),App_Ver 各自独立 # 每个分支一条轨道(main/master 为 release,其余即分支名),App_Ver 各自独立
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200 assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200
assert client.put("/api/config/versions", json={"track": "dev", "app_ver": "2.198.0"}).status_code == 200 assert client.put("/api/config/versions", json={"track": "dev", "app_ver": "2.198.0"}).status_code == 200
assert client.put("/api/config/versions", json={"track": "feature", "app_ver": "2.100.0"}).status_code == 200 assert client.put("/api/config/versions", json={"track": "feature/login", "app_ver": "2.100.0"}).status_code == 200
data = client.get("/api/config/versions").json() data = client.get("/api/config/versions").json()
assert data["branch_track"] == {"main": "release", "dev": "dev"} assert data["branch_track"] == {"main": "release", "dev": "dev", "feature/login": "feature/login"}
assert data["tracks"]["release"]["app_ver"] == "2.196.0" assert data["tracks"]["release"]["app_ver"] == "2.196.0"
assert data["tracks"]["dev"]["app_ver"] == "2.198.0" assert data["tracks"]["dev"]["app_ver"] == "2.198.0"
assert data["tracks"]["feature"]["app_ver"] == "2.100.0" assert data["tracks"]["feature/login"]["app_ver"] == "2.100.0"
assert data["tracks"]["feature"]["build_ver"] == "2.100.0.0" assert data["tracks"]["feature/login"]["build_ver"] == "2.100.0.0"
assert data["tracks"]["release"]["kind"] == "release" assert data["tracks"]["release"]["kind"] == "release"
assert data["tracks"]["feature"]["kind"] == "feature" assert data["tracks"]["feature/login"]["kind"] == "feature"
# 顶层字段镜像主上架轨(第一个上架分支),兼容旧客户端 # 顶层字段镜像主上架轨(第一个上架分支),兼容旧客户端
assert data["primary_track"] == "release" assert data["primary_track"] == "release"
assert data["app_ver"] == "2.196.0" assert data["app_ver"] == "2.196.0"
@@ -252,8 +253,8 @@ def test_update_versions_rejects_unknown_track(client, tmp_config):
assert resp.status_code == 400 assert resp.status_code == 400
def test_legacy_versions_split_per_release_branch(client, tmp_config): def test_legacy_versions_split_per_branch(client, tmp_config):
"""最旧的单轨配置:拆成每个上架分支一条轨,各自沿用原来的已用构建号""" """最旧的单轨配置:拆成每个分支一条轨,各自沿用原来的已用构建号"""
import json import json
with open(tmp_config, "r", encoding="utf-8") as f: with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f) cfg = json.load(f)
@@ -271,9 +272,6 @@ def test_legacy_versions_split_per_release_branch(client, tmp_config):
"build_ver": "2.195.0.7", "build_ver": "2.195.0.7",
"build_map": {"2.195.0": 7}, "build_map": {"2.195.0": 7},
} }
# feature 轨以同一 App_Ver 初始化,构建号从 .0 起
assert data["tracks"]["feature"]["app_ver"] == "2.195.0"
assert data["tracks"]["feature"]["build_ver"] == "2.195.0.0"
# 拆分后各轨独立:改 release 不影响 dev # 拆分后各轨独立:改 release 不影响 dev
assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200 assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200
data = client.get("/api/config/versions").json() data = client.get("/api/config/versions").json()
@@ -281,26 +279,40 @@ def test_legacy_versions_split_per_release_branch(client, tmp_config):
assert data["tracks"]["dev"]["app_ver"] == "2.195.0" assert data["tracks"]["dev"]["app_ver"] == "2.195.0"
def test_legacy_shared_release_track_splits_per_branch(client, tmp_config): def test_legacy_shared_tracks_split_per_branch(client, tmp_config):
"""旧的 master/develop 共用 release 轨:master 留在 release 轨,其余分支拆出自己的轨""" """旧的共用轨:master 留在 release 轨,其余分支(含自测分支)各拆出自己的轨"""
import json import json
with open(tmp_config, "r", encoding="utf-8") as f: with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f) cfg = json.load(f)
cfg["branches"] = ["main", "dev", "feature/login", "feature/pay"]
cfg["versions"] = {"tracks": { cfg["versions"] = {"tracks": {
"release": {"app_ver": "2.195.0", "build_map": {"2.195.0": 4}}, "release": {"app_ver": "2.195.0", "build_map": {"2.195.0": 4}},
"feature": {"app_ver": "2.100.0", "build_map": {}}, "feature": {"app_ver": "2.100.0", "build_map": {}},
}} }}
cfg["branch_track"] = {"main": "release", "dev": "release"} cfg["branch_track"] = {
"main": "release", "dev": "release",
"feature/login": "feature", "feature/pay": "feature",
}
with open(tmp_config, "w", encoding="utf-8") as f: with open(tmp_config, "w", encoding="utf-8") as f:
json.dump(cfg, f) json.dump(cfg, f)
data = client.get("/api/config/versions").json() data = client.get("/api/config/versions").json()
assert data["branch_track"] == {"main": "release", "dev": "dev"} assert data["branch_track"] == {
"main": "release", "dev": "dev",
"feature/login": "feature/login", "feature/pay": "feature/pay",
}
for track in ("release", "dev"): for track in ("release", "dev"):
assert data["tracks"][track]["kind"] == "release" assert data["tracks"][track]["kind"] == "release"
assert data["tracks"][track]["app_ver"] == "2.195.0"
assert data["tracks"][track]["build_ver"] == "2.195.0.4" assert data["tracks"][track]["build_ver"] == "2.195.0.4"
assert data["tracks"]["feature"]["app_ver"] == "2.100.0" for track in ("feature/login", "feature/pay"):
assert data["tracks"][track]["kind"] == "feature"
assert data["tracks"][track]["app_ver"] == "2.100.0"
# 自测分支的版本号各自独立
assert client.put("/api/config/versions", json={"track": "feature/login", "app_ver": "2.101.0"}).status_code == 200
data = client.get("/api/config/versions").json()
assert data["tracks"]["feature/login"]["app_ver"] == "2.101.0"
assert data["tracks"]["feature/pay"]["app_ver"] == "2.100.0"
def test_branch_track_defaults_and_override(client, tmp_config): def test_branch_track_defaults_and_override(client, tmp_config):
@@ -308,22 +320,21 @@ def test_branch_track_defaults_and_override(client, tmp_config):
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200 assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
data = client.get("/api/config/versions").json() data = client.get("/api/config/versions").json()
# 上架分支各有一条独立轨道(main/master 为 release,其余即分支名),自测分支共用 feature 轨 # 每个分支一条自己的轨道,类型按分支名推断
assert data["branch_track"]["develop"] == "develop" assert data["branch_track"]["develop"] == "develop"
assert data["branch_track"]["feature/login"] == "feature" assert data["branch_track"]["feature/login"] == "feature/login"
assert data["tracks"]["develop"]["kind"] == "release" assert data["tracks"]["develop"]["kind"] == "release"
assert data["tracks"]["feature/login"]["kind"] == "feature"
# 手动改归属:改成上架后拥有自己的轨道 # 手动改类型:轨道名不变,只改构建号是否自增
resp = client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "release"}) resp = client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "release"})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["track"] == "feature/login" assert resp.json()["track"] == "feature/login"
data = client.get("/api/config/versions").json() data = client.get("/api/config/versions").json()
assert data["branch_track"]["feature/login"] == "feature/login"
assert data["tracks"]["feature/login"]["kind"] == "release" assert data["tracks"]["feature/login"]["kind"] == "release"
# 改回自测则重新共用 feature 轨
assert client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "feature"}).status_code == 200 assert client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "feature"}).status_code == 200
assert client.get("/api/config/versions").json()["branch_track"]["feature/login"] == "feature" assert client.get("/api/config/versions").json()["tracks"]["feature/login"]["kind"] == "feature"
assert client.put("/api/config/branches/track", json={"branch": "nope", "track": "release"}).status_code == 404 assert client.put("/api/config/branches/track", json={"branch": "nope", "track": "release"}).status_code == 404
assert client.put("/api/config/branches/track", json={"branch": "develop", "track": "beta"}).status_code == 400 assert client.put("/api/config/branches/track", json={"branch": "develop", "track": "beta"}).status_code == 400
+6 -5
View File
@@ -426,7 +426,7 @@ async def test_generate_config_build_no_skips_other_release_track(tmp_path, log_
async def test_generate_config_feature_branch_never_increments(tmp_path, log_streamer): async def test_generate_config_feature_branch_never_increments(tmp_path, log_streamer):
"""feature 分支用自己的版本号,且构建号永不自增(即使是 App_Store 打包)""" """自测分支用自己那条轨道的版本号,且构建号永不自增(即使是 App_Store 打包)"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"
build_dir.mkdir() build_dir.mkdir()
@@ -435,7 +435,7 @@ async def test_generate_config_feature_branch_never_increments(tmp_path, log_str
mock_config = _versions_config({ mock_config = _versions_config({
"release": _release("2.195.0", {"2.195.0": 5}), "release": _release("2.195.0", {"2.195.0": 5}),
"feature": _feature("2.100.0"), "feature/login": _feature("2.100.0"),
}) })
with patch("backend.routers.config.load_config", return_value=mock_config), \ with patch("backend.routers.config.load_config", return_value=mock_config), \
@@ -444,7 +444,7 @@ async def test_generate_config_feature_branch_never_increments(tmp_path, log_str
assert result["VERSION"] == "2.100.0" assert result["VERSION"] == "2.100.0"
assert result["BUILD_VERSION"] == "2.100.0.0" assert result["BUILD_VERSION"] == "2.100.0.0"
assert mock_config["versions"]["tracks"]["feature"]["build_map"] == {} assert mock_config["versions"]["tracks"]["feature/login"]["build_map"] == {}
save_mock.assert_not_called() save_mock.assert_not_called()
@@ -458,9 +458,10 @@ async def test_generate_config_branch_track_override(tmp_path, log_streamer):
mock_config = _versions_config({ mock_config = _versions_config({
"release": _release("2.196.0"), "release": _release("2.196.0"),
"feature": _feature("2.100.0"), "feature/login": _feature("2.100.0"),
}) })
mock_config["branch_track"] = {"release/2.196": "feature"} # release/2.196 按名字本应是上架轨,显式指到 feature/login 那条轨道
mock_config["branch_track"] = {"release/2.196": "feature/login"}
with patch("backend.routers.config.load_config", return_value=mock_config): with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir) result = await generate_config("t1", task, build_dir)