qtcloud-devops-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qtcloud_devops_cli-0.1.0/PKG-INFO +7 -0
- qtcloud_devops_cli-0.1.0/README.md +65 -0
- qtcloud_devops_cli-0.1.0/app/__init__.py +0 -0
- qtcloud_devops_cli-0.1.0/app/cli.py +57 -0
- qtcloud_devops_cli-0.1.0/app/config.py +8 -0
- qtcloud_devops_cli-0.1.0/app/release.py +253 -0
- qtcloud_devops_cli-0.1.0/pyproject.toml +26 -0
- qtcloud_devops_cli-0.1.0/qtcloud_devops_cli.egg-info/PKG-INFO +7 -0
- qtcloud_devops_cli-0.1.0/qtcloud_devops_cli.egg-info/SOURCES.txt +15 -0
- qtcloud_devops_cli-0.1.0/qtcloud_devops_cli.egg-info/dependency_links.txt +1 -0
- qtcloud_devops_cli-0.1.0/qtcloud_devops_cli.egg-info/entry_points.txt +2 -0
- qtcloud_devops_cli-0.1.0/qtcloud_devops_cli.egg-info/requires.txt +2 -0
- qtcloud_devops_cli-0.1.0/qtcloud_devops_cli.egg-info/top_level.txt +1 -0
- qtcloud_devops_cli-0.1.0/setup.cfg +4 -0
- qtcloud_devops_cli-0.1.0/tests/test_cli_commands.py +62 -0
- qtcloud_devops_cli-0.1.0/tests/test_config.py +12 -0
- qtcloud_devops_cli-0.1.0/tests/test_release.py +446 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# qtcloud-devops-cli
|
|
2
|
+
|
|
3
|
+
DevOps CLI — 发布管理、契约检查与工作流自动化。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd apps/qtcloud-devops
|
|
9
|
+
pip install -e src/cli
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
依赖:Python 3.12+,包管理器 uv。
|
|
13
|
+
|
|
14
|
+
## 项目结构
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
src/cli/
|
|
18
|
+
├── app/
|
|
19
|
+
│ ├── __init__.py
|
|
20
|
+
│ ├── cli.py # Typer CLI 入口
|
|
21
|
+
│ ├── config.py # pydantic-settings 配置
|
|
22
|
+
│ ├── contract.py # 契约加载与查询
|
|
23
|
+
│ └── release.py # 发布 Release 逻辑
|
|
24
|
+
├── tests/
|
|
25
|
+
│ ├── __init__.py
|
|
26
|
+
│ ├── conftest.py
|
|
27
|
+
│ ├── test_cli_commands.py
|
|
28
|
+
│ ├── test_release.py
|
|
29
|
+
│ └── test_contract.py
|
|
30
|
+
├── pyproject.toml
|
|
31
|
+
├── AGENTS.md
|
|
32
|
+
├── CHANGELOG.md
|
|
33
|
+
├── ROADMAP.md
|
|
34
|
+
└── .gitignore
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 用法
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 标签 + GitHub Release
|
|
41
|
+
qtcloud-devops release --version v0.1.0
|
|
42
|
+
|
|
43
|
+
# 仅标签
|
|
44
|
+
qtcloud-devops release --version v0.1.0 --tag-only
|
|
45
|
+
|
|
46
|
+
# 仅为已有标签补 GitHub Release
|
|
47
|
+
qtcloud-devops release --version v0.1.0 --release-only
|
|
48
|
+
|
|
49
|
+
# 仅预检查
|
|
50
|
+
qtcloud-devops release --version v0.1.0 --dry-run
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 发布流程
|
|
54
|
+
|
|
55
|
+
1. **更新版本号** → 改 `pyproject.toml`
|
|
56
|
+
2. **写 CHANGELOG** → 更新 `CHANGELOG.md`
|
|
57
|
+
3. **提交** → `chore: bump qtcloud-devops-cli to vX.Y.Z`
|
|
58
|
+
4. **打 tag** → `cli/vX.Y.Z`
|
|
59
|
+
5. **推送** → CI 自动发布
|
|
60
|
+
|
|
61
|
+
## 命名约定
|
|
62
|
+
|
|
63
|
+
- 包名(PyPI): `qtcloud-devops-cli`
|
|
64
|
+
- 导入名: `app.*`
|
|
65
|
+
- 仓库 tag 前缀: `cli/`
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""qtcloud-devops-cli — DevOps 发布管理命令行工具。
|
|
3
|
+
|
|
4
|
+
基于 devops-release 技能,提供预检查、发布前确认、执行发布、验证、回滚的全流程自动化。
|
|
5
|
+
|
|
6
|
+
用法:
|
|
7
|
+
qtcloud-devops release --version v0.1.0 # 标签 + GitHub Release
|
|
8
|
+
qtcloud-devops release --version v0.1.0 --tag-only # 仅创建 Git 标签
|
|
9
|
+
qtcloud-devops release --version v0.1.0 --release-only # 仅创建 GitHub Release
|
|
10
|
+
qtcloud-devops release --version v0.1.0 --dry-run # 仅检查
|
|
11
|
+
qtcloud-devops release --version v0.1.0 -y # 跳过确认
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
app = typer.Typer()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.callback()
|
|
20
|
+
def main_callback() -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def release(
|
|
25
|
+
version: str = typer.Option(..., "--version", "-V", help="版本号(如 v0.1.0)"),
|
|
26
|
+
changelog: str = typer.Option(
|
|
27
|
+
"CHANGELOG.md", "--changelog", help="CHANGELOG.md 路径"
|
|
28
|
+
),
|
|
29
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="仅检查,不执行"),
|
|
30
|
+
tag_only: bool = typer.Option(
|
|
31
|
+
False, "--tag-only", help="仅创建 Git 标签(默认行为,显式声明意图)"
|
|
32
|
+
),
|
|
33
|
+
release_only: bool = typer.Option(
|
|
34
|
+
False, "--release-only", help="仅创建 GitHub Release,跳过 Git 标签"
|
|
35
|
+
),
|
|
36
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示,直接发布"),
|
|
37
|
+
):
|
|
38
|
+
"""发布 Release。
|
|
39
|
+
|
|
40
|
+
默认行为:创建 Git 标签并推送 + GitHub Release(仓库从 git remote 自动检测)。
|
|
41
|
+
--tag-only:仅打标签,跳过 GitHub Release。
|
|
42
|
+
--release-only:仅为已有标签创建 GitHub Release(跳过标签创建)。
|
|
43
|
+
"""
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from app.release import run
|
|
47
|
+
|
|
48
|
+
code = run(version, Path(changelog), dry_run, tag_only, release_only, yes)
|
|
49
|
+
raise typer.Exit(code=code)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
return app()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__": # pragma: no cover
|
|
57
|
+
exit(main())
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_remote_repo() -> Optional[str]:
|
|
8
|
+
"""从 git remote 解析 owner/name。"""
|
|
9
|
+
result = subprocess.run(
|
|
10
|
+
["git", "remote", "get-url", "origin"],
|
|
11
|
+
capture_output=True,
|
|
12
|
+
text=True,
|
|
13
|
+
)
|
|
14
|
+
if result.returncode != 0:
|
|
15
|
+
return None
|
|
16
|
+
url = result.stdout.strip()
|
|
17
|
+
m = re.search(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$", url)
|
|
18
|
+
if m:
|
|
19
|
+
return m.group(1)
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def precheck(version: str, changelog: Path, release_only: bool = False) -> list[str]:
|
|
24
|
+
errors = []
|
|
25
|
+
|
|
26
|
+
if not re.match(
|
|
27
|
+
r"^(v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?|[a-zA-Z0-9_.-]+/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$",
|
|
28
|
+
version,
|
|
29
|
+
):
|
|
30
|
+
errors.append(f"版本号格式错误: {version}")
|
|
31
|
+
|
|
32
|
+
if changelog.exists():
|
|
33
|
+
content = changelog.read_text(encoding="utf-8")
|
|
34
|
+
ver = version.split("/v", 1)[1] if "/v" in version else version.lstrip("v")
|
|
35
|
+
if f"## [{ver}]" not in content:
|
|
36
|
+
errors.append(f"CHANGELOG.md 未找到 {ver} 版本记录")
|
|
37
|
+
else:
|
|
38
|
+
errors.append(f"CHANGELOG.md 不存在: {changelog}")
|
|
39
|
+
|
|
40
|
+
if release_only:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "tag", "-l"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
)
|
|
46
|
+
existing_tags = result.stdout.strip().split("\n")
|
|
47
|
+
if version not in existing_tags:
|
|
48
|
+
errors.append(f"标签不存在: {version}(--release-only 需要标签已存在)")
|
|
49
|
+
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["git", "status", "--porcelain"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
)
|
|
55
|
+
if result.stdout.strip():
|
|
56
|
+
errors.append("工作区有未提交的变更")
|
|
57
|
+
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
)
|
|
63
|
+
branch = result.stdout.strip()
|
|
64
|
+
if not branch.startswith(("main", "master", "release/")):
|
|
65
|
+
errors.append(
|
|
66
|
+
f"不在可发布分支上 (当前: {branch}),请切换到 main/master/release/*"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return errors
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_notes(version: str, changelog: Path) -> Optional[str]:
|
|
73
|
+
ver = version.split("/v", 1)[1] if "/v" in version else version.lstrip("v")
|
|
74
|
+
content = changelog.read_text(encoding="utf-8")
|
|
75
|
+
lines = content.split("\n")
|
|
76
|
+
capture = False
|
|
77
|
+
notes: list[str] = []
|
|
78
|
+
for line in lines:
|
|
79
|
+
if line.startswith(f"## [{ver}]"):
|
|
80
|
+
capture = True
|
|
81
|
+
continue
|
|
82
|
+
if capture:
|
|
83
|
+
if line.startswith("## ["):
|
|
84
|
+
break
|
|
85
|
+
notes.append(line)
|
|
86
|
+
text = "\n".join(notes).strip()
|
|
87
|
+
return text if text else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def confirm_release(version: str, notes: Optional[str], yes: bool = False) -> bool:
|
|
91
|
+
"""🧠 AI 介入点:展示检查摘要并等待用户确认"""
|
|
92
|
+
print(f"\n发布版本: {version}")
|
|
93
|
+
print()
|
|
94
|
+
print("检查结果:")
|
|
95
|
+
print(" ✓ 预检查全部通过")
|
|
96
|
+
print()
|
|
97
|
+
print("Release Notes 预览:")
|
|
98
|
+
print(notes or "(空)")
|
|
99
|
+
print()
|
|
100
|
+
|
|
101
|
+
if yes:
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
response = input("确认发布? (y/N): ").strip().lower()
|
|
106
|
+
return response in ("y", "yes")
|
|
107
|
+
except (EOFError, KeyboardInterrupt):
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_tag(version: str) -> bool:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["git", "tag", version],
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
)
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
print(f"创建标签失败: {result.stderr.strip()}")
|
|
119
|
+
return False
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def push_tag(version: str) -> bool:
|
|
124
|
+
result = subprocess.run(
|
|
125
|
+
["git", "push", "origin", version],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
)
|
|
129
|
+
if result.returncode != 0:
|
|
130
|
+
print(f"推送标签失败: {result.stderr.strip()}")
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def create_release(version: str, notes: str, repo: str) -> bool:
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
[
|
|
138
|
+
"gh",
|
|
139
|
+
"release",
|
|
140
|
+
"create",
|
|
141
|
+
version,
|
|
142
|
+
"--title",
|
|
143
|
+
version,
|
|
144
|
+
"--notes",
|
|
145
|
+
notes,
|
|
146
|
+
"--repo",
|
|
147
|
+
repo,
|
|
148
|
+
],
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
)
|
|
152
|
+
if result.returncode != 0:
|
|
153
|
+
print(f"创建 Release 失败: {result.stderr.strip()}")
|
|
154
|
+
return False
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def verify_release(version: str, repo: str) -> bool:
|
|
159
|
+
"""🤖 规则:发布后验证"""
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
["gh", "release", "view", version, "--repo", repo],
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
)
|
|
165
|
+
if result.returncode != 0:
|
|
166
|
+
print(f"⚠ 验证 Release 失败: {result.stderr.strip()}")
|
|
167
|
+
return False
|
|
168
|
+
print(result.stdout.strip())
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def rollback_tag(version: str) -> None:
|
|
173
|
+
"""🧠 AI 介入点:标签已推送但后续失败时回滚"""
|
|
174
|
+
subprocess.run(["git", "tag", "-d", version], capture_output=True)
|
|
175
|
+
subprocess.run(
|
|
176
|
+
["git", "push", "origin", "--delete", version],
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
)
|
|
180
|
+
print(f"↻ 标签 {version} 已回滚")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run(
|
|
184
|
+
version: str,
|
|
185
|
+
changelog: Optional[Path] = None,
|
|
186
|
+
dry_run: bool = False,
|
|
187
|
+
tag_only: bool = False,
|
|
188
|
+
release_only: bool = False,
|
|
189
|
+
yes: bool = False,
|
|
190
|
+
):
|
|
191
|
+
changelog = changelog or Path.cwd() / "CHANGELOG.md"
|
|
192
|
+
|
|
193
|
+
if tag_only and release_only:
|
|
194
|
+
print("错误: --tag-only 和 --release-only 不能同时使用")
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
# --- 预检查 ---
|
|
198
|
+
errors = precheck(version, changelog, release_only=release_only)
|
|
199
|
+
if errors:
|
|
200
|
+
print("预检查失败:")
|
|
201
|
+
for err in errors:
|
|
202
|
+
print(f" ✗ {err}")
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
notes = extract_notes(version, changelog)
|
|
206
|
+
print(f"\n=== Release Notes 预览 ===")
|
|
207
|
+
print(notes or "(空)")
|
|
208
|
+
print("=========================\n")
|
|
209
|
+
|
|
210
|
+
if dry_run:
|
|
211
|
+
print("✓ 预检查通过 (dry-run 模式,不执行)")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
# --- 发布前确认 ---
|
|
215
|
+
if not confirm_release(version, notes, yes=yes):
|
|
216
|
+
print("已取消发布")
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
# --- 执行发布 ---
|
|
220
|
+
tag_created = False
|
|
221
|
+
|
|
222
|
+
if not release_only:
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
["git", "tag", "-l"],
|
|
225
|
+
capture_output=True, text=True,
|
|
226
|
+
)
|
|
227
|
+
tag_exists = version in result.stdout.strip().split("\n")
|
|
228
|
+
if tag_exists:
|
|
229
|
+
print(f"→ 标签 {version} 已存在,跳过 tag 创建")
|
|
230
|
+
else:
|
|
231
|
+
if not create_tag(version):
|
|
232
|
+
return 1
|
|
233
|
+
if not push_tag(version):
|
|
234
|
+
rollback_tag(version)
|
|
235
|
+
return 1
|
|
236
|
+
tag_created = True
|
|
237
|
+
print(f"✓ 标签 {version} 已创建并推送")
|
|
238
|
+
|
|
239
|
+
if not tag_only:
|
|
240
|
+
repo = get_remote_repo()
|
|
241
|
+
if not repo:
|
|
242
|
+
print("错误: 无法从 git remote 解析 GitHub 仓库")
|
|
243
|
+
if tag_created:
|
|
244
|
+
rollback_tag(version)
|
|
245
|
+
return 1
|
|
246
|
+
if not create_release(version, notes or "", repo):
|
|
247
|
+
if tag_created:
|
|
248
|
+
rollback_tag(version)
|
|
249
|
+
return 1
|
|
250
|
+
print(f"✓ GitHub Release {version} 已创建")
|
|
251
|
+
print(f" https://github.com/{repo}/releases/tag/{version}")
|
|
252
|
+
|
|
253
|
+
return 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools.packages.find]
|
|
6
|
+
include = ["app*"]
|
|
7
|
+
exclude = ["tests*"]
|
|
8
|
+
|
|
9
|
+
[project]
|
|
10
|
+
name = "qtcloud-devops-cli"
|
|
11
|
+
version = "0.1.0"
|
|
12
|
+
description = "DevOps CLI — 发布管理"
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"typer",
|
|
16
|
+
"pydantic-settings",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
qtcloud-devops = "app.cli:main"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=9.0.3",
|
|
25
|
+
"pytest-cov>=7.1.0",
|
|
26
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
app/__init__.py
|
|
4
|
+
app/cli.py
|
|
5
|
+
app/config.py
|
|
6
|
+
app/release.py
|
|
7
|
+
qtcloud_devops_cli.egg-info/PKG-INFO
|
|
8
|
+
qtcloud_devops_cli.egg-info/SOURCES.txt
|
|
9
|
+
qtcloud_devops_cli.egg-info/dependency_links.txt
|
|
10
|
+
qtcloud_devops_cli.egg-info/entry_points.txt
|
|
11
|
+
qtcloud_devops_cli.egg-info/requires.txt
|
|
12
|
+
qtcloud_devops_cli.egg-info/top_level.txt
|
|
13
|
+
tests/test_cli_commands.py
|
|
14
|
+
tests/test_config.py
|
|
15
|
+
tests/test_release.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
app
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
from app.cli import app
|
|
5
|
+
|
|
6
|
+
runner = CliRunner()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_help():
|
|
10
|
+
result = runner.invoke(app, ["--help"])
|
|
11
|
+
assert result.exit_code == 0
|
|
12
|
+
assert "release" in result.stdout
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_release_help():
|
|
16
|
+
result = runner.invoke(app, ["release", "--help"])
|
|
17
|
+
assert result.exit_code == 0
|
|
18
|
+
assert "--version" in result.stdout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_release_invalid_version():
|
|
22
|
+
result = runner.invoke(app, ["release", "--version", "invalid"])
|
|
23
|
+
assert result.exit_code != 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_release_dry_run(monkeypatch):
|
|
27
|
+
changelog = Path("/tmp/test_release_dry_run_CHANGELOG.md")
|
|
28
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
29
|
+
|
|
30
|
+
def mock_run(cmd, **kw):
|
|
31
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
32
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
33
|
+
return MagicMock(returncode=0, stdout="")
|
|
34
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_run)
|
|
35
|
+
result = runner.invoke(app, [
|
|
36
|
+
"release", "--version", "v0.1.0",
|
|
37
|
+
"--changelog", str(changelog), "--dry-run",
|
|
38
|
+
])
|
|
39
|
+
assert result.exit_code == 0
|
|
40
|
+
changelog.unlink()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_main_help(monkeypatch):
|
|
44
|
+
from app.cli import main
|
|
45
|
+
monkeypatch.setattr("sys.argv", ["app.cli", "--help"])
|
|
46
|
+
try:
|
|
47
|
+
main()
|
|
48
|
+
except SystemExit as e:
|
|
49
|
+
assert e.code == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_main_module_entry():
|
|
53
|
+
import subprocess
|
|
54
|
+
import sys
|
|
55
|
+
root = Path(__file__).resolve().parent.parent
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[sys.executable, "-m", "app.cli", "--help"],
|
|
58
|
+
capture_output=True, text=True,
|
|
59
|
+
cwd=str(root),
|
|
60
|
+
)
|
|
61
|
+
assert result.returncode == 0
|
|
62
|
+
assert "release" in result.stdout
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
from app.config import Settings, settings
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_settings_is_basemodel():
|
|
6
|
+
assert isinstance(settings, BaseSettings)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_settings_env_prefix(monkeypatch):
|
|
10
|
+
monkeypatch.setenv("QTCLOUD_DEVOPS_FOO", "bar")
|
|
11
|
+
s = Settings()
|
|
12
|
+
assert hasattr(s, "model_config")
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from app.release import (
|
|
5
|
+
precheck, extract_notes, confirm_release,
|
|
6
|
+
create_tag, push_tag, create_release, verify_release,
|
|
7
|
+
rollback_tag, run, get_remote_repo,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def mock_subprocess(commands, default=None):
|
|
12
|
+
if default is None:
|
|
13
|
+
default = MagicMock(returncode=0, stdout="")
|
|
14
|
+
def _mock(cmd, **kw):
|
|
15
|
+
key = tuple(cmd)
|
|
16
|
+
if key in commands:
|
|
17
|
+
return commands[key]
|
|
18
|
+
return default
|
|
19
|
+
return _mock
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def git_precheck_ok():
|
|
23
|
+
return mock_subprocess({
|
|
24
|
+
("git", "tag", "-l"): MagicMock(stdout=""),
|
|
25
|
+
("git", "status", "--porcelain"): MagicMock(stdout=""),
|
|
26
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_precheck_invalid_version():
|
|
31
|
+
errors = precheck("invalid", Path("CHANGELOG.md"))
|
|
32
|
+
assert any("版本号格式错误" in e for e in errors)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_precheck_changelog_not_found():
|
|
36
|
+
errors = precheck("v0.1.0", Path("/nonexistent/CHANGELOG.md"))
|
|
37
|
+
assert any("不存在" in e for e in errors)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_precheck_default_does_not_check_tag_exists(monkeypatch):
|
|
41
|
+
"""默认模式不检查标签是否存在"""
|
|
42
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
43
|
+
("git", "status", "--porcelain"): MagicMock(stdout=""),
|
|
44
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
|
|
45
|
+
}, default=MagicMock(returncode=0, stdout="")))
|
|
46
|
+
errors = precheck("v0.1.0", Path("/tmp/CHANGELOG.md"))
|
|
47
|
+
assert all("标签" not in e for e in errors)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_precheck_dirty_workspace(monkeypatch):
|
|
51
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
52
|
+
("git", "tag", "-l"): MagicMock(stdout=""),
|
|
53
|
+
("git", "status", "--porcelain"): MagicMock(stdout=" M CHANGELOG.md\n"),
|
|
54
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
|
|
55
|
+
}))
|
|
56
|
+
errors = precheck("v0.2.0", Path("/tmp/CHANGELOG.md"))
|
|
57
|
+
assert any("工作区有未提交的变更" in e for e in errors)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_precheck_wrong_branch(monkeypatch):
|
|
61
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
62
|
+
("git", "tag", "-l"): MagicMock(stdout=""),
|
|
63
|
+
("git", "status", "--porcelain"): MagicMock(stdout=""),
|
|
64
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="feature/foo\n"),
|
|
65
|
+
}))
|
|
66
|
+
errors = precheck("v0.2.0", Path("/tmp/CHANGELOG.md"))
|
|
67
|
+
assert any("不在可发布分支上" in e for e in errors)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_precheck_no_changelog_content(monkeypatch):
|
|
71
|
+
monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
|
|
72
|
+
changelog = Path("/tmp/test_precheck_no_content.md")
|
|
73
|
+
changelog.write_text("# CHANGELOG\n\n## [0.2.0]\n\n内容\n")
|
|
74
|
+
errors = precheck("v9.9.9", changelog)
|
|
75
|
+
assert any("未找到" in e for e in errors)
|
|
76
|
+
changelog.unlink()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_precheck_all_pass(monkeypatch):
|
|
80
|
+
monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
|
|
81
|
+
changelog = Path("/tmp/test_precheck_pass.md")
|
|
82
|
+
changelog.write_text("# CHANGELOG\n\n## [0.2.0]\n\n内容\n")
|
|
83
|
+
errors = precheck("v0.2.0", changelog)
|
|
84
|
+
assert len(errors) == 0
|
|
85
|
+
changelog.unlink()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_extract_notes():
|
|
89
|
+
changelog = Path("/tmp/test_changelog.md")
|
|
90
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0] - 2026-01-01\n\n初始版本。\n\n### Added\n\n- 功能 A\n- 功能 B\n")
|
|
91
|
+
notes = extract_notes("v0.1.0", changelog)
|
|
92
|
+
assert "功能 A" in notes
|
|
93
|
+
assert "功能 B" in notes
|
|
94
|
+
changelog.unlink()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_extract_notes_multiple_sections():
|
|
98
|
+
changelog = Path("/tmp/test_changelog_multi.md")
|
|
99
|
+
changelog.write_text("# CHANGELOG\n\n## [0.2.0] - 2026-02-01\n\n版本 0.2.0 内容。\n\n## [0.1.0] - 2026-01-01\n\n初始版本。\n\n### Added\n\n- 功能 A\n")
|
|
100
|
+
notes = extract_notes("v0.2.0", changelog)
|
|
101
|
+
assert "版本 0.2.0" in notes
|
|
102
|
+
assert "功能 A" not in notes
|
|
103
|
+
changelog.unlink()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_extract_notes_not_found():
|
|
107
|
+
changelog = Path("/tmp/test_changelog_empty.md")
|
|
108
|
+
changelog.write_text("# CHANGELOG\n\n## [0.2.0]\n\n其他版本\n")
|
|
109
|
+
notes = extract_notes("v9.9.9", changelog)
|
|
110
|
+
assert notes is None
|
|
111
|
+
changelog.unlink()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_confirm_release_yes_flag():
|
|
115
|
+
assert confirm_release("v0.1.0", "some notes", yes=True) is True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_confirm_release_no_input(monkeypatch):
|
|
119
|
+
monkeypatch.setattr("builtins.input", lambda _: "n")
|
|
120
|
+
assert confirm_release("v0.1.0", "some notes") is False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_confirm_release_yes_input(monkeypatch):
|
|
124
|
+
monkeypatch.setattr("builtins.input", lambda _: "y")
|
|
125
|
+
assert confirm_release("v0.1.0", "some notes") is True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_confirm_release_eof(monkeypatch):
|
|
129
|
+
monkeypatch.setattr("builtins.input", lambda _: (_ for _ in ()).throw(EOFError()))
|
|
130
|
+
assert confirm_release("v0.1.0", "some notes") is False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_confirm_release_keyboard_interrupt(monkeypatch):
|
|
134
|
+
monkeypatch.setattr("builtins.input", lambda _: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
135
|
+
assert confirm_release("v0.1.0", "some notes") is False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_create_tag_success(monkeypatch):
|
|
139
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0))
|
|
140
|
+
assert create_tag("v0.1.0") is True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_create_tag_failure(monkeypatch):
|
|
144
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr="错误"))
|
|
145
|
+
assert create_tag("v0.1.0") is False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_push_tag_success(monkeypatch):
|
|
149
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0))
|
|
150
|
+
assert push_tag("v0.1.0") is True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_push_tag_failure(monkeypatch):
|
|
154
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr="错误"))
|
|
155
|
+
assert push_tag("v0.1.0") is False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_create_release_success(monkeypatch):
|
|
159
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0))
|
|
160
|
+
assert create_release("v0.1.0", "notes", "quanttide/repo") is True
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_create_release_failure(monkeypatch):
|
|
164
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr="错误"))
|
|
165
|
+
assert create_release("v0.1.0", "notes", "quanttide/repo") is False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_verify_release_success(monkeypatch):
|
|
169
|
+
mock = MagicMock(returncode=0, stdout="Release v0.1.0\nURL: ...")
|
|
170
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: mock)
|
|
171
|
+
assert verify_release("v0.1.0", "quanttide/repo") is True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_verify_release_failure(monkeypatch):
|
|
175
|
+
mock = MagicMock(returncode=1, stderr="not found")
|
|
176
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: mock)
|
|
177
|
+
assert verify_release("v0.1.0", "quanttide/repo") is False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_rollback_tag(monkeypatch):
|
|
181
|
+
calls = []
|
|
182
|
+
monkeypatch.setattr(
|
|
183
|
+
"app.release.subprocess.run",
|
|
184
|
+
lambda cmd, **kw: calls.append(cmd) or MagicMock(stdout=""),
|
|
185
|
+
)
|
|
186
|
+
rollback_tag("v0.1.0")
|
|
187
|
+
assert calls[0] == ["git", "tag", "-d", "v0.1.0"]
|
|
188
|
+
assert calls[1] == ["git", "push", "origin", "--delete", "v0.1.0"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_get_remote_repo_ssh(monkeypatch):
|
|
192
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(
|
|
193
|
+
returncode=0, stdout="git@github.com:quanttide/quanttide-platform.git\n"
|
|
194
|
+
))
|
|
195
|
+
assert get_remote_repo() == "quanttide/quanttide-platform"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_get_remote_repo_https(monkeypatch):
|
|
199
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(
|
|
200
|
+
returncode=0, stdout="https://github.com/quanttide/quanttide-platform\n"
|
|
201
|
+
))
|
|
202
|
+
assert get_remote_repo() == "quanttide/quanttide-platform"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_get_remote_repo_failure(monkeypatch):
|
|
206
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=1, stderr=""))
|
|
207
|
+
assert get_remote_repo() is None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_run_dry_run(monkeypatch):
|
|
211
|
+
monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
|
|
212
|
+
changelog = Path("/tmp/test_run_dry.md")
|
|
213
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
214
|
+
assert run("v0.1.0", changelog, dry_run=True) == 0
|
|
215
|
+
changelog.unlink()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_run_dry_run_with_errors():
|
|
219
|
+
assert run("invalid", Path("/nonexistent"), dry_run=True) == 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_run_cancelled(monkeypatch):
|
|
223
|
+
monkeypatch.setattr("app.release.subprocess.run", git_precheck_ok())
|
|
224
|
+
monkeypatch.setattr("builtins.input", lambda _: "n")
|
|
225
|
+
changelog = Path("/tmp/test_run_cancel.md")
|
|
226
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
227
|
+
assert run("v0.1.0", changelog) == 0
|
|
228
|
+
changelog.unlink()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_run_create_tag_failure(monkeypatch):
|
|
232
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
233
|
+
("git", "tag", "-l"): MagicMock(stdout=""),
|
|
234
|
+
("git", "tag", "v0.1.0"): MagicMock(returncode=1, stderr="错误"),
|
|
235
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(returncode=0, stdout="main\n"),
|
|
236
|
+
}, default=MagicMock(returncode=0, stdout="")))
|
|
237
|
+
changelog = Path("/tmp/test_run_tag_fail.md")
|
|
238
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
239
|
+
assert run("v0.1.0", changelog, yes=True) == 1
|
|
240
|
+
changelog.unlink()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_run_push_tag_failure_triggers_rollback(monkeypatch):
|
|
244
|
+
calls = []
|
|
245
|
+
def recorder(cmd, **kw):
|
|
246
|
+
calls.append(cmd)
|
|
247
|
+
if cmd == ["git", "tag", "-l"]:
|
|
248
|
+
return MagicMock(returncode=0, stdout="")
|
|
249
|
+
if cmd == ["git", "push", "origin", "v0.1.0"]:
|
|
250
|
+
return MagicMock(returncode=1, stderr="错误")
|
|
251
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
252
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
253
|
+
return MagicMock(returncode=0, stdout="")
|
|
254
|
+
|
|
255
|
+
monkeypatch.setattr("app.release.subprocess.run", recorder)
|
|
256
|
+
changelog = Path("/tmp/test_run_push_fail.md")
|
|
257
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
258
|
+
assert run("v0.1.0", changelog, yes=True) == 1
|
|
259
|
+
assert ["git", "tag", "-d", "v0.1.0"] in calls
|
|
260
|
+
assert ["git", "push", "origin", "--delete", "v0.1.0"] in calls
|
|
261
|
+
changelog.unlink()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_run_tag_only_explicit(monkeypatch):
|
|
265
|
+
"""--tag-only 显式声明,只打标签不发 release"""
|
|
266
|
+
changelog = Path("/tmp/test_run_tag_only_explicit.md")
|
|
267
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
268
|
+
|
|
269
|
+
calls = []
|
|
270
|
+
def recorder(cmd, **kw):
|
|
271
|
+
calls.append(cmd)
|
|
272
|
+
if cmd == ["git", "tag", "-l"]:
|
|
273
|
+
return MagicMock(returncode=0, stdout="")
|
|
274
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
275
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
276
|
+
return MagicMock(returncode=0, stdout="")
|
|
277
|
+
|
|
278
|
+
monkeypatch.setattr("app.release.subprocess.run", recorder)
|
|
279
|
+
assert run("v0.1.0", changelog, tag_only=True, yes=True) == 0
|
|
280
|
+
assert ["git", "tag", "v0.1.0"] in calls
|
|
281
|
+
assert not any("create" in c for c in calls)
|
|
282
|
+
changelog.unlink()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_run_tag_and_release_only_mutually_exclusive():
|
|
286
|
+
assert run("v0.1.0", Path("/tmp"), tag_only=True, release_only=True) == 1
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_run_default_creates_tag_and_release(monkeypatch):
|
|
290
|
+
"""默认行为:标签 + GitHub Release"""
|
|
291
|
+
changelog = Path("/tmp/test_run_default.md")
|
|
292
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
293
|
+
|
|
294
|
+
calls = []
|
|
295
|
+
def recorder(cmd, **kw):
|
|
296
|
+
calls.append(cmd)
|
|
297
|
+
if cmd == ["git", "tag", "-l"]:
|
|
298
|
+
return MagicMock(returncode=0, stdout="")
|
|
299
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
300
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
301
|
+
if cmd == ["git", "remote", "get-url", "origin"]:
|
|
302
|
+
return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
|
|
303
|
+
if "view" in str(cmd):
|
|
304
|
+
return MagicMock(returncode=0, stdout="Release v0.1.0\n")
|
|
305
|
+
return MagicMock(returncode=0, stdout="")
|
|
306
|
+
|
|
307
|
+
monkeypatch.setattr("app.release.subprocess.run", recorder)
|
|
308
|
+
assert run("v0.1.0", changelog, yes=True) == 0
|
|
309
|
+
assert ["git", "tag", "v0.1.0"] in calls
|
|
310
|
+
assert any("create" in c for c in calls)
|
|
311
|
+
changelog.unlink()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_run_default_skips_tag_if_exists(monkeypatch):
|
|
315
|
+
"""默认模式:标签已存在时跳过 tag 创建,继续发 release"""
|
|
316
|
+
changelog = Path("/tmp/test_run_default_tag_exists.md")
|
|
317
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
318
|
+
|
|
319
|
+
calls = []
|
|
320
|
+
def recorder(cmd, **kw):
|
|
321
|
+
calls.append(cmd)
|
|
322
|
+
if cmd == ["git", "tag", "-l"]:
|
|
323
|
+
return MagicMock(returncode=0, stdout="v0.1.0\n")
|
|
324
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
325
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
326
|
+
if cmd == ["git", "remote", "get-url", "origin"]:
|
|
327
|
+
return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
|
|
328
|
+
if "view" in str(cmd):
|
|
329
|
+
return MagicMock(returncode=0, stdout="Release v0.1.0\n")
|
|
330
|
+
return MagicMock(returncode=0, stdout="")
|
|
331
|
+
|
|
332
|
+
monkeypatch.setattr("app.release.subprocess.run", recorder)
|
|
333
|
+
assert run("v0.1.0", changelog, yes=True) == 0
|
|
334
|
+
assert ["git", "tag", "v0.1.0"] not in calls
|
|
335
|
+
assert any("create" in c for c in calls)
|
|
336
|
+
changelog.unlink()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_run_release_only(monkeypatch):
|
|
340
|
+
"""--release-only 只创建 GitHub Release,不打标签"""
|
|
341
|
+
changelog = Path("/tmp/test_run_release_only.md")
|
|
342
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
343
|
+
|
|
344
|
+
calls = []
|
|
345
|
+
def recorder(cmd, **kw):
|
|
346
|
+
calls.append(cmd)
|
|
347
|
+
if cmd == ["git", "tag", "-l"]:
|
|
348
|
+
return MagicMock(returncode=0, stdout="v0.1.0\n")
|
|
349
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
350
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
351
|
+
if cmd == ["git", "remote", "get-url", "origin"]:
|
|
352
|
+
return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
|
|
353
|
+
if "view" in str(cmd):
|
|
354
|
+
return MagicMock(returncode=0, stdout="Release v0.1.0\nURL: ...")
|
|
355
|
+
return MagicMock(returncode=0, stdout="")
|
|
356
|
+
|
|
357
|
+
monkeypatch.setattr("app.release.subprocess.run", recorder)
|
|
358
|
+
assert run("v0.1.0", changelog, release_only=True, yes=True) == 0
|
|
359
|
+
assert not any(cmd == ["git", "tag", "v0.1.0"] for cmd in calls)
|
|
360
|
+
assert any("create" in c for c in calls)
|
|
361
|
+
changelog.unlink()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_run_release_only_fails_without_remote(monkeypatch):
|
|
365
|
+
"""--release-only 无法解析 remote 时报错"""
|
|
366
|
+
changelog = Path("/tmp/test_run_rel_no_remote.md")
|
|
367
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
368
|
+
|
|
369
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
370
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(returncode=0, stdout="main\n"),
|
|
371
|
+
("git", "remote", "get-url", "origin"): MagicMock(returncode=1, stderr=""),
|
|
372
|
+
}, default=MagicMock(returncode=0, stdout="")))
|
|
373
|
+
|
|
374
|
+
assert run("v0.1.0", changelog, release_only=True, yes=True) == 1
|
|
375
|
+
changelog.unlink()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_run_release_only_requires_existing_tag(monkeypatch):
|
|
379
|
+
"""--release-only 要求标签已存在(预检查通过)"""
|
|
380
|
+
changelog = Path("/tmp/test_run_release_only_tag_exists.md")
|
|
381
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
382
|
+
|
|
383
|
+
calls = []
|
|
384
|
+
def recorder(cmd, **kw):
|
|
385
|
+
calls.append(cmd)
|
|
386
|
+
if cmd == ["git", "tag", "-l"]:
|
|
387
|
+
return MagicMock(returncode=0, stdout="v0.1.0\n")
|
|
388
|
+
if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]:
|
|
389
|
+
return MagicMock(returncode=0, stdout="main\n")
|
|
390
|
+
if cmd == ["git", "remote", "get-url", "origin"]:
|
|
391
|
+
return MagicMock(returncode=0, stdout="git@github.com:quanttide/repo.git\n")
|
|
392
|
+
if "view" in str(cmd):
|
|
393
|
+
return MagicMock(returncode=0, stdout="Release v0.1.0\n")
|
|
394
|
+
return MagicMock(returncode=0, stdout="")
|
|
395
|
+
|
|
396
|
+
monkeypatch.setattr("app.release.subprocess.run", recorder)
|
|
397
|
+
assert run("v0.1.0", changelog, release_only=True, yes=True) == 0
|
|
398
|
+
changelog.unlink()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_run_release_only_fails_if_tag_missing(monkeypatch):
|
|
402
|
+
"""--release-only 标签不存在时预检查拒绝"""
|
|
403
|
+
changelog = Path("/tmp/test_run_release_only_tag_missing.md")
|
|
404
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
405
|
+
|
|
406
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
407
|
+
("git", "tag", "-l"): MagicMock(stdout="v0.2.0\n"),
|
|
408
|
+
("git", "status", "--porcelain"): MagicMock(stdout=""),
|
|
409
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
|
|
410
|
+
}, default=MagicMock(returncode=0, stdout="")))
|
|
411
|
+
|
|
412
|
+
assert run("v0.1.0", changelog, release_only=True, yes=True) == 1
|
|
413
|
+
changelog.unlink()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_precheck_release_only_requires_tag(monkeypatch):
|
|
417
|
+
"""--release-only 时要求标签已存在"""
|
|
418
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
419
|
+
("git", "tag", "-l"): MagicMock(stdout="v0.1.0\n"),
|
|
420
|
+
("git", "status", "--porcelain"): MagicMock(stdout=""),
|
|
421
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
|
|
422
|
+
}))
|
|
423
|
+
changelog = Path("/tmp/test_precheck_release_only_tag_ok.md")
|
|
424
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
425
|
+
errors = precheck("v0.1.0", changelog, release_only=True)
|
|
426
|
+
assert len(errors) == 0
|
|
427
|
+
changelog.unlink()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_precheck_release_only_fails_if_tag_missing(monkeypatch):
|
|
431
|
+
"""--release-only 时标签不存在应报错"""
|
|
432
|
+
monkeypatch.setattr("app.release.subprocess.run", mock_subprocess({
|
|
433
|
+
("git", "tag", "-l"): MagicMock(stdout="v0.2.0\n"),
|
|
434
|
+
("git", "status", "--porcelain"): MagicMock(stdout=""),
|
|
435
|
+
("git", "rev-parse", "--abbrev-ref", "HEAD"): MagicMock(stdout="main\n"),
|
|
436
|
+
}))
|
|
437
|
+
changelog = Path("/tmp/test_precheck_release_only_tag_missing.md")
|
|
438
|
+
changelog.write_text("# CHANGELOG\n\n## [0.1.0]\n\n内容\n")
|
|
439
|
+
errors = precheck("v0.1.0", changelog, release_only=True)
|
|
440
|
+
assert any("标签不存在" in e for e in errors)
|
|
441
|
+
changelog.unlink()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_run_uses_default_changelog(monkeypatch):
|
|
445
|
+
monkeypatch.setattr("app.release.subprocess.run", lambda *a, **kw: MagicMock(returncode=0, stdout="main\n"))
|
|
446
|
+
assert run("v0.1.0", dry_run=True) == 1
|