docs: 抬档门禁 + 1.0.0 前置(稳定契约承诺)

- **抬哪一档成为硬门禁**:破坏性条数取**目标提交里候选那一段** ⇒ `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。
This commit is contained in:
t
2026-10-13 17:13:44 +08:00
parent 041f18bfd2
commit 3b26457165
5 changed files with 169 additions and 1 deletions
+11
View File
@@ -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 版本号只有一个权威来源(每个项目**显式声明**它)
+13
View File
@@ -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 条 |
@@ -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-*` 仅保留参数兼容,传了会被
+3
View File
@@ -132,6 +132,9 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/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)。
+140 -1
View File
@@ -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 调用")