diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 4bbd89d..dbb9953 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -13,12 +13,14 @@ 版本号**只增不减**(回退走部署层,不改这个文件);每个项目只有本文件这一个版本来源。 3. **跑预检(只读)**:`python3 scripts/release_preflight.py --version X.Y.Z` —— 输出**两段**:`准备建议`(还差什么)与 `发布就绪判定`(是否就绪)。 - ⚠️ 预检**不执行、也不推荐自动发布路径**;正式发版动作由人按本文执行。 + ⚠️ 预检**不执行、也不推荐自动发布路径**:**自动发布路径已禁用** —— `--execute` / `--approve-*` + 只保留参数兼容(不报 unknown argument),传了会被**明确拒绝**(rc=2,拒绝发生在收集事实之前 ⇒ + **零写操作、零 API 调用**)。正式发版动作由人按本文执行。 **就绪 = 证据齐(缺一条都不算)**: | 判据 | 说明 | |---|---| - | 候选版本**已写进目标提交** | 查 `git show :CHANGELOG.md`;只在工作区写了、没提交 ⇒ 不就绪 | + | 候选版本**已是目标提交里的顶部版本** | 查 `git show :CHANGELOG.md`;只在工作区写了、没提交 ⇒ 不就绪;只在**历史**里出现过(顶部是别的版本)⇒ **也不就绪**;预检会把该提交的顶部版本打出来 | | 完整 40 位 SHA 指向准确提交 | 目标提交 == HEAD,或 `--sha` 逐字符一致 | | 记录检查通过 | 结构与 tag 一致性(真查远端) | | **必需 context 集合全部 success** | 集合来自 `.gitea/required-contexts.txt`;缺失/为空 ⇒ 没有策略 ⇒ 阻断;失败 / 缺项 / pending / 查询异常一律阻断 | @@ -28,9 +30,9 @@ 自查(不依赖网络、不打真 tag):`python3 scripts/release_preflight.py --self-test`、 `python3 scripts/version.py --self-test`。 -4. **批准后执行**:`python3 scripts/release_preflight.py --execute --version X.Y.Z - --approve-version X.Y.Z --approve-sha <完整SHA>`(两个批准参数必须逐字符等于预检结果)。 - 它创建**注释 tag** `vX.Y.Z`(指向验收过的那个提交)并建关联 Release,正文取自 `CHANGELOG.md`。 +4. **由人执行**(预检不做这件事):创建**注释 tag** `vX.Y.Z`(指向验收过的那个提交,完整 40 位 SHA) + 并建关联 Release,正文取自 `CHANGELOG.md`。⚠️ `release_preflight.py --execute` **已被禁用** + (见第 3 步),不要试图用它自动发版。 5. **读回**:`git ls-remote --tags origin` 里该 tag 的 SHA == 本地 `git rev-parse vX.Y.Z^{commit}`; Release 的 `tag_name` 正确。**已发布的 tag 不移动、不覆盖。** diff --git a/scripts/ci_negative_fixtures.sh b/scripts/ci_negative_fixtures.sh index 07b717f..93938c4 100644 --- a/scripts/ci_negative_fixtures.sh +++ b/scripts/ci_negative_fixtures.sh @@ -686,15 +686,15 @@ def seed(d): for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]): subprocess.run(["git", *args], cwd=d, check=True, capture_output=True) -def pre(d): - p = subprocess.run(["python3", "scripts/release_preflight.py", "--version", "0.2.0", "--no-api"], +def pre(d, *args): + p = subprocess.run(["python3", "scripts/release_preflight.py", *args, "--no-api"], cwd=d, capture_output=True, text=True) return p.returncode, (p.stdout + p.stderr) cases = [] with tempfile.TemporaryDirectory() as tmp: d = pathlib.Path(tmp) / "r"; d.mkdir(); seed(d) - rc1, t1 = pre(d) + rc1, t1 = pre(d, "--version", "0.2.0") cases.append(("记录只在工作区(没提交)⇒ 明说『还没写进目标提交』且不就绪", rc1 == 1 and "还没写进目标提交" in t1 and "现在**不能**发" in t1)) cases.append(("就绪判定里含『准备建议』与『发布就绪判定』两段(不是混在一起)", @@ -709,7 +709,7 @@ with tempfile.TemporaryDirectory() as tmp: subprocess.run(["git", "add", "-A"], cwd=d, check=True, capture_output=True) subprocess.run(["git", "commit", "-q", "-m", "chore(release): 0.2.0 记录"], cwd=d, check=True, capture_output=True) - rc2, t2 = pre(d) + rc2, t2 = pre(d, "--version", "0.2.0") cases.append(("记录进了提交 ⇒ 『还没写进目标提交』这条阻塞**消失**", "还没写进目标提交" not in t2)) cases.append(("仍然不就绪(没核对远端 / 没有 CI 证据)⇒ 不许给可发结论", @@ -717,11 +717,43 @@ with tempfile.TemporaryDirectory() as tmp: cases.append(("候选记录存在 ≠ 已发布:不该出现『已存在』类误判", "已存在" not in t2.replace("不存在(可发)", ""))) + # 候选版本必须“**是**目标提交里的顶部版本”:只在历史里出现过不算 + d2 = pathlib.Path(tmp) / "r2"; d2.mkdir() + (d2 / "scripts").mkdir(parents=True); (d2 / "docs").mkdir(); (d2 / ".gitea").mkdir() + for n in ("check_changelog.py", "release_preflight.py"): + (d2 / "scripts" / n).write_text(pathlib.Path("scripts", n).read_text(encoding="utf-8"), + encoding="utf-8") + (d2 / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8") + (d2 / ".gitea" / "required-contexts.txt").write_text("build / build (push)\n", encoding="utf-8") + (d2 / "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") + (d2 / "no-hooks").mkdir(exist_ok=True) + for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]): + subprocess.run(["git", *args], cwd=d2, check=True, capture_output=True) + _, t3 = pre(d2, "--version", "0.2.0") + cases.append(("候选版本只在**历史**里(顶部是 0.3.0)⇒ 明确『还没写进目标提交』", + "还没写进目标提交" in t3)) + _, t4 = pre(d2, "--version", "0.3.0") + cases.append(("候选版本就是该提交的**顶部**版本 ⇒ 该条阻塞消失", "还没写进目标提交" not in t4)) + bad = [n for n, okv in cases if not okv] for n, okv in cases: print(("✅ " if okv else "❌ ") + n) raise SystemExit(1 if bad else 0) PY +pycheck "预检:--execute 自动发布路径已禁用(明确拒绝、rc=2、零写操作:跑完仓里 tag 不变)" <<'PY' +import subprocess +before = subprocess.run(["git", "tag", "-l"], capture_output=True, text=True).stdout.split() +p = subprocess.run(["python3", "scripts/release_preflight.py", "--execute", + "--approve-version", "9.9.9", "--approve-sha", "0" * 40], + capture_output=True, text=True) +after = subprocess.run(["git", "tag", "-l"], capture_output=True, text=True).stdout.split() +okv = (p.returncode == 2 and "已禁用" in (p.stderr or "") and not p.stdout.strip() + and before == after) +print(("✅ " if okv else "❌ ") + f"rc={p.returncode}|tag {before} → {after}|输出为空={not p.stdout.strip()}") +raise SystemExit(0 if okv else 1) +PY if grep -qE "git tag -a|--data-binary|POST" /tmp/kg-pf.log; then no "预检输出里出现了写操作迹象" else diff --git a/scripts/release_preflight.py b/scripts/release_preflight.py index 8fccb1e..fb174a5 100644 --- a/scripts/release_preflight.py +++ b/scripts/release_preflight.py @@ -19,9 +19,8 @@ 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> + python3 scripts/release_preflight.py --self-test # 只读保证 + 就绪判定的自检 + # ⚠️ 自动发布路径已禁用:--execute 会被明确拒绝(rc=2,零写操作) """ from __future__ import annotations @@ -38,6 +37,7 @@ 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*)$") # 预检路径允许的只读 git 子命令(写命令在这里直接拒) GIT_READ_OK = {"ls-remote", "rev-parse", "log", "show", "cat-file", "for-each-ref", @@ -160,15 +160,23 @@ def required_contexts(root: Path = ROOT) -> tuple[list[str], str]: 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 就绪"的分界线:工作区里写了、没提交 ⇒ 还不是就绪。 - """ +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 False - return bool(re.search(rf"^##\s*\[{re.escape(version)}\]", blob, re.MULTILINE)) + 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: @@ -195,7 +203,9 @@ def readiness(f: dict) -> tuple[bool, list[str], list[str]]: blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);" f"{cand} 只是建议值,**不当作已发布**") if not f.get("in_target_commit"): - blockers.append(f"候选版本 {cand} 还没写进目标提交(该提交的 CHANGELOG.md 里没有 `## [{cand}]`)" + 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"破坏性变化带发布文档链接)并**提交**,然后重跑预检") @@ -257,6 +267,14 @@ def main(argv: list[str]) -> int: 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) @@ -316,10 +334,12 @@ def main(argv: list[str]) -> int: # ── 事实收集(只读)────────────────────────────────────────────── 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]})") + 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 "")) @@ -377,6 +397,7 @@ def main(argv: list[str]) -> int: 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, "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()), @@ -401,53 +422,7 @@ def main(argv: list[str]) -> int: 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 - + return 1 if blockers else 0 def self_test() -> int: """预检的**只读保证**要能被证明:写命令与 POST 必须在预检路径上被拒。""" @@ -507,6 +482,9 @@ def self_test() -> int: rd("首发**显式**指定且记录已进提交 ⇒ 就绪", True, first_release=True, explicit_version=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) @@ -549,6 +527,68 @@ def self_test() -> int: 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: @@ -557,9 +597,10 @@ def self_test() -> int: print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;" "就绪判定 19 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" "tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);" - "『写进提交才算数』用临时仓库验证") + "『顶部版本才算数』与『写进提交才算数』用临时仓库验证;" + "`--execute` 明确拒绝且零写操作、零 API 调用") return 0 if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) \ No newline at end of file + raise SystemExit(main(sys.argv[1:]))