diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 4236139..023e067 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -64,6 +64,26 @@ python3 scripts/version.py --identity # JSON:release_version / build_kind - 二进制里的注入字段(`internal/version`)与上表一一对应;契约测试断言: 注入 == CHANGELOG 顶部(发布构建)、开发构建必须是 `dev` 形状(**不许声称正式版**),且**跳过不算通过**。 +## 二·补、抬哪一档(硬门禁,预检会拦) + +| 抬哪一位 | 什么情况 | 预检判据(不满足 ⇒ 阻断,发不出去) | +|---|---|---| +| **修订** `0.1.N → 0.1.N+1` | 只修 bug、内部改动:对外行为不变、无破坏性、无成规模新能力 | 候选号必须 **> 当前开发号**(开发号到 `0.1.7` 时只能发 `0.1.8` 及以上);候选 ≤ 上一个正式版本 ⇒ 阻断(号只增不减) | +| **次版本** `0.1.x → 0.2.0` | **有破坏性变化**(0.x 阶段一律走次版本),或成规模的对外新能力/行为变化 | 候选发布记录里破坏性条数 ≥1 且当前是 `0.x` ⇒ 必须抬**次版本**;只抬修订 ⇒ 阻断 | +| **主版本** `0.x → 1.0.0`、`1.x → 2.0.0` | 首次声明"对外契约稳定"(`1.0.0`);之后**任何**破坏性变化(`2.0.0`) | ≥1.0.0 阶段破坏性 ⇒ 必须抬**主版本**(抬次/修订都阻断);**首发 `1.0.0` 还要求本文《稳定契约承诺》写实并在发布记录里链接它** | + +判据里用的"破坏性条数"取自**目标提交里候选版本那一段**(发版事实看提交里的记录,不看工作区)。 + +## 二·补·2、《稳定契约承诺》(发 1.0.0 的前置条件) + +> ⛔ 现在**故意留着占位**:只要还有 `(待填)` / `TODO` / `TBD`,预检在候选为 `1.0.0`(且上一个正式版本是 `0.x`)时就会阻断。 +> 逐项写实、并在 `CHANGELOG.md` 的 `## [1.0.0]` 记录里链接本文后,门禁才放行。四项一个都不能少。 + +- **接口**(待填):对外 HTTP / CLI / SDK 接口的稳定范围与版本策略(哪些字段冻结、变更怎么通知)。 +- **数据模型**(待填):库表 / 文件 / 消息结构的兼容窗口与迁移责任。 +- **配置**(待填):配置项与默认值的冻结范围、弃用流程、回退方式。 +- **CLI 输出与退出码**(待填):脚本可依赖的输出形态与退出码约定。 + ## 三、破坏性变化 / 数据迁移怎么写 在 `CHANGELOG.md` 的 `### Changed` 里: diff --git a/scripts/ci_negative_fixtures.sh b/scripts/ci_negative_fixtures.sh index 1987d4a..0abbb57 100644 --- a/scripts/ci_negative_fixtures.sh +++ b/scripts/ci_negative_fixtures.sh @@ -805,5 +805,103 @@ else no "预检留下了 tag:$(git tag -l 'v*' | tr '\n' ' ')" fi +echo +echo "§24 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)与 1.0.0 前置(《稳定契约承诺》)" +cat >/tmp/kg_bump_lib.py <<'LIB' +import os, pathlib, shutil, subprocess, tempfile + + +def mk(prev, cand, breaking_in_changed, stab_section=None, link_in_record=False): + """造临时仓库:CHANGELOG 顶部 = 候选版本,下面留上一版本;docs 里有/没有承诺段。""" + d = pathlib.Path(tempfile.mkdtemp(prefix="kg-bump-")) + (d / "scripts").mkdir() + for n in ("check_changelog.py", "release_preflight.py", "version.py"): + (d / "scripts" / n).write_text(pathlib.Path("scripts", n).read_text(encoding="utf-8"), + encoding="utf-8") + (d / "docs").mkdir() + doc = "# 发布运行手册\n" + if stab_section: + doc += "\n## 稳定契约承诺(占位)\n\n" + stab_section + "\n" + (d / "docs" / "RELEASE_RUNBOOK.md").write_text(doc, encoding="utf-8") + (d / ".gitea").mkdir() + (d / ".gitea" / "required-contexts.txt").write_text("build / build (push)\n", encoding="utf-8") + changed = ("\n### Changed\n\n- **破坏性**:接口下线,升级见 " + "[发布文档](docs/RELEASE_RUNBOOK.md)\n") if breaking_in_changed else "" + added = "- 新东西" + ("(稳定契约承诺见 [本文](docs/RELEASE_RUNBOOK.md))" if link_in_record else "") + (d / "CHANGELOG.md").write_text( + f"# Changelog\n\n## [{cand}] - 2026-10-13\n\n### Added\n\n{added}\n{changed}" + f"\n## [{prev}] - 2026-09-01\n\n### Fixed\n\n- 旧\n", encoding="utf-8") + (d / "no-hooks").mkdir() + env = {**os.environ, "GIT_AUTHOR_NAME": "f", "GIT_AUTHOR_EMAIL": "f@f", + "GIT_COMMITTER_NAME": "f", "GIT_COMMITTER_EMAIL": "f@f", + "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath", + "GIT_CONFIG_VALUE_0": str(d / "no-hooks")} + for a in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]): + subprocess.run(["git", *a], cwd=d, env=env, check=True, capture_output=True) + return d + + +def pre(prev, cand, breaking, *args, **kw): + d = mk(prev, cand, breaking, **kw) + try: + r = subprocess.run(["python3", "scripts/release_preflight.py", "--version", cand, "--no-api"], + cwd=d, capture_output=True, text=True) + return r.stdout + r.stderr + finally: + shutil.rmtree(d, ignore_errors=True) +LIB +FULL="冻结 /api/v1;变更提前一个次版本通知。|表结构兼容窗口 2 个大版本。|配置项冻结,弃用先警告一个小版本。|0/1/2 语义固定。" + +pycheck "破坏性 + 0.x 只抬修订(0.1.0→0.1.3)⇒ 阻断,并给出抬档结论" <<'PY' +import kg_bump_lib as L # noqa +out = L.pre("0.1.0", "0.1.3", True) +good = "必须抬**次版本**" in out and "⛔ 抬档不够" in out +print(("✅ " if good else "❌ ") + "版本走向行:" + next((l.strip() for l in out.splitlines() + if "版本走向" in l), "(没有这行)")) +raise SystemExit(0 if good else 1) +PY +pycheck "破坏性 + 0.x 抬次(0.1.0→0.2.0)⇒ 该条不拦(✅ 抬档够)" <<'PY' +import kg_bump_lib as L # noqa +out = L.pre("0.1.0", "0.2.0", True) +good = "必须抬**次版本**" not in out and "✅ 抬档够" in out +print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "版本走向" in l), "")) +raise SystemExit(0 if good else 1) +PY +pycheck "破坏性 + ≥1.0.0 只抬次(1.2.3→1.3.0)⇒ 阻断(必须抬主)" <<'PY' +import kg_bump_lib as L # noqa +out = L.pre("1.2.3", "1.3.0", True) +good = "必须抬**主版本**" in out +print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "版本走向" in l), "")) +raise SystemExit(0 if good else 1) +PY +pycheck "首发 1.0.0 但《稳定契约承诺》留占位 ⇒ 阻断(报出占位与缺项)" <<'PY' +import kg_bump_lib as L # noqa +out = L.pre("0.9.3", "1.0.0", False, + stab_section="- **接口**(待填)\n- **数据模型**(待填)\n") +good = "稳定契约承诺" in out and "占位" in out and "没覆盖「配置」" in out +print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "1.0.0 前置" in l), "")) +raise SystemExit(0 if good else 1) +PY +pycheck "1.0.0 + 承诺段写实 + 发布记录里链接 ⇒ 该条不拦" <<'PY' +import kg_bump_lib as L # noqa +sec = ("- **接口**:冻结 /api/v1;变更提前一个次版本通知。\n" + "- **数据模型**:表结构兼容窗口 2 个大版本。\n" + "- **配置**:配置项冻结,弃用先警告一个小版本。\n" + "- **CLI 输出与退出码**:0/1/2 语义固定。\n") +out = L.pre("0.9.3", "1.0.0", False, stab_section=sec, link_in_record=True) +good = "候选进入 1.0.0" not in out and "✅ 已写实" in out +print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "1.0.0 前置" in l), "")) +raise SystemExit(0 if good else 1) +PY +pycheck "1.0.0 + 承诺段写实但发布记录里**没链接** ⇒ 仍阻断" <<'PY' +import kg_bump_lib as L # noqa +sec = ("- **接口**:冻结 /api/v1。\n- **数据模型**:兼容 2 个大版本。\n" + "- **配置**:配置项冻结。\n- **CLI 输出与退出码**:0/1/2 固定。\n") +out = L.pre("0.9.3", "1.0.0", False, stab_section=sec, link_in_record=False) +good = "候选进入 1.0.0" in out and "没有链接" in out +print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "1.0.0 前置" in l), "")) +raise SystemExit(0 if good else 1) +PY + printf '\n\033[1m════ 反向验收汇总 ════\033[0m\n ✅ 通过 %d | ❌ 失败 %d\n' "$pass" "$fail" -[ "$fail" -eq 0 ] || exit 1 \ No newline at end of file +[ "$fail" -eq 0 ] || exit 1 diff --git a/scripts/release_preflight.py b/scripts/release_preflight.py index c297034..04884a0 100644 --- a/scripts/release_preflight.py +++ b/scripts/release_preflight.py @@ -213,6 +213,61 @@ def _ver_tuple(v: str) -> tuple[int, int, int]: return a, b, c +BREAK_ITEM = re.compile(r"^\s*(?:[-*+]|\d+[.)])?\s*\*\*破坏性\*\*") +STABILITY_HEADING = re.compile(r"(?m)^##+\s*.*稳定契约承诺") +# 「稳定契约承诺」必须逐项覆盖的对外契约面(缺项 / 留占位 ⇒ 不许发 1.0.0) +STABILITY_ITEMS = ("接口", "数据模型", "配置", "CLI 输出与退出码") +STABILITY_PLACEHOLDERS = ("待填", "TODO", "TBD", "xxx", "XXX") +STABILITY_DOCS = ("docs/RELEASE_RUNBOOK.md", "docs/STABILITY_CONTRACT.md") + + +def released_in_commit(sha: str, *, root: Path = ROOT) -> list[str]: + """目标提交里 CHANGELOG 的已发布版本清单(顶部在前)。""" + rc, text = _git_read("show", f"{sha}:CHANGELOG.md", cwd=root) + return released_versions(text) if rc == 0 else [] + + +def breaking_in_commit(sha: str, version: str, *, root: Path = ROOT) -> int: + """**候选版本那一段**里的破坏性变化条数(发版事实看提交里的记录,不看工作区)。""" + rc, text = _git_read("show", f"{sha}:CHANGELOG.md", cwd=root) + if rc != 0: + return 0 + return sum(1 for line in block_of(text, version).splitlines() if BREAK_ITEM.match(line)) + + +def bump_level(prev: str | None, cand: str) -> str | None: + """抬了哪一档:major / minor / patch;比不出来(首发 / 号没涨)⇒ None。""" + if not (prev and SEMVER.match(prev) and SEMVER.match(cand)): + return None + a, b = _ver_tuple(prev), _ver_tuple(cand) + if b[0] > a[0]: + return "major" + if b[0] == a[0] and b[1] > a[1]: + return "minor" + if b[0] == a[0] and b[1] == a[1] and b[2] > a[2]: + return "patch" + return None + + +def stability_contract_problems(*, root: Path = ROOT) -> list[str]: + """《稳定契约承诺》是否写实(发 1.0.0 的前置条件);返回缺失项(空 = 合格)。""" + doc = next((root / d for d in STABILITY_DOCS + if (root / d).is_file() + and STABILITY_HEADING.search((root / d).read_text(encoding="utf-8"))), None) + if doc is None: + return [f"找不到《稳定契约承诺》段(在 {' 或 '.join(STABILITY_DOCS)} 里以 `## …稳定契约承诺` 开头)"] + text = doc.read_text(encoding="utf-8") + sec = text[STABILITY_HEADING.search(text).start():] + problems: list[str] = [] + for item in STABILITY_ITEMS: + if item not in sec: + problems.append(f"没覆盖「{item}」") + holes = [w for w in STABILITY_PLACEHOLDERS if w in sec] + if holes: + problems.append(f"还留着占位没填({'、'.join(holes)})") + return problems + + def readiness(f: dict) -> tuple[bool, list[str], list[str]]: """把**事实**翻成(是否就绪, 阻塞项, 准备建议)——纯函数,可用假事实直接自检。 @@ -245,6 +300,30 @@ def readiness(f: dict) -> tuple[bool, list[str], list[str]]: advice.append("下一次正式发布建议用 <主>.<次+1>.0(保证正式号严格大于此前所有开发号)") except ValueError: pass + prev, level = f.get("prev_version"), f.get("bump_level") + breaking_n = int(f.get("breaking_in_candidate") or 0) + if prev and level and breaking_n: + prev_major = _ver_tuple(str(prev))[0] + if prev_major == 0 and level == "patch": + blocks = (f"有 {breaking_n} 条破坏性变化,但 {prev} → {cand} 只抬了**修订**:" + f"0.x 阶段破坏性变化必须抬**次版本**(例如 " + f"0.{_ver_tuple(str(prev))[1] + 1}.0)—— 否则使用者按'只是修 bug'升级会踩坑") + blockers.append(blocks) + advice.append(f"把候选改成 0.{_ver_tuple(str(prev))[1] + 1}.0(抬次版本)," + f"并在发布文档里写清升级/迁移与回退") + if prev_major >= 1 and level != "major": + blockers.append(f"有 {breaking_n} 条破坏性变化,但 {prev} → {cand} 没有抬**主版本**:" + f"≥1.0.0 之后破坏性变化必须抬**主版本**(例如 {prev_major + 1}.0.0)") + advice.append(f"把候选改成 {prev_major + 1}.0.0(抬主版本)") + if level is None and prev and SEMVER.match(str(prev)) and SEMVER.match(str(cand)) \ + and _ver_tuple(str(cand)) <= _ver_tuple(str(prev)): + blockers.append(f"候选 {cand} 不大于上一个正式版本 {prev}(版本号只增不减)") + if f.get("first_1_0_0") and not f.get("stability_ok"): + problems = f.get("stability_problems") or ["(未取到具体缺失项)"] + blockers.append("候选进入 1.0.0 = 对外承诺契约稳定:必须先把《稳定契约承诺》写实、" + "并在候选发布记录里链接它 —— 缺:" + ";".join(str(x) for x in problems)) + advice.append("在 docs 的《稳定契约承诺》里逐项写实 接口 / 数据模型 / 配置 / CLI 输出与退出码," + "并在本次 CHANGELOG 的发布记录里链接该文档;不满足就先发 0.x") if f.get("candidate_tagged"): blockers.append(f"远端已经有 tag v{cand} ⇒ 不能覆盖已发布的 tag(版本号只增不减)") if f.get("dirty"): @@ -371,6 +450,36 @@ def main(argv: list[str]) -> int: tag = f"v{candidate}" required, policy_src = required_contexts() dev_number, dev_commits = current_dev_number() + rels = released_in_commit(want_sha) + prev_version = next((v for v in rels if v != candidate), None) + level = bump_level(prev_version, candidate) + breaking_n = breaking_in_commit(want_sha, candidate) + first_1_0_0 = (SEMVER.match(candidate) is not None and _ver_tuple(candidate)[0] == 1 + and (prev_version is None or _ver_tuple(prev_version)[0] == 0)) + stab_problems: list[str] = [] + if first_1_0_0: + stab_problems = stability_contract_problems() + rc, ctext = _git_read("show", f"{want_sha}:CHANGELOG.md") + blk = block_of(ctext, candidate) if rc == 0 else "" + if not (any(d in blk for d in STABILITY_DOCS) or "稳定契约承诺" in blk): + stab_problems.append("候选发布记录里没有链接《稳定契约承诺》" + f"(应引 {' 或 '.join(STABILITY_DOCS)})") + stab_ok = not stab_problems + level_cn = {"major": "主版本", "minor": "次版本", "patch": "修订"}.get(level or "", "看不出来(首发 / 号没涨)") + prev_major = _ver_tuple(prev_version)[0] if (prev_version and SEMVER.match(prev_version)) else None + if not breaking_n: + tail = "候选记录里没有破坏性变化 ⇒ 抬哪一档不限(但候选号必须 > 当前开发号)" + elif prev_major is None: + tail = f"候选记录里有 {breaking_n} 条破坏性变化(首发,无上一版本可比)" + else: + need = ("0.x 阶段:有破坏性必须抬次版本(或主版本)" if prev_major == 0 + else "≥1.0.0:有破坏性必须抬主版本") + ok = (level in ("minor", "major")) if prev_major == 0 else (level == "major") + tail = f"候选记录里有 {breaking_n} 条破坏性变化 ⇒ {need} {'✅ 抬档够' if ok else '⛔ 抬档不够'}" + print(f" 版本走向 : {prev_version or '(首发)'} → {candidate}(抬{level_cn})|{tail}") + if first_1_0_0: + print(f" 1.0.0 前置 : 《稳定契约承诺》" + f"{'✅ 已写实' if stab_ok else '⛔ 未写实:' + ';'.join(stab_problems)}") 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) @@ -437,6 +546,8 @@ def main(argv: list[str]) -> int: "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, + "prev_version": prev_version, "bump_level": level, "breaking_in_candidate": breaking_n, + "first_1_0_0": first_1_0_0, "stability_ok": stab_ok, "stability_problems": stab_problems, "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()), @@ -495,6 +606,9 @@ def self_test() -> int: "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, + # 版本走向/破坏性/1.0.0 前置(默认:无上一版本、无破坏性 ⇒ 新判据不参与) + "prev_version": None, "bump_level": None, "breaking_in_candidate": 0, + "first_1_0_0": False, "stability_ok": True, "stability_problems": [], } def rd(name: str, ready_want: bool, needle: str = "", **over) -> None: @@ -526,6 +640,31 @@ def self_test() -> int: 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) + # ── 抬档判据:破坏性变化 ⇒ 0.x 抬次、≥1.0.0 抬主 ── + rd("破坏性 + 0.x 只抬修订(0.1.0→0.1.3)⇒ 不就绪", + False, "必须抬**次版本**", candidate="0.1.3", prev_version="0.1.0", bump_level="patch", + breaking_in_candidate=1) + rd("破坏性 + 0.x 抬次(0.1.0→0.2.0)⇒ 该条不拦", True, + candidate="0.2.0", prev_version="0.1.0", bump_level="minor", breaking_in_candidate=2) + rd("破坏性 + ≥1.0.0 只抬次(1.2.3→1.3.0)⇒ 不就绪", + False, "必须抬**主版本**", candidate="1.3.0", prev_version="1.2.3", bump_level="minor", + breaking_in_candidate=1) + rd("破坏性 + ≥1.0.0 抬主(1.2.3→2.0.0)⇒ 该条不拦", True, + candidate="2.0.0", prev_version="1.2.3", bump_level="major", breaking_in_candidate=1) + rd("无破坏性 + 只抬修订(0.1.0→0.1.3)⇒ 抬档不拦", True, + candidate="0.1.3", prev_version="0.1.0", bump_level="patch", breaking_in_candidate=0) + rd("候选号没涨(0.2.0→0.2.0)⇒ 不就绪", False, "只增不减", + candidate="0.2.0", prev_version="0.2.0", bump_level=None, breaking_in_candidate=0) + # ── 1.0.0 首见:必须有写实的《稳定契约承诺》 ── + rd("首发 1.0.0 但《稳定契约承诺》没写实 ⇒ 不就绪", False, "稳定契约承诺", + candidate="1.0.0", prev_version="0.9.3", bump_level="major", first_1_0_0=True, + stability_ok=False, stability_problems=["没覆盖「配置」", "还留着占位没填(待填)"]) + rd("首发 1.0.0 且《稳定契约承诺》写实 ⇒ 该条不拦", True, + candidate="1.0.0", prev_version="0.9.3", bump_level="major", first_1_0_0=True, + stability_ok=True, stability_problems=[]) + rd("已经是 1.x 再抬到 2.0.0 ⇒ 不要求重写承诺段", True, + candidate="2.0.0", prev_version="1.4.0", bump_level="major", breaking_in_candidate=1, + first_1_0_0=False) rd("候选版本只在历史里(顶部是别的版本)⇒ 不就绪,且信息里给出真实顶部版本", False, "顶部**已发布版本是 `## [0.3.0]`", in_target_commit=False, top_in_commit="0.3.0", candidate="0.2.0") @@ -639,7 +778,7 @@ def self_test() -> int: print(f" · {p}", file=sys.stderr) return 1 print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;" - "就绪判定 21 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" + "就绪判定 32 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" "tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);" "『顶部版本才算数』与『写进提交才算数』用临时仓库验证;" "`--execute` 明确拒绝且零写操作、零 API 调用")