From 5582aadc09947504a0d090bab5a2833c514352ff Mon Sep 17 00:00:00 2001 From: shenlei Date: Thu, 17 Sep 2026 11:59:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89=20Tag=20?= =?UTF-8?q?=E6=89=93=E5=8C=85=EF=BC=8C=E5=88=86=E6=94=AF=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8F=AF=E6=B7=BB=E5=8A=A0=20Tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分支管理新增「分支 / Tag」类型选择,Tag 存于 config["tags"],与分支不可重名 - 每个 Tag 与分支一样拥有同名独立版本轨道,默认上架轨 - 打包页按「分支」「Tag」分组选择;任务新增 ref_type 字段记录引用类型 - Tag 打包时 fetch --tags --force 后以游离 HEAD 检出 refs/tags/ - 历史记录与下载页按类型显示「分支」或「Tag」 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + README.md | 3 +- backend/database.py | 1 + backend/models.py | 3 +- backend/routers/apps.py | 8 +- backend/routers/config.py | 90 +++++++++++++++++++++-- backend/routers/tasks.py | 7 +- backend/schemas.py | 1 + backend/services/build_service.py | 35 ++++++--- backend/services/distribution.py | 2 +- frontend/src/__tests__/BuildView.test.js | 40 ++++++++++ frontend/src/__tests__/ConfigView.test.js | 41 ++++++++++- frontend/src/views/BuildView.vue | 17 ++++- frontend/src/views/ConfigView.vue | 59 ++++++++++----- frontend/src/views/HistoryView.vue | 2 +- tests/test_api_config.py | 34 +++++++++ tests/test_api_tasks.py | 17 +++++ tests/test_build_service.py | 36 ++++++++- 18 files changed, 347 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b3531c..6fc9007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - `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) +- 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/` detached ## Conventions diff --git a/README.md b/README.md index b2e941a..25ff50d 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token> | App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 | | Scheme 管理 | 打包 Scheme 配置 | | 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 | -| 分支管理 | 可打包的代码分支列表,以及每个分支的版本轨道类型(上架 / 自测) | +| 分支管理 | 可打包的代码分支和 Tag 列表,以及各自的版本轨道类型(上架 / 自测);Tag 打包时以游离 HEAD 检出 `refs/tags/` | | 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) | | 上传配置 | 服务端内置 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 Store 的构建号唯一性按 App_Ver 全局判定,因此两个上架分支若临时用了同一个 App_Ver,递增时会跨轨道取最大值 +1,避免重号被拒。 diff --git a/backend/database.py b/backend/database.py index 9280f0f..21d66d2 100644 --- a/backend/database.py +++ b/backend/database.py @@ -23,6 +23,7 @@ def _migrate_db(): """简单的数据库迁移:为旧表添加缺失的列""" migrations = [ ("tasks", "branch", "VARCHAR DEFAULT 'main'"), + ("tasks", "ref_type", "VARCHAR DEFAULT 'branch'"), ("tasks", "error_category", "VARCHAR"), ("tasks", "superseded_at", "TIMESTAMP"), ("tasks", "dsym_url", "VARCHAR"), diff --git a/backend/models.py b/backend/models.py index d8edabf..ba3a673 100644 --- a/backend/models.py +++ b/backend/models.py @@ -15,7 +15,8 @@ class Task(Base): scheme_id = Column(String, nullable=False) scheme_name = Column(String, nullable=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 diff --git a/backend/routers/apps.py b/backend/routers/apps.py index 546d60b..d06ed2f 100644 --- a/backend/routers/apps.py +++ b/backend/routers/apps.py @@ -1,7 +1,7 @@ """打包选择 API""" from fastapi import APIRouter -from .config import load_config +from .config import get_tags, load_config router = APIRouter(prefix="/api", tags=["apps"]) @@ -44,3 +44,9 @@ async def get_branches_for_build(): """获取分支列表(供打包选择)""" config = load_config() return config.get("branches", ["main"]) + + +@router.get("/tags") +async def get_tags_for_build(): + """获取 Tag 列表(供打包选择)""" + return get_tags(load_config()) diff --git a/backend/routers/config.py b/backend/routers/config.py index 0f05f9f..f3a66fb 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -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) }, } diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 2cba2df..08fcd5f 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -55,6 +55,10 @@ async def create_task(task: TaskCreate, request: Request, user: dict = Depends(g 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()) db_task = Task( 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, obfuscation=task.obfuscation, branch=task.branch, + ref_type=ref_type, status="pending", ) db.add(db_task) from ..services.audit_log import record_operation 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.refresh(db_task) diff --git a/backend/schemas.py b/backend/schemas.py index 640c341..0bd84a3 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -22,6 +22,7 @@ class TaskResponse(BaseModel): scheme_name: str obfuscation: bool branch: str + ref_type: Optional[str] = "branch" status: str current_step: Optional[str] created_at: Optional[datetime] diff --git a/backend/services/build_service.py b/backend/services/build_service.py index af576ad..105075d 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -291,8 +291,8 @@ def _db_update(db, task, **fields): 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_masked = mask_git_remote_url(remote_url) 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(): 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: 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: raise Exception("git remote set-url 失败") - 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 失败"), - ] + if is_tag: + # Tag 检出为游离 HEAD;--force 让远端被移动过的 Tag 覆盖本地旧 Tag + tag_ref = f"refs/tags/{branch}" + commands = [ + (("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: process = await asyncio.create_subprocess_exec( *_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) if not source_dir.exists(): 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}") 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, "等待共享源码准备队列...") async with source_prepare_lock: 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) await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}") 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") config_data = await generate_config(task_id, task, build_dir) 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 await asyncio.to_thread(_db_update, db, task, config_json=json.dumps(config_data, ensure_ascii=False)) diff --git a/backend/services/distribution.py b/backend/services/distribution.py index 827162f..a7fe30d 100644 --- a/backend/services/distribution.py +++ b/backend/services/distribution.py @@ -174,7 +174,7 @@ def _write_download_page(config: dict, html: Path, manifest_url: str): meta_items = [ ("构建版本", config.get("BUILD_VERSION", "")), ("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( f"
{escape(label)}
{escape(str(value))}
" diff --git a/frontend/src/__tests__/BuildView.test.js b/frontend/src/__tests__/BuildView.test.js index 1712099..4fad61a 100644 --- a/frontend/src/__tests__/BuildView.test.js +++ b/frontend/src/__tests__/BuildView.test.js @@ -73,6 +73,46 @@ describe('BuildView.vue', () => { 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 () => { const wrapper = mountBuildView() await flushPromises() diff --git a/frontend/src/__tests__/ConfigView.test.js b/frontend/src/__tests__/ConfigView.test.js index 59d3be7..e1f4554 100644 --- a/frontend/src/__tests__/ConfigView.test.js +++ b/frontend/src/__tests__/ConfigView.test.js @@ -23,6 +23,7 @@ function mockFetch(url) { '/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } }, '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, '/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/versions': { app_ver: '2.195.0', @@ -31,8 +32,9 @@ function mockFetch(url) { tracks: { 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: {} }, + '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'] }, } @@ -253,4 +255,41 @@ describe('ConfigView.vue', () => { 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], + ]) + }) }) diff --git a/frontend/src/views/BuildView.vue b/frontend/src/views/BuildView.vue index baf96bb..3b67103 100644 --- a/frontend/src/views/BuildView.vue +++ b/frontend/src/views/BuildView.vue @@ -41,9 +41,14 @@
- +
@@ -167,6 +172,7 @@ const authFetch = (url, options = {}) => { const apps = ref({}) const schemes = ref({}) const branches = ref(['main']) +const tags = ref([]) const tasks = ref([]) const selectedServer = ref('') const serverEnvironments = computed(() => { @@ -314,15 +320,18 @@ onUnmounted(() => { }) onMounted(async () => { - const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([ + const [appsRes, schemesRes, branchesRes, tagsRes, tasksRes] = await Promise.all([ authFetch('/api/apps'), authFetch('/api/schemes'), authFetch('/api/branches'), + authFetch('/api/tags'), authFetch('/api/tasks'), ]) apps.value = await appsRes.json() schemes.value = await schemesRes.json() branches.value = await branchesRes.json() + const tagList = await tagsRes.json() + tags.value = Array.isArray(tagList) ? tagList : [] const allTasks = await tasksRes.json() // 从历史页跳转过来时,读取 taskId 查询参数 @@ -436,7 +445,7 @@ const errorCategoryHint = (cat) => { provisioning: '描述文件问题:描述文件过期、不匹配或未安装', compilation: '代码编译错误:请检查代码语法和类型引用', dependency: '依赖问题:Pod 依赖缺失或版本不兼容', - git: '代码拉取失败:请检查分支是否存在', + git: '代码拉取失败:请检查分支或 Tag 是否存在', config: '配置替换失败:请检查打包配置', build: '构建配置错误:请检查 Scheme 和 Workspace 配置', unknown: '打包失败:请查看详细日志排查原因', diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index f3f1320..f141b0f 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -152,36 +152,42 @@

分支管理

- 管理可打包的代码分支,打包时从对应分支目录获取源码。 - 版本轨道决定该分支使用哪一套版本号:上架(master/develop 共用,App_Store 打包构建号自增)、自测(feature 用,构建号不自增)。 + 管理可打包的代码分支和 Tag,打包时从对应分支或 Tag 获取源码。 + 每个分支 / Tag 各有一套独立的版本号,版本轨道决定构建号行为:上架(App_Store 打包构建号自增)、自测(构建号不自增)。新增 Tag 默认为上架。

- - + + +
- + + - - + + + - - + +
分支名称名称类型 版本轨道 操作
{{ b }}
{{ r.name }}{{ r.type === 'tag' ? 'Tag' : '分支' }} - - +
暂无分支配置
暂无分支配置
@@ -297,7 +303,7 @@
- 格式:主版本.次版本.修订号。该版本号只用于 {{ t.branches.join(' / ') }} 分支(轨道 {{ t.name }}),与其它分支互不影响。 + 格式:主版本.次版本.修订号。该版本号只用于 {{ t.branches.join(' / ') }}(轨道 {{ t.name }}),与其它分支 / Tag 互不影响。
@@ -647,7 +653,14 @@ const apps = ref({}) const schemes = ref({}) const servers = ref({}) const branches = ref([]) +const tags = 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({}) // 版本轨道:每个分支一条独立轨道,轨道名即分支名(master/main 沿用历史名 release)。 // 轨道类型 kind 决定构建号行为:release=上架、App_Store 打包自增;feature=自测、不自增。 @@ -678,13 +691,14 @@ const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'fe // 打包设置里按分支分块展示版本号;多个分支共用一条轨道时只显示一块 const branchTracks = computed(() => { const seen = new Set() - return branches.value + const refs = [...branches.value, ...tags.value] + return refs .map(b => branchTrack.value[b]) .filter(name => name && versions.value[name] && !seen.has(name) && seen.add(name)) .map(name => ({ name, 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 } - 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/schemes'), authFetch('/api/config/servers'), authFetch('/api/config/branches'), + authFetch('/api/config/tags'), authFetch('/api/config/build'), authFetch('/api/config/upload'), authFetch('/api/config'), @@ -762,6 +777,8 @@ const loadData = async () => { schemes.value = await schemesRes.json() servers.value = await serversRes.json() branches.value = await branchesRes.json() + const tagList = await tagsRes.json() + tags.value = Array.isArray(tagList) ? tagList : [] buildSettings.value = await buildRes.json() uploadConfig.value = await uploadRes.json() jsonContent.value = JSON.stringify(await configRes.json(), null, 2) @@ -1178,12 +1195,12 @@ const deleteScheme = async (id) => { await loadData() } -// 分支管理 +// 分支管理(分支与 Tag 共用一张表) const addBranch = async () => { const name = newBranch.value.trim() if (!name) return try { - const res = await authFetch('/api/config/branches', { + const res = await authFetch(newRefType.value === 'tag' ? '/api/config/tags' : '/api/config/branches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), @@ -1218,10 +1235,12 @@ const setBranchTrack = async (name, track) => { } } -const deleteBranch = async (name) => { - if (!confirm(`确定删除分支「${name}」?`)) return +const deleteBranch = async ({ name, type }) => { + const isTag = type === 'tag' + if (!confirm(`确定删除${isTag ? ' Tag ' : '分支'}「${name}」?`)) return 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('删除失败') await loadData() } catch (e) { diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue index adae8f6..687be48 100644 --- a/frontend/src/views/HistoryView.vue +++ b/frontend/src/views/HistoryView.vue @@ -110,7 +110,7 @@ {{ logTask?.build_type }}
- 分支 + {{ logTask?.ref_type === 'tag' ? 'Tag' : '分支' }} {{ logTask?.branch || '-' }}
diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 14d13d7..d78c642 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -345,3 +345,37 @@ def test_delete_branch_with_slash_in_name(client, tmp_config): resp = client.delete("/api/config/branches/feature%2Flogin") assert resp.status_code == 200 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 diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index 51b687f..5d3624f 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -38,6 +38,23 @@ def test_create_task_default_branch(mock_queue, client, tmp_config): }) assert resp.status_code == 200 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") diff --git a/tests/test_build_service.py b/tests/test_build_service.py index f5f616c..76ce93c 100644 --- a/tests/test_build_service.py +++ b/tests/test_build_service.py @@ -112,6 +112,25 @@ async def test_update_source_existing(tmp_path, log_streamer): 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/,不依赖同名远程分支""" + 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): """fetch 失败时抛异常""" 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"), \ 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), \ + 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"): build_dir, commit = await prepare_source_snapshot("t1", task) assert build_dir.exists() 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 ----