6de5d52363
build / build (push) Successful in 51s
按使用者要求定的版本规则(自动升的是**开发标识**,发布版本与 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。
651 lines
35 KiB
Python
651 lines
35 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 # 只读保证 + 就绪判定的自检
|
||
# ⚠️ 自动发布路径已禁用:--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:]))
|