From 3b26457165856bf2d969791dea00bd9c35cb7139 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 13 Oct 2026 17:13:44 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=8A=AC=E6=A1=A3=E9=97=A8=E7=A6=81=20?= =?UTF-8?q?+=201.0.0=20=E5=89=8D=E7=BD=AE=EF=BC=88=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=89=BF=E8=AF=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **抬哪一档成为硬门禁**:破坏性条数取**目标提交里候选那一段** ⇒ `0.x` 有破坏性必须抬**次版本**(或主版本), `≥1.0.0` 有破坏性必须抬**主版本**;只抬修订/抬次不够都阻断。无破坏性时抬哪档不限, 但候选号必须严格大于上一正式版与当前开发号(号只增不减)。 - **`1.0.0` 首见的前置**:《稳定契约承诺》必须写实(接口 / 数据模型 / 配置 / CLI 输出与退出码, 不留 `待填`/`TODO`/`TBD`)并在 `## [1.0.0]` 记录里链接它;`1.x → 2.0.0` 不重复要求。 - 同步:正本 §6 五次修正(⑭⑮)、07 发版判据表、"抬哪一档"对照表、templates/release-preflight.py、 templates/README.md、04 §M(V-14~V-17)。 验证:templates/release-preflight.py --self-test 32 分支全过;试点仓夹具 91/0、make verify 15/15。 --- 01-规范-Git与CI-CD(Gitea版).md | 11 +++ 04-模板评估与修正.md | 13 +++ 07-发版操作说明(tag与Release).md | 2 + templates/README.md | 3 + templates/release-preflight.py | 141 ++++++++++++++++++++++++++++- 5 files changed, 169 insertions(+), 1 deletion(-) diff --git a/01-规范-Git与CI-CD(Gitea版).md b/01-规范-Git与CI-CD(Gitea版).md index 4d2f7fc..a4ab0ae 100644 --- a/01-规范-Git与CI-CD(Gitea版).md +++ b/01-规范-Git与CI-CD(Gitea版).md @@ -483,6 +483,17 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; } > ⑫ **浅克隆里拒绝算号**(退回 `-dev+g<短SHA>`),检出步骤在有 tag 时 `--unshallow` 补全历史; > ⑬ **发布时次版本加一**(`0.1.x` → 下一个 `0.2.0`),预检挡住"候选 <= 当前开发号"。 > +> **本节 2026-10-13 五次修正**(抬档判据硬化): +> ⑭ **抬哪一档由规则定、由预检拦**(硬门禁):破坏性条数取自**目标提交里候选版本那一段**(不看工作区)—— +> **`0.x` 阶段:有破坏性必须抬次版本(或主版本)**,只抬修订 ⇒ 阻断; +> **`≥1.0.0` 以后:有破坏性必须抬主版本**,抬次/修订 ⇒ 阻断; +> 无破坏性时抬哪一档不限,但候选号必须**严格大于**上一个正式版本、且大于当前开发号(号只增不减)。 +> ⑮ **`1.0.0` 首见 = 对外承诺契约稳定**,必须有写实的《稳定契约承诺》(覆盖 接口 / 数据模型 / +> 配置 / CLI 输出与退出码 四项、**不许留 `待填`/`TODO`/`TBD`**)**且候选发布记录里链接它**, +> 否则阻断;`1.x` 之后抬 `2.0.0` 不重复要求。 +> 判据的模板落点:`templates/release-preflight.py`(`bump_level()` / `breaking_in_commit()` / +> `stability_contract_problems()`)+ 项目 `docs/RELEASE_RUNBOOK.md` 的《稳定契约承诺》段。 +> > 操作步骤见同级 `07-发版操作说明(tag与Release).md`。 ### 6.1 版本号只有一个权威来源(每个项目**显式声明**它) diff --git a/04-模板评估与修正.md b/04-模板评估与修正.md index b4cc545..6d69859 100644 --- a/04-模板评估与修正.md +++ b/04-模板评估与修正.md @@ -236,3 +236,16 @@ | V-11 | 开发版本自动递增 | `display_version`(development)= **`<正式版本的主.次>.<自 `v<正式版本>` 以来的提交数>`**:`0.1.1`、`0.1.12`;恰在发布提交上 = 正式号;**不写进 CHANGELOG**(发布记录只在发版时动),由 `scripts/version.py` 从 git 算 | `version.py --self-test`:临时仓库打 tag → 恰在 tag 上 = `0.2.0`、tag 后第 2 个提交 = `0.2.2`、且**不等于**正式号;夹具里 Go 侧同场景再跑一遍 | | V-12 | 浅克隆会把提交数算少(会报错号) | 浅仓库里**拒绝算号**,退回 `<版本>-dev+g<短SHA>`;检出步骤在仓库有 tag 时 `git fetch --unshallow` 补全历史 | `version.py --self-test`(有基线但无 tag ⇒ 回退形态);Go 契约同样判 `--is-shallow-repository` | | V-13 | 正式版本可能撞上已经出现过的开发号 | 发版预检新增判据:**候选正式版本必须严格大于当前开发号**,否则阻断并建议"次版本加一" | `release_preflight.py --self-test` 两条分支(`0.1.5` vs 开发号 `0.1.12` ⇒ 阻断;`0.2.0` ⇒ 放行)+ 夹具 §23 两态 | + +--- + +## M. 第八轮修正(抬档判据硬化,2026-10-13) + +使用者问"什么情况打 0.2.0、什么情况打 1.0.0"。原来预检只**打印**破坏性条数、不拦抬档 ⇒ 补成硬门禁。 + +| 编号 | 事项 | 做法 | 负例/断言在哪 | +|---|---|---|---| +| V-14 | 有破坏性却只抬修订(`0.x`) | 破坏性条数取**目标提交里候选那一段**;`0.x` 阶段有破坏性 ⇒ 必须抬次版本(或主版本),只抬修订 ⇒ 阻断并建议 `0.<次+1>.0` | `release_preflight.py --self-test` 2 条 + 夹具 §24 两条(真实临时仓库跑预检) | +| V-15 | `≥1.0.0` 有破坏性却抬次/修订 | 必须抬**主版本**(`1.2.3 → 2.0.0`);抬次 ⇒ 阻断 | self-test 2 条 + 夹具 §24 一条 | +| V-16 | `1.0.0` 随便就发 | `1.0.0` 首见(上一版本是 `0.x`)⇒ 必须有写实的《稳定契约承诺》:覆盖 接口/数据模型/配置/CLI 输出与退出码、**不含 `待填`/`TODO`/`TBD`**,且候选发布记录里**链接**它 | self-test 3 条 + 夹具 §24 三条(占位 ⇒ 阻断;写实+链接 ⇒ 放行;写实但没链接 ⇒ 仍阻断) | +| V-17 | 号没涨也能"发" | 候选 ≤ 上一个正式版本 ⇒ 阻断(版本只增不减) | self-test 1 条 | diff --git a/07-发版操作说明(tag与Release).md b/07-发版操作说明(tag与Release).md index 7b7d830..01c51dc 100644 --- a/07-发版操作说明(tag与Release).md +++ b/07-发版操作说明(tag与Release).md @@ -97,6 +97,8 @@ python3 scripts/version.py --identity # 构建身份( | Release 不存在 | **只有 HTTP 404 才算「不存在」**;403/5xx/超时 ⇒ 查询异常 ⇒ 阻断 | | 首发版本号 | 还没有已发布版本 ⇒ 必须**显式指定**(`--version X.Y.Z`);**不把「记录里有这个版本」当作已发布** | | 版本号不与开发号撞车 | 候选正式版本必须**严格大于当前开发号**(预检会挡住 `<=` 的候选;下一次发布用次版本加一) | +| **抬档够不够(硬门禁)** | 候选发布记录里有破坏性变化 ⇒ `0.x` 阶段必须抬**次版本**(或主版本)、`≥1.0.0` 必须抬**主版本**;只抬修订 ⇒ 阻断。判据取**目标提交里候选那一段**,不看工作区 | +| **`1.0.0` 的前置** | 必须先把《稳定契约承诺》(接口 / 数据模型 / 配置 / CLI 输出与退出码)写实——**不许留占位**——并在 `## [1.0.0]` 记录里链接它;否则阻断。`1.x → 2.0.0` 不重复要求 | 预检输出分两段:**`准备建议`**(还差什么)与 **`发布就绪判定`**(是否就绪)。 ⚠️ 预检是**只读**的:**自动发布路径已禁用**(`--execute` / `--approve-*` 仅保留参数兼容,传了会被 diff --git a/templates/README.md b/templates/README.md index 325cf64..c63194d 100644 --- a/templates/README.md +++ b/templates/README.md @@ -132,6 +132,9 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///actions/ta `<主>.<次>.<自 v<正式版本> 以来的提交数>`(如 `0.1.12`;无基线/取不到 tag 时 `dev+g<短SHA>`), **不写进 CHANGELOG**、数字只增;**不许只显示上一正式版本号、不许自称正式版**。 正式发布时请把**次版本加一**(`0.1.x` → `0.2.0`),预检会挡住"候选 <= 当前开发号"。 + **抬档也是硬门禁**:候选发布记录里有破坏性变化 ⇒ `0.x` 必须抬**次版本**、`≥1.0.0` 必须抬**主版本** + (只抬修订 ⇒ 阻断);**发 `1.0.0` 前**必须在 `docs/RELEASE_RUNBOOK.md` 里把《稳定契约承诺》 + (接口 / 数据模型 / 配置 / CLI 输出与退出码)逐项写实、去掉占位,并在发布记录里链接它。 ⚠️ 浅克隆里拒绝算号(退回 `dev+g<短SHA>`),检出步骤在有 tag 时会 `--unshallow` 补全历史。 - ⚠️ **正式构建要核证据**:`--identity --release` 必须能核到 **完整 40 位 SHA + 指向该提交的注释 tag `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致**;缺一条 ⇒ **拒绝标为正式版**(rc=2)。 diff --git a/templates/release-preflight.py b/templates/release-preflight.py index c297034..04884a0 100644 --- a/templates/release-preflight.py +++ b/templates/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 调用")