7a9d51cc70
按要求补三项(本轮不推、不打真实 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。
565 lines
31 KiB
Python
565 lines
31 KiB
Python
#!/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:])) |