Files
knowledge-governance/scripts/release_preflight.py
T
姚章浩 4f8ee0a8c5 feat(version): 变更记录改固定四类,破坏性变化带发布文档链接
按项目方给的规则重做 CHANGELOG 与检查器(本轮不推、不打 tag、不部署):

- 条目形状:四个自定义必填节(改了什么/兼容性与迁移影响/怎么验收/已知问题)
  → 固定四类 Added/Changed/Fixed/Security;分类可省略,**不许空栏目**;每个版本块
  至少一个非空分类;`[Unreleased]` 段不得写日期。
- 破坏性变化:固定写在 Changed 里、以 `**破坏性**` 开头,**必须链接发布文档**;
  仓内 `docs/….md` 链接**对账**(不存在 ⇒ 红)。写在别的分类里也判红。
- 结构判据补强:非法日期、重复版本、顺序必须严格递减、前导零;围栏代码块里的
  `##`/`###` 不算标题;**块正文以任意二级标题为界**(「## 能力基线(未发布)」这类
  不带方括号的标题原先不是边界,会把后文算成上一节的内容 ⇒ 空栏目被兜成"有内容",
  夹具真的抓到了这个假绿)。
- tag 一致性:远端专属清单 `.ci-remote-tags` → `ls-remote` → 本地 refs 三级来源;
  **没核对远端就不许说校验成功**(CI/预检用 `--require-remote`,拿不到清单即红),
  非正式 tag(如本地 `x`)不当作发布版本、不判红,没有 tag 不判红。
- `0.1.0` 定为**未发布的能力基线**(远端无 tag、无 Release):改成不带方括号的
  「## 能力基线(未发布)」,不补造 tag/发布日期/发布事实。
- 过程记录迁出:审查轮次与命令输出 → `docs/archive/CI_接入留档.md`(原文保留);
  未合并事项 → `docs/BACKLOG.md`;补 `docs/RELEASE_RUNBOOK.md`(破坏性变化的链接目标,
  同时修掉 CHANGELOG 里指向不存在文档的悬空引用)。
- 发版预检:无版本基线时把「首个正式版本号由人指定」列成阻塞项(不再要求先有基线才能跑);
  打印破坏性变化条数;远端 tag 查不到时明说未核对并阻塞;不再硬读已删除的『已知问题』节。
- traps trap-5 口径修正:不是"不用 tag",而是"构建时不要用 git describe 取版本号"。

验证:check_changelog --self-test 全过;反仓夹具 80/0(含五条真改文件的结构负例、
六条只在临时仓库里造 tag 的场景);make verify 15/15 success。
2026-10-13 14:59:35 +08:00

369 lines
18 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 ""
# 下一个二级标题就停(`### x` 不算:`^##\s` 不匹配三井号)。
# ⚠️ 不能只认 `## [`:像「## 能力基线(未发布)」这种没有方括号的二级标题会把整段吞进来。
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)
current = rel[0] if rel else None
first_release = not rel
# 还没有正式发布过时:没有可 --bump 的基线,版本号必须由人显式指定 ——
# 这里只给一个建议值,并把它当成阻塞项(不假装有基线)。
candidate = args.version or (bump(current, args.bump) if current else "1.0.0")
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 if current else '(还没有正式发布过 —— CHANGELOG 里没有已发布版本条目)'}")
if first_release:
print(f" 待发布版本 : {candidate}(**建议值**:本仓还没有正式版本基线,"
f"首个正式版本号由你指定,用 `--version X.Y.Z` 覆盖)")
else:
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 段:"
"正式发版前要把这次改动写进去)"))
breaking = [l.strip() for l in text.splitlines()
if l.strip().startswith(("- **破坏性**", "* **破坏性**"))]
print(f"\n 破坏性变化 : {len(breaking)} 条(写在 `### Changed` 里、必须带发布文档链接,"
f"写清升级/迁移与回退)")
for b in breaking[:5]:
print(f" · {b[:150]}")
print(" 遗留风险 : 见 docs/BACKLOG.md 与 Unreleased 里写明的未验证项"
"(预检不替你判断它们的可接受性)")
# 检查器(结构与 tag 一致性)
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"),
"--tags", "--require-remote"], 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 first_release:
blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);"
f"{candidate} 只是建议值")
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}^{{}}")
if rc != 0 or args.no_api:
why = "显式 --no-api" if args.no_api else "`git ls-remote --tags origin` 失败"
print(f"\n 远端 tag : **未核对**({why})—— 不能声称 tag 已核验")
blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发")
else:
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(" 遗留风险/未验证: 见 docs/BACKLOG.md 与本次 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:]))