4f8ee0a8c5
按项目方给的规则重做 CHANGELOG 与检查器(本轮不推、不打 tag、不部署): - 条目形状:四个自定义必填节(改了什么/兼容性与迁移影响/怎么验收/已知问题) → 固定四类 Added/Changed/Fixed/Security;分类可省略,**不许空栏目**;每个版本块 至少一个非空分类;`[Unreleased]` 段不得写日期。 - 破坏性变化:固定写在 Changed 里、以 `**破坏性**` 开头,**必须链接发布文档**; 仓内 `docs/….md` 链接**对账**(不存在 ⇒ 红)。写在别的分类里也判红。 - 结构判据补强:非法日期、重复版本、顺序必须严格递减、前导零;围栏代码块里的 `##`/`###` 不算标题;**块正文以任意二级标题为界**(「## 能力基线(未发布)」这类 不带方括号的标题原先不是边界,会把后文算成上一节的内容 ⇒ 空栏目被兜成"有内容", 夹具真的抓到了这个假绿)。 - tag 一致性:远端专属清单 `.ci-remote-tags` → `ls-remote` → 本地 refs 三级来源; **没核对远端就不许说校验成功**(CI/预检用 `--require-remote`,拿不到清单即红), 非正式 tag(如本地 `x`)不当作发布版本、不判红,没有 tag 不判红。 - `0.1.0` 定为**未发布的能力基线**(远端无 tag、无 Release):改成不带方括号的 「## 能力基线(未发布)」,不补造 tag/发布日期/发布事实。 - 过程记录迁出:审查轮次与命令输出 → `docs/archive/CI_接入留档.md`(原文保留); 未合并事项 → `docs/BACKLOG.md`;补 `docs/RELEASE_RUNBOOK.md`(破坏性变化的链接目标, 同时修掉 CHANGELOG 里指向不存在文档的悬空引用)。 - 发版预检:无版本基线时把「首个正式版本号由人指定」列成阻塞项(不再要求先有基线才能跑); 打印破坏性变化条数;远端 tag 查不到时明说未核对并阻塞;不再硬读已删除的『已知问题』节。 - traps trap-5 口径修正:不是"不用 tag",而是"构建时不要用 git describe 取版本号"。 验证:check_changelog --self-test 全过;反仓夹具 80/0(含五条真改文件的结构负例、 六条只在临时仓库里造 tag 的场景);make verify 15/15 success。
369 lines
18 KiB
Python
369 lines
18 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 状态时打印"未核对",不写成"已通过"。
|
||
|
||
用法:
|
||
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:])) |