Files
eng-standards-main/templates/release-preflight.py
T
姚章浩 79c154f9af docs: 版本条目改固定四类 + 破坏性变化固定写法 + 构建身份(口径变更)
对着试点仓落地时暴露的问题做的口径与模板修正(本轮不推、不打 tag、不部署):

- 条目形状:四个自定义必填节 → **固定四类** Added/Changed/Fixed/Security
  (分类可省略、不许空栏目);过程记录进 docs/archive/,未合并事项进 docs/BACKLOG.md。
- 破坏性变化/迁移:固定写在 Changed 里、以 `**破坏性**` 开头并**必须链接发布文档**,
  仓内 `docs/….md` 链接**对账**(不存在即红)。
- 构建身份:release_version / build_kind / commit_sha / display_version / built_at;
  开发构建显示 X.Y.Z-dev+g<短SHA>(无基线 dev+g<短SHA>),**不许只显示上一正式版本号、
  不许自称正式版**;release 只认显式发布上下文。
- **还没有正式版本一律放行**:不判红;首个正式版本号**由人指定**(预检把它列成阻塞项)。
- **没核对远端就不许说校验成功**:CI/预检用 `--require-remote`(本地模式明确写「未核对远端」)。
- 修正"CI 取不到 tag 所以不用 tag"的过时口径:正式版本**就是**用注释 tag,
  只是**构建时不要用 git describe 取版本号**;**测试 tag 只在临时测试仓库里创建**。
- 模板同步:templates/check-changelog.py、templates/release-preflight.py、
  新增 templates/version.py;ci-gate.py 的 version 项在 CI 下强制核对远端;
  new-project.sh 落 scripts/version.py 并按四类重写 CHANGELOG 骨架;
  README/07/模板 README 同步;04 增加 §I(V-1~V-4 四条,含负例在哪)。

验证:templates 两个检查器 --self-test 全过;new-project.sh bash -n 通过;
生成的新工程里 check_changelog --tags 绿、--identity 给出 dev+g<短SHA>、
CI=true 下 version 项因"未核对远端"判红(预期);试点仓反向夹具 80/0。
2026-10-13 14:59:48 +08:00

369 lines
18 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 状态时打印"未核对",不写成"已通过"。
用法:
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}"
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 / Release / CI 证据
tag = f"v{candidate}"
blockers: list[str] = []
if first_release:
blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);"
f"{candidate} 只是建议值")
if dirty_lines:
blockers.append(f"工作区有 {len(dirty_lines)} 处未提交")
if not FULL_SHA.match(head):
blockers.append("HEAD 不是完整 40 位 SHA")
if want_sha != head:
blockers.append(f"--sha {want_sha[:12]} != HEAD {head[:12]}(发版必须指向准确提交)")
if not checker_ok:
blockers.append("CHANGELOG/版本记录检查不通过")
if not unreleased.strip():
blockers.append("没有 Unreleased 段:发版前要把这次改动写进 CHANGELOG")
if candidate in rel:
blockers.append(f"版本 {candidate} 已经在 CHANGELOG 里出现过(版本号只增不减)")
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}^{{}}")
if rc != 0 or args.no_api:
why = "显式 --no-api" if args.no_api else "`git ls-remote --tags origin` 失败"
print(f"\n 远端 tag : **未核对**({why})—— 不能声称 tag 已核验")
blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发")
else:
print(f"\n 远端 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 '(无)'}")
run_line = "(未查询)"
ctx_line = "(未查询)"
if token and base and slug:
st, body = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token)
rel_exists = st == 200
print(f" Gitea Release: {tag} {'已存在 ⛔' if rel_exists else '不存在(可发)'}")
if rel_exists:
blockers.append(f"Gitea Release {tag} 已存在")
st, stt = _api_read(base, f"/repos/{slug}/commits/{head}/status", token)
if st == 200:
ctxs = [(s.get("context"), s.get("status")) for s in stt.get("statuses") or []]
ctx_line = "、".join(f"{c}={v}" for c, v in ctxs) or "(这个提交上一个状态都没有)"
if not any(v == "success" for _, v in ctxs):
blockers.append(f"提交 {head[:12]} 上没有成功的 status context(CI 证据不足)")
else:
ctx_line = f"(查不到:HTTP {st})"
blockers.append("查不到该提交的 CI 状态")
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:
r0 = mine[0]
run_line = (f"run #{r0.get('run_number')} {r0.get('status')} "
f"{r0.get('url') or ''}".strip())
else:
run_line = "(这个提交上没有运行记录)"
else:
print(f" Gitea Release: **未核对**({'显式 --no-api' if args.no_api else '没有取到凭据'})")
blockers.append("没有核对远端 Release/CI 状态(缺凭据)")
print(f" CI 状态 : {ctx_line}")
print(f" 最近运行 : {run_line}")
print(" 遗留风险/未验证: 见 docs/BACKLOG.md 与本次 Unreleased 里写明的未验证项"
"(预检不替你判断它们的可接受性)")
print("\n── 结论 ──")
if blockers:
for b in blockers:
print(f" ⛔ {b}")
print(f" ⇒ 现在**不能**发 {candidate}")
else:
print(f" ✅ 预检通过:可以发 {candidate}(tag {tag} → 提交 {head[:12]})")
print(" 批准后执行(本轮不执行):")
print(f" python3 scripts/release_preflight.py --execute --version {candidate} "
f"--approve-version {candidate} --approve-sha {head}")
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("版本推进算错了")
if problems:
print("❌ 预检自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))