Files
knowledge-governance/scripts/release_preflight.py
T
t 6de5d52363
build / build (push) Successful in 51s
feat(version): 开发号随提交自动递增 + 首个正式版本 0.1.0
按使用者要求定的版本规则(自动升的是**开发标识**,发布版本与 tag 仍由人批准):

- **开发号自动递增**:display_version(development) = `<正式版本的主.次>.<自 v<正式版本> 以来的提交数>`
  (正式 0.1.0 之后第 1 个提交 0.1.1、第 12 个 0.1.12;恰在发布提交上 = 正式号)。
  它**不写进 CHANGELOG**(发布记录只在发版时动),由 git 算出来 ⇒ 每个提交都有自己的号、数字只增。
- **浅克隆不猜号**:`--depth` 会把条数算少 ⇒ 浅仓库里拒绝算号,退回 `<版本>-dev+g<短SHA>`;
  检出步骤在仓库有 tag 时 `--unshallow` 补全历史。
- 接线三处齐全:`version.py --identity` → 门禁 `-X …CommitsSinceRelease=<n>`;Makefile 的
  `COMMITS_SINCE_RELEASE`;Dockerfile 同名 build-arg;Go 侧新增 `CommitsSinceRelease` 与
  DisplayVersion() 新规则;契约测试**交叉核对**"注入的提交数 == git 算出的"(防注入错号)。
- **首个正式版本 0.1.0**:CHANGELOG 的 `[Unreleased]` 提成 `## [0.1.0] - 2026-10-13`;
  头部补"版本怎么走"两行;RELEASE_RUNBOOK 补递增规则与"下一次发布把次版本加一"。
- 发版预检新增:打印**当前开发号**,并挡住"候选正式版本 <= 当前开发号"(会把开发号当正式号;建议次版本加一)。

验证:version.py --self-test(含 tag 后第 2 个提交 = 0.2.2、恰在 tag 上 = 0.2.0、无 tag 不猜号);
release_preflight --self-test(21 分支,含开发号两条);go test 本地三态 + 临时仓库 tag+2 提交实测
(Python 与 Go 都得出 0.2.2);反向夹具 85/0;make verify 15/15 success。
2026-10-13 16:45:56 +08:00

651 lines
35 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 状态时打印"未核对",不写成"已通过"。
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
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
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/<sha>/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()
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,
"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,
}
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)
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 在预检路径上全部被拒;版本推进正确;"
"就绪判定 21 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、"
"tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);"
"『顶部版本才算数』与『写进提交才算数』用临时仓库验证;"
"`--execute` 明确拒绝且零写操作、零 API 调用")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))