Files
knowledge-governance/scripts/release_preflight.py
T
姚章浩 40d84994cb feat(ci): 版本记录检查(四节 + 正式 tag 一致性)+ 只读发版预检
check_changelog.py:正式版本条目必须四节齐备且非空(改了什么/兼容性与迁移影响/怎么验收/已知问题)、版本号三段无前导零、[Unreleased] 在最上面;新增 --tags 核对正式 tag(必须注释 tag)与它指到那个提交里的版本记录一致。只有 Unreleased 的新工程放行;没有正式 tag 不判红(开发版本不打 tag 是常态)。ci_checks.py:新增必需检查 version(门禁 15 项),策略版本 ci-checks/2。ci_checkout.sh:记录远端 tag 清单并取 tag 对象供核对。release_preflight.py:只读预检(版本建议/完整 SHA/CI 证据/CHANGELOG/远端 tag 与 Release/遗留项),写类 git 命令与 POST 在预检路径上被拒(--self-test 证明);批准后 --execute 才打注释 tag、推 tag、建 Release 并读回。CHANGELOG 0.1.0 补上原本缺的两节。
2026-10-12 17:57:43 +08:00

349 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""发版预检 —— **只读**,把"要不要发、发什么、有没有证据"一次列清楚。
设计上的三条硬规矩:
1. **预检不产生任何副作用**:只走白名单里的只读命令(`git ls-remote/rev-parse/log/show/…`、
HTTP GET)。`_git_read()` / `_api_read()` 会**拒绝**任何写操作 —— 这条有自检
(`--self-test` 断言"写命令与 POST 在预检路径上被拒")。
2. **正式 tag / Release 只在显式批准后执行**:`--execute --approve-version X.Y.Z --approve-sha <完整40位>`
三个都给齐、且预检没有阻塞项,才会 `git tag -a` + `git push <tag>` + 建 Release;
执行后**读回**(远端 tag SHA == 本地打的那个提交、Release 的 tag_name/version 一致)才算成功。
3. **拿不到证据就说拿不到**:没有凭据、查不到 CI 状态时打印"未核对",不写成"已通过"。
用法:
python3 scripts/release_preflight.py # 建议 patch(默认)
python3 scripts/release_preflight.py --bump minor
python3 scripts/release_preflight.py --version 0.2.0
python3 scripts/release_preflight.py --self-test # 只读保证的自检
# 批准后才执行(本轮不执行):
python3 scripts/release_preflight.py --execute --approve-version 0.2.0 --approve-sha <完整SHA>
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHANGELOG = ROOT / "CHANGELOG.md"
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
# 预检路径允许的只读 git 子命令(写命令在这里直接拒)
GIT_READ_OK = {"ls-remote", "rev-parse", "log", "show", "cat-file", "for-each-ref",
"status", "diff", "describe", "tag", "remote"}
class WriteRefused(RuntimeError):
pass
def _git_read(*args, cwd=None, check=False) -> tuple[int, str]:
"""只读 git 调用。**任何写子命令在这里就被拒**(预检不许有副作用)。"""
if args and args[0] not in GIT_READ_OK:
raise WriteRefused(f"预检路径不允许 git {args[0]}(只读白名单:{sorted(GIT_READ_OK)})")
if args and args[0] == "tag":
# 只有"列出来"的写法算只读;`git tag v1.2.3` / `-a` / `-d` 都是写 —— 一律拒
rest = list(args[1:])
if not rest or rest[0] not in ("-l", "--list", "--contains", "--points-at"):
raise WriteRefused(f"预检路径不允许打/改/删 tag:git {' '.join(args)}")
p = subprocess.run(["git", *args], cwd=cwd or ROOT, capture_output=True, text=True)
if check and p.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} 失败:{p.stderr[:200]}")
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
def _cred(host: str) -> str:
"""从 ~/.git-credentials 取凭据(**只返回,不打印**)。取不到就返回空串。"""
f = Path.home() / ".git-credentials"
if not f.is_file():
return ""
host_only = (host or "").split(":")[0]
for line in f.read_text(encoding="utf-8").splitlines():
# ⚠️ LAN 主机的凭据在文件里是 **URL 编码**的(`192.168.50.223%3a3000`)——
# 先 unquote 再比;按字面匹配的症状是"没有凭据",会把人引向网络/权限方向。
u = urllib.parse.urlparse(urllib.parse.unquote(line.strip()))
if host_only and u.hostname == host_only:
return urllib.parse.unquote(u.password or "")
return ""
def _api_read(base: str, path: str, token: str, method: str = "GET") -> tuple[int, dict]:
"""只读 API。**非 GET 在这里就被拒**。"""
if method != "GET":
raise WriteRefused(f"预检路径不允许 {method} {path}(只读)")
req = urllib.request.Request(f"{base}/api/v1{path}", method="GET",
headers={"Authorization": "token " + token})
try:
with urllib.request.urlopen(req, timeout=25) as x:
return x.status, json.loads(x.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:200]}
except (urllib.error.URLError, OSError) as e:
return 0, {"_error": str(e)[:200]}
# ── 解析 ────────────────────────────────────────────────────────────────
def released_versions(text: str) -> list[str]:
return [m.group(1) for m in re.finditer(r"^##\s*\[(\d+\.\d+\.\d+)\]", text, re.MULTILINE)]
def block_of(text: str, heading: str) -> str:
m = re.search(rf"^##\s*\[{re.escape(heading)}\][^\n]*$", text, re.MULTILINE)
if not m:
return ""
nxt = re.search(r"^##\s*\[", text[m.end():], re.MULTILINE)
end = m.end() + (nxt.start() if nxt else len(text) - m.end())
return text[m.start():end].rstrip()
def section(text: str, sec: str) -> str:
if sec not in text:
return ""
tail = text.split(sec, 1)[1]
return re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0].strip()
def bump(version: str, kind: str) -> str:
a, b, c = (int(x) for x in version.split("."))
if kind == "major":
return f"{a + 1}.0.0"
if kind == "minor":
return f"{a}.{b + 1}.0"
return f"{a}.{b}.{c + 1}"
def repo_slug() -> str:
rc, out = _git_read("remote", "get-url", "origin")
if rc != 0:
return ""
m = re.search(r"[:/]([^/]+/[^/]+?)(?:\.git)?$", out.strip())
return m.group(1) if m else ""
def server_base() -> str:
rc, out = _git_read("remote", "get-url", "origin")
if rc != 0:
return ""
u = urllib.parse.urlparse(out.strip())
if not u.scheme:
return ""
return f"{u.scheme}://{u.netloc}"
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--bump", default="patch", choices=["major", "minor", "patch"])
ap.add_argument("--version", default="")
ap.add_argument("--sha", default="")
ap.add_argument("--execute", action="store_true")
ap.add_argument("--approve-version", default="")
ap.add_argument("--approve-sha", default="")
ap.add_argument("--no-api", action="store_true", help="不查远端(没有凭据时的显式选择)")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
text = CHANGELOG.read_text(encoding="utf-8") if CHANGELOG.is_file() else ""
rel = released_versions(text)
if not rel:
print("❌ CHANGELOG.md 里没有已发布版本 ⇒ 没有版本基线,发不了版", file=sys.stderr)
return 2
current = rel[0]
candidate = args.version or bump(current, args.bump)
if not SEMVER.match(candidate):
print(f"❌ 候选版本号不合法:{candidate!r}(要 主.次.修订 三段十进制、无前导零)", file=sys.stderr)
return 2
rc, head = _git_read("rev-parse", "HEAD")
head = head.strip() if rc == 0 else ""
rc, branch = _git_read("rev-parse", "--abbrev-ref", "HEAD")
branch = branch.strip() if rc == 0 else ""
want_sha = args.sha or head
rc, dirty = _git_read("status", "--porcelain")
dirty_lines = [l for l in dirty.splitlines() if l.strip() and not l.startswith("?? .ci-")]
slug, base = repo_slug(), server_base()
print("── 发布预检(只读:不会打 tag、不会建 Release、不会推送)──")
print(f" 仓库 : {slug or '(取不到 origin)'}")
print(f" 分支/提交 : {branch} @ {head or '(取不到 HEAD)'}"
f"{'' if FULL_SHA.match(head) else ' ⚠️ 不是完整 40 位'}")
print(f" 工作区 : {'干净' if not dirty_lines else f'有 {len(dirty_lines)} 处未提交 —— 发版要在干净树上看'}")
print(f" 当前已发布 : {current}")
print(f" 待发布版本 : {candidate}({'显式 --version' if args.version else f'--bump {args.bump} 的建议值;'
f'可选 major/minor/patch'})")
# CHANGELOG 内容
unreleased = block_of(text, "Unreleased")
print("\n CHANGELOG(Unreleased 原样打印):")
print(" " + (unreleased.replace("\n", "\n ")[:1500] if unreleased.strip() else "(没有 Unreleased 段:"
"正式发版前要把这次改动写进去)"))
known = section(block_of(text, current), "### 已知问题") if current in rel else ""
print(f"\n 当前版本的『已知问题』: {known.splitlines()[0] if known else '(没有写)'}")
# 检查器(结构与 tag 一致性)
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"), "--tags"],
cwd=ROOT, capture_output=True, text=True)
checker_ok = chk.returncode == 0
print(f"\n CHANGELOG/版本记录检查: {'✅ 通过' if checker_ok else '❌ 不通过 —— 先修它再发版'}")
if not checker_ok:
print(" " + (chk.stdout + chk.stderr).strip().replace("\n", "\n ")[-800:])
# 远端 tag / Release / CI 证据
tag = f"v{candidate}"
blockers: list[str] = []
if dirty_lines:
blockers.append(f"工作区有 {len(dirty_lines)} 处未提交")
if not FULL_SHA.match(head):
blockers.append("HEAD 不是完整 40 位 SHA")
if want_sha != head:
blockers.append(f"--sha {want_sha[:12]} != HEAD {head[:12]}(发版必须指向准确提交)")
if not checker_ok:
blockers.append("CHANGELOG/版本记录检查不通过")
if not unreleased.strip():
blockers.append("没有 Unreleased 段:发版前要把这次改动写进 CHANGELOG")
if candidate in rel:
blockers.append(f"版本 {candidate} 已经在 CHANGELOG 里出现过(版本号只增不减)")
token = "" if args.no_api else _cred(urllib.parse.urlparse(base).hostname or "")
rc, tags_out = _git_read("ls-remote", "--tags", "origin")
remote_tags = [l.split("refs/tags/")[-1] for l in tags_out.splitlines() if "refs/tags/" in l]
if f"{tag}^{{}}" in remote_tags:
remote_tags.remove(f"{tag}^{{}}")
print(f"\n 远端 tag : {tag} {'已存在 ⛔ 不能覆盖已发布的 tag' if tag in remote_tags else '不存在(可发)'}"
f"|已存在的正式 tag: {[t for t in remote_tags if re.match(r'^v\\d', t)] or '(无)'}")
run_line = "(未查询)"
ctx_line = "(未查询)"
if token and base and slug:
st, body = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token)
rel_exists = st == 200
print(f" Gitea Release: {tag} {'已存在 ⛔' if rel_exists else '不存在(可发)'}")
if rel_exists:
blockers.append(f"Gitea Release {tag} 已存在")
st, stt = _api_read(base, f"/repos/{slug}/commits/{head}/status", token)
if st == 200:
ctxs = [(s.get("context"), s.get("status")) for s in stt.get("statuses") or []]
ctx_line = "、".join(f"{c}={v}" for c, v in ctxs) or "(这个提交上一个状态都没有)"
if not any(v == "success" for _, v in ctxs):
blockers.append(f"提交 {head[:12]} 上没有成功的 status context(CI 证据不足)")
else:
ctx_line = f"(查不到:HTTP {st})"
blockers.append("查不到该提交的 CI 状态")
st, runs = _api_read(base, f"/repos/{slug}/actions/runs?limit=20", token)
if st == 200:
mine = [r for r in (runs.get("workflow_runs") or [])
if (r.get("head_sha") or "") == head]
if mine:
r0 = mine[0]
run_line = (f"run #{r0.get('run_number')} {r0.get('status')} "
f"{r0.get('url') or ''}".strip())
else:
run_line = "(这个提交上没有运行记录)"
else:
print(f" Gitea Release: **未核对**({'显式 --no-api' if args.no_api else '没有取到凭据'})")
blockers.append("没有核对远端 Release/CI 状态(缺凭据)")
print(f" CI 状态 : {ctx_line}")
print(f" 最近运行 : {run_line}")
print(" 遗留风险/未验证: 见 CHANGELOG 的『已知问题』与本次 Unreleased 里写明的未验证项"
"(预检不替你判断它们的可接受性)")
print("\n── 结论 ──")
if blockers:
for b in blockers:
print(f" ⛔ {b}")
print(f" ⇒ 现在**不能**发 {candidate}")
else:
print(f" ✅ 预检通过:可以发 {candidate}(tag {tag} → 提交 {head[:12]})")
print(" 批准后执行(本轮不执行):")
print(f" python3 scripts/release_preflight.py --execute --version {candidate} "
f"--approve-version {candidate} --approve-sha {head}")
if not args.execute:
return 1 if blockers else 0
# ── 显式批准后的执行路径(本轮不跑)────────────────────────────────
if blockers:
print("⛔ 预检有阻塞项,**不执行**打 tag/建 Release", file=sys.stderr)
return 1
if args.approve_version != candidate or args.approve_sha != head:
print("⛔ 批准参数与预检结果不符(--approve-version/--approve-sha 必须逐字符等于预检结果)—— 不执行",
file=sys.stderr)
return 1
print(f"\n── 执行(已获批准)──\n git tag -a {tag} -m 'release {candidate}' → push → 建 Release → 读回")
p = subprocess.run(["git", "tag", "-a", tag, "-m", f"release {candidate}"], cwd=ROOT,
capture_output=True, text=True)
if p.returncode:
print(f"❌ 打 tag 失败:{p.stderr[:200]}", file=sys.stderr)
return 1
p = subprocess.run(["git", "push", "origin", tag], cwd=ROOT, capture_output=True, text=True)
if p.returncode:
print(f"❌ 推送 tag 失败(**不建 Release**):{p.stderr[:200]}", file=sys.stderr)
return 1
rc, back = _git_read("ls-remote", "--tags", "origin", tag)
back_sha = (back.split()[0] if back.strip() else "").strip()
rc2, tagged = _git_read("rev-parse", f"{tag}^{{commit}}")
ok_tag = bool(back_sha) and tagged.strip() == head
print(f" 读回远端 {tag} = {back_sha[:12] or '(空)'}|本地 tag 指向 {tagged.strip()[:12]}"
f" ⇒ {'✅ 一致' if ok_tag else '❌ 不一致'}")
if not ok_tag:
return 1
body = json.dumps({"tag_name": tag, "name": tag, "body": block_of(text, current) or unreleased,
"draft": False, "prerelease": False}).encode()
req = urllib.request.Request(f"{base}/api/v1/repos/{slug}/releases", data=body, method="POST",
headers={"Authorization": "token " + token,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as x:
created = json.loads(x.read().decode() or "{}")
except (urllib.error.URLError, OSError) as e:
print(f"❌ 建 Release 失败(tag 已经推上去了,别重复推):{e}", file=sys.stderr)
return 1
st, got = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token)
same = st == 200 and (got.get("tag_name") == tag)
print(f" Release 读回:HTTP {st} tag_name={got.get('tag_name')!r} id={got.get('id')}"
f" ⇒ {'✅ 一致' if same else '❌ 不一致'}")
print(f" (说明来自 CHANGELOG,不维护第二份正文;tag 不可移动、不可覆盖)")
return 0 if same else 1
def self_test() -> int:
"""预检的**只读保证**要能被证明:写命令与 POST 必须在预检路径上被拒。"""
problems: list[str] = []
for fn, arg in ((_git_read, "push"), (_git_read, "commit"), (_git_read, "tag")):
try:
fn(arg, "x")
problems.append(f"预检竟然允许 git {arg}")
except WriteRefused:
pass
try:
_git_read("tag", "-a", "v9.9.9", "-m", "x")
problems.append("预检竟然允许打 tag(-a)")
except WriteRefused:
pass
for method in ("POST", "PUT", "DELETE", "PATCH"):
try:
_api_read("http://127.0.0.1:1", "/x", "t", method)
problems.append(f"预检竟然允许 {method}")
except WriteRefused:
pass
# 版本号推进
if bump("1.2.3", "patch") != "1.2.4" or bump("1.2.3", "minor") != "1.3.0" or bump("1.2.3", "major") != "2.0.0":
problems.append("版本推进算错了")
if problems:
print("❌ 预检自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))