#!/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 ` + 建 Release; 执行后**读回**(远端 tag SHA == 本地打的那个提交、Release 的 tag_name/version 一致)才算成功。 3. **拿不到证据就说拿不到**:没有凭据、查不到 CI 状态时打印"未核对",不写成"已通过"。 4. **输出分两段**:`准备建议`(还差什么)与 `发布就绪判定`(是否就绪);**就绪才算可发**。 5. **就绪 = 证据齐**:候选版本**已写进目标提交**、完整 SHA 指向准确提交、 **必需 context 集合全部 success**(集合来自 `.gitea/required-contexts.txt`,缺项/待定/失败/查询异常都阻断)、 远端 tag 已核对且未占用、**Release 查询只有 HTTP 404 才算「不存在」**。 6. **本预检不执行、也不推荐自动发布路径**:正式发版动作由人按 `docs/RELEASE_RUNBOOK.md` 执行。 用法: 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 # 只读保证 + 就绪判定的自检 # ⚠️ 自动发布路径已禁用:--execute 会被明确拒绝(rc=2,零写操作) """ 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}$") RELEASE_HEADING = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE) SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") SIGNUM = re.compile(r"^\d+\.\d+\.\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}" REQUIRED_CONTEXTS_FILE = ROOT / ".gitea" / "required-contexts.txt" def required_contexts(root: Path = ROOT) -> tuple[list[str], str]: """明确的必需 context 集合(一行一个,`#` 之后是注释)。 ⚠️ 这份文件是"要求哪些检查"的**唯一来源**:缺失或只有注释 ⇒ **没有策略** ⇒ 就绪判定直接阻断(不许拿"体检时观察到哪些状态"顶替)。 """ f = root / ".gitea" / "required-contexts.txt" if not f.is_file(): return [], f"没有 {f.name}" out = [l.split("#", 1)[0].strip() for l in f.read_text(encoding="utf-8").splitlines()] out = [x for x in out if x] return out, (f"{f.name}({len(out)} 条)" if out else f"{f.name} 只有注释 / 是空的") def top_version_in_commit(sha: str, *, root: Path = ROOT) -> str | None: """那个提交里 CHANGELOG.md 的**顶部**已发布版本;没有已发布条目 ⇒ None。""" rc, blob = _git_read("show", f"{sha}:CHANGELOG.md", cwd=root) if rc != 0: return None m = RELEASE_HEADING.search(blob) return m.group(1) if m else None def version_in_commit(sha: str, version: str, *, root: Path = ROOT) -> bool: """候选版本是否**已是那个提交里的顶部已发布版本**(查该提交里的 CHANGELOG.md,不看工作区)。 ⚠️ 必须比"**顶部**那条":候选版本在历史里出现过(比如旧版本条目还列在下面)**不算就绪** —— 那说明这次发版的记录还没写进目标提交。也正因如此,比较顶部同时隐含了 "最新在最上面 / 版本只增不减" 的排序要求。 """ return top_version_in_commit(sha, root=root) == version def release_state(status: int) -> str: """Release 查询结果的语义:**只有 HTTP 404 代表「不存在」**,其余非 200 一律算查询异常。""" if status == 200: return "exists" if status == 404: return "absent" return "error" def current_dev_number() -> tuple[str | None, int | None]: """当前开发号(`<主>.<次>.<自 tag 的提交数>`)与提交数;取不到 ⇒ (None, None)。 直接复用 `scripts/version.py` 的身份计算(同一份规则,不另写一套)。 """ try: sys.path.insert(0, str(ROOT / "scripts")) import version as ver # type: ignore # noqa: PLC0415 ident = ver.build_identity()[0] n = ident.get("commits_since_release") dev = ident.get("display_version") if ident.get("build_kind") == "development" and dev and SIGNUM.match(str(dev)): return str(dev), (int(n) if isinstance(n, int) else None) return None, (int(n) if isinstance(n, int) else None) except Exception: return None, None def _ver_tuple(v: str) -> tuple[int, int, int]: a, b, c = (int(x) for x in v.split(".")) return a, b, c BREAK_ITEM = re.compile(r"^\s*(?:[-*+]|\d+[.)])?\s*\*\*破坏性\*\*") STABILITY_HEADING = re.compile(r"(?m)^##+\s*.*稳定契约承诺") # 「稳定契约承诺」必须逐项覆盖的对外契约面(缺项 / 留占位 ⇒ 不许发 1.0.0) STABILITY_ITEMS = ("接口", "数据模型", "配置", "CLI 输出与退出码") STABILITY_PLACEHOLDERS = ("待填", "TODO", "TBD", "xxx", "XXX") STABILITY_DOCS = ("docs/RELEASE_RUNBOOK.md", "docs/STABILITY_CONTRACT.md") def released_in_commit(sha: str, *, root: Path = ROOT) -> list[str]: """目标提交里 CHANGELOG 的已发布版本清单(顶部在前)。""" rc, text = _git_read("show", f"{sha}:CHANGELOG.md", cwd=root) return released_versions(text) if rc == 0 else [] def breaking_in_commit(sha: str, version: str, *, root: Path = ROOT) -> int: """**候选版本那一段**里的破坏性变化条数(发版事实看提交里的记录,不看工作区)。""" rc, text = _git_read("show", f"{sha}:CHANGELOG.md", cwd=root) if rc != 0: return 0 return sum(1 for line in block_of(text, version).splitlines() if BREAK_ITEM.match(line)) def bump_level(prev: str | None, cand: str) -> str | None: """抬了哪一档:major / minor / patch;比不出来(首发 / 号没涨)⇒ None。""" if not (prev and SEMVER.match(prev) and SEMVER.match(cand)): return None a, b = _ver_tuple(prev), _ver_tuple(cand) if b[0] > a[0]: return "major" if b[0] == a[0] and b[1] > a[1]: return "minor" if b[0] == a[0] and b[1] == a[1] and b[2] > a[2]: return "patch" return None def stability_contract_problems(*, root: Path = ROOT) -> list[str]: """《稳定契约承诺》是否写实(发 1.0.0 的前置条件);返回缺失项(空 = 合格)。""" doc = next((root / d for d in STABILITY_DOCS if (root / d).is_file() and STABILITY_HEADING.search((root / d).read_text(encoding="utf-8"))), None) if doc is None: return [f"找不到《稳定契约承诺》段(在 {' 或 '.join(STABILITY_DOCS)} 里以 `## …稳定契约承诺` 开头)"] text = doc.read_text(encoding="utf-8") sec = text[STABILITY_HEADING.search(text).start():] problems: list[str] = [] for item in STABILITY_ITEMS: if item not in sec: problems.append(f"没覆盖「{item}」") holes = [w for w in STABILITY_PLACEHOLDERS if w in sec] if holes: problems.append(f"还留着占位没填({'、'.join(holes)})") return problems def readiness(f: dict) -> tuple[bool, list[str], list[str]]: """把**事实**翻成(是否就绪, 阻塞项, 准备建议)——纯函数,可用假事实直接自检。 就绪 = 证据齐:候选版本已写进目标提交、工作区干净、完整 SHA 指向准确提交、 记录检查通过、远端 tag 已核对且未占用、**必需 context 集合全部 success**、 Release 不存在(查询异常也不算就绪)。 """ blockers: list[str] = [] advice: list[str] = [] cand = f.get("candidate") or "?" if f.get("first_release") and not f.get("explicit_version"): blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);" f"{cand} 只是建议值,**不当作已发布**") if not f.get("in_target_commit"): top = f.get("top_in_commit") blockers.append(f"候选版本 {cand} 还没写进目标提交(该提交的 CHANGELOG.md **顶部**已发布版本是 " f"{('`## [' + top + ']`') if top else '(没有已发布版本条目)'},不是 {cand})" f"—— 版本记录要先提交,再发版") advice.append(f"把 `## [Unreleased]` 改成 `## [{cand}] - YYYY-MM-DD`(四类小节、" f"破坏性变化带发布文档链接)并**提交**,然后重跑预检") dev = f.get("dev_number") if dev and SEMVER.match(str(f.get("candidate") or "")): try: if _ver_tuple(str(f["candidate"])) <= _ver_tuple(str(dev)): blockers.append(f"候选正式版本 {f['candidate']} 不大于当前开发号 {dev}" f"—— 会把已经出现过的开发号当成正式版(开发号随提交递增," f"发布请把次版本加一,例如 " f"{str(dev).split('.')[0]}.{int(str(dev).split('.')[1]) + 1}.0)") advice.append("下一次正式发布建议用 <主>.<次+1>.0(保证正式号严格大于此前所有开发号)") except ValueError: pass prev, level = f.get("prev_version"), f.get("bump_level") breaking_n = int(f.get("breaking_in_candidate") or 0) if prev and level and breaking_n: prev_major = _ver_tuple(str(prev))[0] if prev_major == 0 and level == "patch": blocks = (f"有 {breaking_n} 条破坏性变化,但 {prev} → {cand} 只抬了**修订**:" f"0.x 阶段破坏性变化必须抬**次版本**(例如 " f"0.{_ver_tuple(str(prev))[1] + 1}.0)—— 否则使用者按'只是修 bug'升级会踩坑") blockers.append(blocks) advice.append(f"把候选改成 0.{_ver_tuple(str(prev))[1] + 1}.0(抬次版本)," f"并在发布文档里写清升级/迁移与回退") if prev_major >= 1 and level != "major": blockers.append(f"有 {breaking_n} 条破坏性变化,但 {prev} → {cand} 没有抬**主版本**:" f"≥1.0.0 之后破坏性变化必须抬**主版本**(例如 {prev_major + 1}.0.0)") advice.append(f"把候选改成 {prev_major + 1}.0.0(抬主版本)") if level is None and prev and SEMVER.match(str(prev)) and SEMVER.match(str(cand)) \ and _ver_tuple(str(cand)) <= _ver_tuple(str(prev)): blockers.append(f"候选 {cand} 不大于上一个正式版本 {prev}(版本号只增不减)") if f.get("first_1_0_0") and not f.get("stability_ok"): problems = f.get("stability_problems") or ["(未取到具体缺失项)"] blockers.append("候选进入 1.0.0 = 对外承诺契约稳定:必须先把《稳定契约承诺》写实、" "并在候选发布记录里链接它 —— 缺:" + ";".join(str(x) for x in problems)) advice.append("在 docs 的《稳定契约承诺》里逐项写实 接口 / 数据模型 / 配置 / CLI 输出与退出码," "并在本次 CHANGELOG 的发布记录里链接该文档;不满足就先发 0.x") if f.get("candidate_tagged"): blockers.append(f"远端已经有 tag v{cand} ⇒ 不能覆盖已发布的 tag(版本号只增不减)") if f.get("dirty"): blockers.append(f"工作区有 {f['dirty']} 处未提交") if not f.get("head_full"): blockers.append("HEAD 不是完整 40 位 SHA") if not f.get("sha_is_head"): blockers.append("目标提交 != HEAD(发版必须指向准确提交)") if not f.get("checker_ok"): blockers.append("CHANGELOG / 版本记录检查不通过") if not f.get("unreleased_present"): advice.append("CHANGELOG 里没有 `[Unreleased]` 段:发版前把这次改动写进记录") if not f.get("remote_tags_checked"): blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发") if f.get("contexts_error"): blockers.append(f"查不到该提交的 CI 状态({f['contexts_error']})") req = f.get("required_contexts") or [] ctx = f.get("contexts") if not req: blockers.append("没有明确的必需 context 集合(`.gitea/required-contexts.txt` 缺失或为空)" "—— 不许拿「体检时观察到的状态」顶替") advice.append("把本仓真正必需的 status context 一行一个写进 `.gitea/required-contexts.txt`" "(字符串要与 `GET /commits//status` 里的 `context` 逐字符相同)") elif not f.get("contexts_error"): if ctx is None: blockers.append("没有取到该提交的 status context") else: missing = [c for c in req if c not in ctx] bad = [(c, ctx[c]) for c in req if c in ctx and ctx[c] != "success"] if missing: blockers.append(f"必需 context 缺项(该提交上没有这些状态):{missing}") if bad: blockers.append("必需 context 未全部 success:" + "、".join(f"{c}={v}" for c, v in bad)) if f.get("release_query_error"): blockers.append(f"Release 查询异常({f['release_query_error']})—— 只有 HTTP 404 才算「不存在」," f"查询异常不得当作可发") elif f.get("release_exists"): blockers.append(f"Gitea Release {f.get('tag') or cand} 已存在") if f.get("no_api"): blockers.append("没有核对远端 Release / CI 状态(缺凭据或显式 --no-api)") if not blockers: advice.append("证据齐了:正式发版动作由人按 docs/RELEASE_RUNBOOK.md 执行(本预检只读、不执行)") return (not blockers), blockers, advice 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() # ⚠️ 本预检是**只读**的:按约定**禁用自动发布路径**。`--execute` / `--approve-*` 只保留参数兼容 # (不报 unknown argument),但传了就**明确拒绝**,且拒绝发生在收集事实之前 ⇒ **零写操作、 # 零 API 调用**。正式发版动作由人按 docs/RELEASE_RUNBOOK.md 执行。 if args.execute: print("⛔ 本预检是只读的:自动发布路径(打 tag / 推 tag / 建 Release)**已禁用**,不执行。\n" " 正式发版动作请由人按 docs/RELEASE_RUNBOOK.md 执行;`--execute` / `--approve-version` /\n" " `--approve-sha` 仅保留参数兼容,传了也不会产生任何写操作。", file=sys.stderr) return 2 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 = f"v{candidate}" required, policy_src = required_contexts() dev_number, dev_commits = current_dev_number() rels = released_in_commit(want_sha) prev_version = next((v for v in rels if v != candidate), None) level = bump_level(prev_version, candidate) breaking_n = breaking_in_commit(want_sha, candidate) first_1_0_0 = (SEMVER.match(candidate) is not None and _ver_tuple(candidate)[0] == 1 and (prev_version is None or _ver_tuple(prev_version)[0] == 0)) stab_problems: list[str] = [] if first_1_0_0: stab_problems = stability_contract_problems() rc, ctext = _git_read("show", f"{want_sha}:CHANGELOG.md") blk = block_of(ctext, candidate) if rc == 0 else "" if not (any(d in blk for d in STABILITY_DOCS) or "稳定契约承诺" in blk): stab_problems.append("候选发布记录里没有链接《稳定契约承诺》" f"(应引 {' 或 '.join(STABILITY_DOCS)})") stab_ok = not stab_problems level_cn = {"major": "主版本", "minor": "次版本", "patch": "修订"}.get(level or "", "看不出来(首发 / 号没涨)") prev_major = _ver_tuple(prev_version)[0] if (prev_version and SEMVER.match(prev_version)) else None if not breaking_n: tail = "候选记录里没有破坏性变化 ⇒ 抬哪一档不限(但候选号必须 > 当前开发号)" elif prev_major is None: tail = f"候选记录里有 {breaking_n} 条破坏性变化(首发,无上一版本可比)" else: need = ("0.x 阶段:有破坏性必须抬次版本(或主版本)" if prev_major == 0 else "≥1.0.0:有破坏性必须抬主版本") ok = (level in ("minor", "major")) if prev_major == 0 else (level == "major") tail = f"候选记录里有 {breaking_n} 条破坏性变化 ⇒ {need} {'✅ 抬档够' if ok else '⛔ 抬档不够'}" print(f" 版本走向 : {prev_version or '(首发)'} → {candidate}(抬{level_cn})|{tail}") if first_1_0_0: print(f" 1.0.0 前置 : 《稳定契约承诺》" f"{'✅ 已写实' if stab_ok else '⛔ 未写实:' + ';'.join(stab_problems)}") print(f" 当前开发号 : {dev_number or '(无正式基线 / 取不到 tag ⇒ dev+g<短SHA>)'}" f"{f'(自 v{current} 起 {dev_commits} 个提交)' if (dev_number and dev_commits is not None) else ''}") top_in_commit = top_version_in_commit(want_sha) in_target = top_in_commit == candidate mark = "✅ 就是顶部" if in_target else "❌ 不是顶部" print(f"\n 目标提交内记录: {mark}(该提交的 CHANGELOG.md 顶部已发布版本 = " f"{('`## [' + top_in_commit + ']`') if top_in_commit else '(没有已发布版本条目)'};" f"候选 {candidate};查的是 {want_sha[:12]})") print(f" 必需 context : {len(required)} 条({policy_src})" + (":" + "、".join(required) if required else "")) 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}^{{}}") remote_tags_checked = rc == 0 and not args.no_api if not remote_tags_checked: why = "显式 --no-api" if args.no_api else "`git ls-remote --tags origin` 失败" print(f" 远端 tag : **未核对**({why})—— 不能声称 tag 已核验") else: print(f" 远端 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 '(无)'}") contexts = None contexts_error = "" release_exists = False release_query_error = "" run_line = "(未查询)" ctx_line = "(未查询)" if token and base and slug: st, _ = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token) state = release_state(st) note = {"exists": "已存在 ⛔ 不能覆盖", "absent": "不存在(可发)"}.get( state, f"查询异常(HTTP {st})⛔ 只有 404 才算「不存在」,不得当作可发") print(f" Gitea Release: {tag} {note}") release_exists = state == "exists" if state == "error": release_query_error = f"HTTP {st}" st, stt = _api_read(base, f"/repos/{slug}/commits/{head}/status", token) if st == 200: contexts = {x.get("context"): x.get("status") for x in stt.get("statuses") or []} ctx_line = "、".join(f"{c}={v}" for c, v in contexts.items()) or "(这个提交上一个状态都没有)" else: contexts_error = f"HTTP {st}" ctx_line = f"(查不到:HTTP {st})" 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: run_line = (f"run #{mine[0].get('run_number')} {mine[0].get('status')} " f"{mine[0].get('url') or ''}".strip()) else: run_line = "(这个提交上没有运行记录)" else: print(f" Gitea Release: **未核对**({'显式 --no-api' if args.no_api else '没有取到凭据'})") print(f" CI 状态 : {ctx_line}") print(f" 最近运行 : {run_line}") print(" 遗留风险/未验证: 见 docs/BACKLOG.md 与本次 Unreleased 里写明的未验证项" "(预检不替你判断它们的可接受性)") facts = { "candidate": candidate, "tag": tag, "first_release": first_release, "explicit_version": bool(args.version), "in_target_commit": in_target, "top_in_commit": top_in_commit, "dev_number": dev_number, "prev_version": prev_version, "bump_level": level, "breaking_in_candidate": breaking_n, "first_1_0_0": first_1_0_0, "stability_ok": stab_ok, "stability_problems": stab_problems, "dirty": len(dirty_lines), "head_full": bool(FULL_SHA.match(head)), "sha_is_head": want_sha == head, "checker_ok": checker_ok, "unreleased_present": bool(unreleased.strip()), "candidate_tagged": bool(remote_tags_checked and tag in remote_tags), "remote_tags_checked": remote_tags_checked, "required_contexts": required, "contexts": contexts, "contexts_error": contexts_error, "release_exists": release_exists, "release_query_error": release_query_error, "no_api": bool(args.no_api), } ready, blockers, advice = readiness(facts) print("\n── 准备建议(先做完这些,才有「就绪」可言)──") for a in (advice or ["(没有额外建议)"]): print(f" · {a}") print("\n── 发布就绪判定 ──") if ready: print(f" ✅ 发布就绪:{candidate}(tag {tag} → 提交 {head[:12]})") print(" ⚠️ 正式发版动作**不在本预检里执行、也不推荐自动路径**:" "步骤与读回判据见 docs/RELEASE_RUNBOOK.md,由人按批准执行。") else: for b in blockers: print(f" ⛔ {b}") print(f" ⇒ 现在**不能**发 {candidate}(未就绪)") return 1 if blockers else 0 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("版本推进算错了") # ── 就绪判定:全部用**假事实**驱动纯函数(不打真 tag、不碰网络)── base_facts = { "candidate": "0.2.0", "tag": "v0.2.0", "first_release": False, "explicit_version": True, "in_target_commit": True, "dirty": 0, "head_full": True, "sha_is_head": True, "checker_ok": True, "unreleased_present": True, "candidate_tagged": False, "remote_tags_checked": True, "required_contexts": ["build / build (push)"], "contexts": {"build / build (push)": "success"}, "contexts_error": "", "release_exists": False, "release_query_error": "", "no_api": False, # 版本走向/破坏性/1.0.0 前置(默认:无上一版本、无破坏性 ⇒ 新判据不参与) "prev_version": None, "bump_level": None, "breaking_in_candidate": 0, "first_1_0_0": False, "stability_ok": True, "stability_problems": [], } def rd(name: str, ready_want: bool, needle: str = "", **over) -> None: f = {**base_facts, **over} ready, blockers, _ = readiness(f) joined = " / ".join(blockers) if ready != ready_want or (needle and needle not in joined): problems.append(f"就绪判定[{name}]:ready={ready}(期望 {ready_want})blockers={joined[:160]}") rd("证据齐 ⇒ 就绪", True) rd("必需 context pending ⇒ 不就绪", False, "未全部 success", contexts={"build / build (push)": "pending"}) rd("必需 context failure ⇒ 不就绪", False, "未全部 success", contexts={"build / build (push)": "failure"}) rd("必需 context 没被报出来(缺项)⇒ 不就绪", False, "缺项", contexts={"other / other (push)": "success"}) rd("有别的 context 成功但必需项没报 ⇒ 仍不就绪", False, "缺项", required_contexts=["build / build (push)", "lint / lint (push)"]) rd("status 查询异常 ⇒ 不就绪", False, "查不到该提交的 CI 状态", contexts_error="HTTP 500") rd("没有必需 context 策略 ⇒ 不就绪", False, "没有明确的必需 context 集合", required_contexts=[]) rd("Release 查询异常 ⇒ 不就绪", False, "Release 查询异常", release_query_error="HTTP 500") rd("Release 已存在 ⇒ 不就绪", False, "已存在", release_exists=True) rd("远端 tag 已被占用 ⇒ 不就绪", False, "不能覆盖已发布的 tag", candidate_tagged=True) rd("没核对远端 tag ⇒ 不就绪", False, "没有核对远端 tag", remote_tags_checked=False) rd("首发未显式指定版本 ⇒ 不就绪", False, "首个正式版本号必须由人显式指定", first_release=True, explicit_version=False) rd("首发**显式**指定且记录已进提交 ⇒ 就绪", True, first_release=True, explicit_version=True, contexts={"build / build (push)": "success"}) rd("候选正式版本不大于当前开发号 ⇒ 不就绪(不许把开发号当正式号)", False, "不大于当前开发号", candidate="0.1.5", dev_number="0.1.12", in_target_commit=True) rd("候选正式版本 > 当前开发号 ⇒ 该条不拦(次版本加一)", True, "", candidate="0.2.0", dev_number="0.1.12", in_target_commit=True, contexts={"build / build (push)": "success"}) rd("候选版本没进目标提交 ⇒ 不就绪", False, "还没写进目标提交", in_target_commit=False) # ── 抬档判据:破坏性变化 ⇒ 0.x 抬次、≥1.0.0 抬主 ── rd("破坏性 + 0.x 只抬修订(0.1.0→0.1.3)⇒ 不就绪", False, "必须抬**次版本**", candidate="0.1.3", prev_version="0.1.0", bump_level="patch", breaking_in_candidate=1) rd("破坏性 + 0.x 抬次(0.1.0→0.2.0)⇒ 该条不拦", True, candidate="0.2.0", prev_version="0.1.0", bump_level="minor", breaking_in_candidate=2) rd("破坏性 + ≥1.0.0 只抬次(1.2.3→1.3.0)⇒ 不就绪", False, "必须抬**主版本**", candidate="1.3.0", prev_version="1.2.3", bump_level="minor", breaking_in_candidate=1) rd("破坏性 + ≥1.0.0 抬主(1.2.3→2.0.0)⇒ 该条不拦", True, candidate="2.0.0", prev_version="1.2.3", bump_level="major", breaking_in_candidate=1) rd("无破坏性 + 只抬修订(0.1.0→0.1.3)⇒ 抬档不拦", True, candidate="0.1.3", prev_version="0.1.0", bump_level="patch", breaking_in_candidate=0) rd("候选号没涨(0.2.0→0.2.0)⇒ 不就绪", False, "只增不减", candidate="0.2.0", prev_version="0.2.0", bump_level=None, breaking_in_candidate=0) # ── 1.0.0 首见:必须有写实的《稳定契约承诺》 ── rd("首发 1.0.0 但《稳定契约承诺》没写实 ⇒ 不就绪", False, "稳定契约承诺", candidate="1.0.0", prev_version="0.9.3", bump_level="major", first_1_0_0=True, stability_ok=False, stability_problems=["没覆盖「配置」", "还留着占位没填(待填)"]) rd("首发 1.0.0 且《稳定契约承诺》写实 ⇒ 该条不拦", True, candidate="1.0.0", prev_version="0.9.3", bump_level="major", first_1_0_0=True, stability_ok=True, stability_problems=[]) rd("已经是 1.x 再抬到 2.0.0 ⇒ 不要求重写承诺段", True, candidate="2.0.0", prev_version="1.4.0", bump_level="major", breaking_in_candidate=1, first_1_0_0=False) rd("候选版本只在历史里(顶部是别的版本)⇒ 不就绪,且信息里给出真实顶部版本", False, "顶部**已发布版本是 `## [0.3.0]`", in_target_commit=False, top_in_commit="0.3.0", candidate="0.2.0") rd("工作区脏 ⇒ 不就绪", False, "未提交", dirty=3) rd("目标提交 != HEAD ⇒ 不就绪", False, "目标提交 != HEAD", sha_is_head=False) rd("记录检查不通过 ⇒ 不就绪", False, "检查不通过", checker_ok=False) rd("缺凭据/--no-api ⇒ 不就绪", False, "没有核对远端 Release", no_api=True) # 候选记录存在 ≠ 已发布:三种证据都齐时应当就绪,且阻塞项里不该出现"已发布" _, bl, _ = readiness(base_facts) if any("已发布" in b for b in bl): problems.append(f"把『记录里有这个版本』误当成『已发布』:{bl}") for st, want in ((200, "exists"), (404, "absent"), (500, "error"), (403, "error"), (0, "error")): if release_state(st) != want: problems.append(f"Release 查询语义错:HTTP {st} 判成了 {release_state(st)}(期望 {want})") # ── 临时仓库:候选版本"写进提交才算数" ── import tempfile as _tf with _tf.TemporaryDirectory() as d: dd = Path(d) (dd / "no-hooks").mkdir(exist_ok=True) env = {**dict(**__import__("os").environ), "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": str(dd / "no-hooks")} (dd / "CHANGELOG.md").write_text("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n", encoding="utf-8") subprocess.run(["git", "init", "-q", "-b", "main"], cwd=dd, env=env, check=True) subprocess.run(["git", "add", "-A"], cwd=dd, env=env, check=True) subprocess.run(["git", "commit", "-q", "-m", "chore: 夹具"], cwd=dd, env=env, check=True) sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=dd, capture_output=True, text=True).stdout.strip() if version_in_commit(sha, "0.2.0", root=dd): problems.append("未提交的版本被当成了『已写进目标提交』") (dd / "CHANGELOG.md").write_text( "# Changelog\n\n## [0.2.0] - 2026-02-01\n\n### Fixed\n\n- y\n", encoding="utf-8") if version_in_commit(sha, "0.2.0", root=dd): problems.append("工作区改了但没提交,仍被判成『已写进目标提交』") subprocess.run(["git", "add", "-A"], cwd=dd, env=env, check=True) subprocess.run(["git", "commit", "-q", "-m", "chore(release): 0.2.0"], cwd=dd, env=env, check=True) sha2 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=dd, capture_output=True, text=True).stdout.strip() if not version_in_commit(sha2, "0.2.0", root=dd): problems.append("版本写进提交后仍判成『没有』") # ── 候选版本必须"是目标提交里的**顶部**版本"(历史里出现过不算)── with _tf.TemporaryDirectory() as d: dd = Path(d) (dd / "no-hooks").mkdir(exist_ok=True) env = {**dict(**__import__("os").environ), "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": str(dd / "no-hooks")} (dd / "CHANGELOG.md").write_text( "# Changelog\n\n## [0.3.0] - 2026-03-01\n\n### Added\n\n- 新\n\n" "## [0.2.0] - 2026-02-01\n\n### Fixed\n\n- 旧\n", encoding="utf-8") subprocess.run(["git", "init", "-q", "-b", "main"], cwd=dd, env=env, check=True) subprocess.run(["git", "add", "-A"], cwd=dd, env=env, check=True) subprocess.run(["git", "commit", "-q", "-m", "chore: 夹具"], cwd=dd, env=env, check=True) sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=dd, capture_output=True, text=True).stdout.strip() if version_in_commit(sha, "0.2.0", root=dd): problems.append("候选版本 0.2.0 只在**历史**里出现(顶部是 0.3.0),却被判成『已写进目标提交』") if not version_in_commit(sha, "0.3.0", root=dd): problems.append("候选版本 0.3.0 就是该提交的顶部版本,却没判成『已写进目标提交』") # ── `--execute` 必须明确拒绝,且**零写操作、零 API 调用** ── calls: list[list[str]] = [] api_hits: list[tuple] = [] real_run, real_api = subprocess.run, _api_read def _spy_run(*a, **k): if a and isinstance(a[0], (list, tuple)): calls.append([str(x) for x in a[0]]) return real_run(*a, **k) def _spy_api(*a, **k): api_hits.append(a) return real_api(*a, **k) subprocess.run = _spy_run # 模块里用的是同一个 subprocess 模块对象 globals()["_api_read"] = _spy_api try: rc_exec = main(["--execute", "--approve-version", "9.9.9", "--approve-sha", "0" * 40]) finally: subprocess.run = real_run globals()["_api_read"] = real_api if rc_exec != 2: problems.append(f"--execute 没有被明确拒绝(退出码 {rc_exec},期望 2)") if api_hits: problems.append(f"--execute 路径竟然发了 API 请求:{len(api_hits)} 次") if calls: problems.append(f"--execute 拒绝路径不该执行任何子进程(零写操作):{calls[:3]}") # 源码里不许再有"直接执行 git 写命令"的行(防止以后被悄悄加回来)。 # 只看**生产路径**(`def self_test()` 之前):自检自己在临时仓库里 `git commit` 造夹具是允许的。 prod = Path(__file__).read_text(encoding="utf-8").split("def self_test()")[0] write_words = ('"tag"', '"push"', '"update-ref"', '"commit"') bad_lines = [] for line in prod.splitlines(): if "subprocess.run(" not in line or '"git"' not in line: continue if any(w in line for w in write_words): bad_lines.append(line.strip()[:120]) if bad_lines: problems.append(f"预检的生产路径里有直接执行 git 写命令的行:{bad_lines[:2]}") if problems: print("❌ 预检自检失败:", file=sys.stderr) for p in problems: print(f" · {p}", file=sys.stderr) return 1 print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;" "就绪判定 32 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" "tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);" "『顶部版本才算数』与『写进提交才算数』用临时仓库验证;" "`--execute` 明确拒绝且零写操作、零 API 调用") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))