qtcloud-devops-cli 0.1.0__py3-none-any.whl

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.
app/__init__.py ADDED
File without changes
app/cli.py ADDED
@@ -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())
app/config.py ADDED
@@ -0,0 +1,8 @@
1
+ from pydantic_settings import BaseSettings
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = {"env_prefix": "QTCLOUD_DEVOPS_"}
6
+
7
+
8
+ settings = Settings()
app/release.py ADDED
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: qtcloud-devops-cli
3
+ Version: 0.1.0
4
+ Summary: DevOps CLI — 发布管理
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: typer
7
+ Requires-Dist: pydantic-settings
@@ -0,0 +1,9 @@
1
+ app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ app/cli.py,sha256=AuVlQxk-PhynHVABzMrOhPV3mvi4WYicDo31CZ4UYK8,1956
3
+ app/config.py,sha256=cIxqxLjbvr3lfQ1hnGUAb3e6yA4EfgLJCPfOry9Wbpk,152
4
+ app/release.py,sha256=03lBPKdhEIhwEzwtm-Fdvl5HnABPQ8ePlKExAB0gUxQ,7272
5
+ qtcloud_devops_cli-0.1.0.dist-info/METADATA,sha256=0lb_Da6p8qKJ_Wd0RG3E7dg6WNXvWgT0GaoSfJBu9ts,177
6
+ qtcloud_devops_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ qtcloud_devops_cli-0.1.0.dist-info/entry_points.txt,sha256=PGuY7dCz6wVxBm4fHmYM9QCBojaXfiWbr3xgc4VXQS8,48
8
+ qtcloud_devops_cli-0.1.0.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
9
+ qtcloud_devops_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ qtcloud-devops = app.cli:main
@@ -0,0 +1 @@
1
+ app