Files
knowledge-governance/scripts/release_preflight.py
T
姚章浩 7a9d51cc70 fix(version): 正式构建核证据 + 预检分"建议/就绪" + CI 就绪按必需 context
按要求补三项(本轮不推、不打真实 tag、不部署;全部用临时仓库与假对象验):

1) 正式构建身份不能只认 --release:
   - 新增 release_evidence():核①完整 40 位 SHA ②**指向该提交的注释 tag** v<版本号>
     ③该提交里 CHANGELOG 顶部版本一致。缺任一条 ⇒ rc=2,身份退回 development 并把
     release_blocked 的原因写进 JSON(给机器读,也给人看)。
   - version.py --self-test:六条负例(有版本无 tag / unknown SHA / 短 SHA / tag 指错提交 /
     轻量 tag / 版本不一致)+ 证据齐正例,**tag 全部在 mktemp 临时仓库里创建**。
   - Go 侧新增 TestReleaseBuildRequiresEvidence:release 构建必须 CommitSHA 完整且
     v<ReleaseVersion> 是**注释 tag** 并指向该提交(独立于 Python 的二次复核)。

2) 发布预检区分"准备建议"与"发布就绪":
   - 新增纯函数 readiness(facts) → (是否就绪, 阻塞项, 建议);输出分两段。
   - **就绪要求候选版本已写进目标提交**(version_in_commit:查 git show <sha>:CHANGELOG.md,
     工作区写了没提交不算);首发按**显式指定版本**处理,**不把"记录里有这个版本"当已发布**
     (已发布的证据是 tag,不是记录条目)。
   - 预检**不执行、也不在输出里推荐自动发布路径**(正式发版由人按 docs/RELEASE_RUNBOOK.md)。

3) CI 就绪判据:必需 context 集合来自 .gitea/required-contexts.txt(缺失/为空 ⇒ 没有策略 ⇒
   阻断,不用观察到的状态顶替);**全部 success 才通过**,失败/缺项/pending/查询异常一律阻断。
   Release 查询加 release_state():**只有 HTTP 404 才算不存在**,403/5xx/超时算查询异常 ⇒ 阻断。

验证:version.py --self-test 绿;release_preflight --self-test 绿(就绪判定 19 个分支 +
临时仓库验证 git show 语义 + 五个 HTTP 码语义);go test 三态(未注入 SKIP / 开发 PASS /
自称正式 RED);反向夹具 83/0(含临时仓库 go test 六例、预检"记录未进提交"两态);
make verify 15/15 success。
2026-10-13 15:26:49 +08:00

565 lines
31 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 # 只读保证的自检
# 批准后才执行(本轮不执行):
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}"
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 version_in_commit(sha: str, version: str, *, root: Path = ROOT) -> bool:
"""候选版本是否**已经写进那个提交**(查该提交里的 CHANGELOG.md,不看工作区)。
⚠️ 这是"准备 vs 就绪"的分界线:工作区里写了、没提交 ⇒ 还不是就绪。
"""
rc, blob = _git_read("show", f"{sha}:CHANGELOG.md", cwd=root)
if rc != 0:
return False
return bool(re.search(rf"^##\s*\[{re.escape(version)}\]", blob, re.MULTILINE))
def release_state(status: int) -> str:
"""Release 查询结果的语义:**只有 HTTP 404 代表「不存在」**,其余非 200 一律算查询异常。"""
if status == 200:
return "exists"
if status == 404:
return "absent"
return "error"
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"):
blockers.append(f"候选版本 {cand} 还没写进目标提交(该提交的 CHANGELOG.md 里没有 `## [{cand}]`)"
f"—— 版本记录要先提交,再发版")
advice.append(f"把 `## [Unreleased]` 改成 `## [{cand}] - YYYY-MM-DD`(四类小节、"
f"破坏性变化带发布文档链接)并**提交**,然后重跑预检")
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()
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()
in_target = version_in_commit(want_sha, candidate)
mark = "✅ 已有" if in_target else "❌ 没有"
print(f"\n 目标提交内记录: {mark} `## [{candidate}]`"
f"(版本记录要先提交,再发版;查的是 {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,
"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}(未就绪)")
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("版本推进算错了")
# ── 就绪判定:全部用**假事实**驱动纯函数(不打真 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, "还没写进目标提交", in_target_commit=False)
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("版本写进提交后仍判成『没有』")
if problems:
print("❌ 预检自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;"
"就绪判定 19 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、"
"tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);"
"『写进提交才算数』用临时仓库验证")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))