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:
@@ -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"),
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
|
||||
|
||||
Reference in New Issue
Block a user