docs: 正式构建核证据 + 预检两段式与就绪判据 + CI 必需 context(口径变更)

同步三项收紧(模板与正本,本轮不推、不打 tag、不部署):

- **正式构建要核证据**:完整 40 位 SHA + 指向该提交的注释 tag v<版本号> + 该提交里
  CHANGELOG 顶部版本一致;缺一条 ⇒ 拒绝标为正式版(rc=2,身份退回 development)。
  负例:有版本无 tag / unknown 或短 SHA / tag 指错提交 / 轻量 tag / 版本不一致;
  **测试 tag 只在临时仓库里创建**。
- **发版预检分两段**:`准备建议` 与 `发布就绪判定`;就绪 = 候选版本已写进目标提交
  (工作区写了没提交不算)、完整 SHA、记录检查、**必需 context 集合全部 success**、
  远端 tag 已核对未占用、Release 不存在;首发必须显式指定版本号。
  预检只读:不执行也不推荐自动发布路径。
- **CI 就绪**:必需 context 集合来自 .gitea/required-contexts.txt(缺失/为空 ⇒ 没有策略 ⇒ 阻断,
  不许拿观察到的状态顶替);失败/缺项/pending/查询异常都阻断;
  **Release 查询只有 HTTP 404 才算不存在**。
- 模板同步:templates/version.py、templates/release-preflight.py;templates/README.md
  (即生成的 AGENTS.md)补两段并点明 required-contexts.txt 故意留空须填写;
  01 正本 §6 三次修正说明(⑧⑨⑩)、§6.5 三态表、§6.6 必查项;07 §3.2 就绪判据表;
  04 §J(V-5~V-8 四条,含负例在哪)。

验证:templates 两个自检全过(构建身份 7 组正/负例;预检就绪 19 分支 + 五个 HTTP 码);
new-project.sh bash -n 通过;试点仓反向夹具 83/0、make verify 15/15。
This commit is contained in:
姚章浩
2026-10-13 15:26:50 +08:00
parent 79c154f9af
commit e3bb6b2018
6 changed files with 495 additions and 60 deletions
+18 -2
View File
@@ -458,6 +458,20 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
> 只是**构建时不要用 `git describe` 取版本号**(浅克隆里它会静默退化)。
> ⑦ **测试 tag 只能在临时测试仓库里创建**,不许在真实仓里造 tag 试检查器。
>
> **本节 2026-10-13 三次修正**(正式构建证据 / 预检两段式 / CI 就绪判据):
> ⑧ **正式构建身份不是"加个 --release 就算"**:必须核**完整 40 位 SHA + 指向该提交的合规注释 tag
> `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致**;缺任何一条 ⇒ **拒绝标为正式版**
> (rc=2,身份退回 development 并写明缺什么)。负例:有版本无 tag、unknown/短 SHA、tag 指错提交、
> 轻量 tag、版本不一致。
> ⑨ **发版预检分两段**:`准备建议`(还差什么)与 `发布就绪判定`(是否就绪)。
> **就绪 = 候选版本已写进目标提交**(查 `git show <sha>:CHANGELOG.md`,工作区里写了没提交不算)、
> 完整 SHA 指向准确提交、记录检查通过、**必需 context 集合全部 success**、远端 tag 已核对未被占用、
> Release 不存在且查询无异常。首发按**显式指定版本**处理,**不把"记录里有这个版本"当作已发布**。
> ⚠️ 预检**只读**:不执行、也不在输出里推荐自动发布路径(正式发版动作由人按 `07-发版操作说明` 执行)。
> ⑩ **CI 就绪判据**:必需 context 集合来自 `.gitea/required-contexts.txt`(缺失/为空 ⇒ **没有策略**,
> 直接阻断,不许拿"体检时观察到的状态"顶替);**失败 / 缺项 / pending / 查询异常一律阻断**。
> **Release 查询只有 HTTP 404 才算「不存在」**,403/5xx/超时都算查询异常,不得当作可发。
>
> 操作步骤见同级 `07-发版操作说明(tag与Release).md`。
### 6.1 版本号只有一个权威来源(每个项目**显式声明**它)
@@ -535,7 +549,7 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
| 状态 | 含义 | 证据(缺一即不算) |
|---|---|---|
| **CI 成功** | 这次提交通过了必需检查 | 提交上的 status context + run 链接 + **完整 SHA** |
| **正式发版** | 批准后打了 tag / 建了 Release | 远端 tag 的 SHA == 验收过的提交;Release 的 `tag_name` 读回一致 |
| **正式发版** | 批准后打了 tag / 建了 Release | 远端 tag 的 SHA == 验收过的提交(**注释 tag**);Release 的 `tag_name` 读回一致;构建身份 `build_kind=release` 且 `release_version` 有 tag 证据(§6.2 ⑧) |
| **部署** | 某个环境真的在跑这个版本 | `/version` 的 `commit_sha` + 部署读回证据(§5.3) |
"CI 绿"不等于"发过版","发过版"不等于"部署了" —— 汇报时说清是哪一个。
@@ -551,7 +565,9 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
(轻量 tag 不合格);**核对必须真的问远端**(`--require-remote`)——
拿不到远端清单 ⇒ 红(没核对就不许说"校验成功");非正式 tag(如本地 `x`)不当作发布版本;
5. 缺失 / 格式错 / 版本漂移 ⇒ **失败**;
6. **还没有正式版本**(新仓常态)⇒ 放行:不许因为"没有已发布版本"判红,首个版本号由人指定。
6. **还没有正式版本**(新仓常态)⇒ 放行:不许因为"没有已发布版本"判红,首个版本号由人指定;
7. **正式构建身份的证据**:`release_version` 非空 ⇒ 必须有指向该提交的注释 tag(§6.2 ⑧);
`version.py --self-test` 与版本契约测试双跑(正例 + 无 tag / 短 SHA / tag 不符 / 轻量 tag 负例)。
⚠️ **没有 tag 不算失败**:开发版本不打 tag 是常态,**日常 CI 不许因此变红**。
参考实现:`scripts/check_changelog.py`(`--self-test` 逐条证明会红;`--tags [--require-remote]` 带 tag 一致性)
+17
View File
@@ -197,3 +197,20 @@
③ 新增构建身份与"开发构建不许自称正式版";④ "还没有正式版本"放行、首个版本号由人指定;
⑤ 拿不到远端 tag ⇒ 红;⑥ 修正"CI 取不到 tag 所以不用 tag"的过时口径(正式版本**就是**用注释 tag,
只是**构建时不要用 `git describe` 取版本号**);⑦ 测试 tag 只在临时仓库创建。
---
## J. 第五轮修正(正式构建证据 / 预检就绪判据 / CI context 集合,2026-10-13)
对着"发布就绪"这件事把判据钉死:**不许靠一个开关或一条观察到的状态就下结论**。
| 编号 | 问题 | 修法 | 负例在哪 |
|---|---|---|---|
| V-5 | **正式构建只认一个 `--release` 标志**:有版本记录就能自称正式版,与"线上跑的是哪个提交"脱钩 | `--identity --release` 改为**核证据**:完整 40 位 SHA + **指向该提交的注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致;缺一条 ⇒ rc=2、身份退回 `development` 并列出缺什么 | `version.py --self-test`:**有版本但无 tag / unknown SHA / 短 SHA / tag 指错提交 / 轻量 tag / 版本不一致** 六条负例 + 证据齐正例(全部临时仓库建 tag);`internal/version` 契约测试再加一条 `TestReleaseBuildRequiresEvidence`(go 侧独立复核 tag 与 SHA) |
| V-6 | **把候选版本"写在文件里"当成已发布**;预检结论与"能不能发"混在一段里 | 预检输出分两段:`准备建议` / `发布就绪判定`;**就绪要求候选版本已写进目标提交**(`git show <sha>:CHANGELOG.md`);首发按**显式指定版本**处理;已发布的证据是 **tag**,不是记录里的条目 | 夹具 §23:临时仓库里"记录只在工作区" ⇒ 明说还没写进目标提交且不就绪;提交后该条消失但仍不就绪(未核对远端);输出里**不出现** `--execute` 建议行 |
| V-7 | **CI 就绪用"任意 context 成功"判定**(一个无关检查成功就能骗过) | 必需 context 集合来自 `.gitea/required-contexts.txt`(缺失/为空 ⇒ 没有策略 ⇒ 阻断);**全部 success 才算通过**,失败/缺项/pending/查询异常一律阻断 | `release_preflight.py --self-test`:就绪判定 19 个分支(pending / failure / 缺项 / 查询异常 / 无策略 / tag 占用 / 未核对远端 / 首发未显式 / 记录未进提交 …逐条阻断) |
| V-8 | **Release 查询非 200 一律当"不存在"** | `release_state()`:**只有 HTTP 404 代表不存在**;403/5xx/超时都算查询异常 ⇒ 阻断(不得当作可发) | 自检里 200/404/500/403/0 五个码逐条断言 |
**生成器同步**:`templates/version.py`、`templates/release-preflight.py` 随本次修正更新;
`templates/README.md`(也就是生成出来的 `AGENTS.md`)补上"正式构建要核证据"和"就绪判据"两段,
并点明 `.gitea/required-contexts.txt` 故意留空、必须逐行填真实 context。
+25 -3
View File
@@ -79,13 +79,35 @@ python3 scripts/check_changelog.py --tags --require-remote # 结构 + 正式 t
python3 scripts/version.py --identity # 构建身份(开发/发布、显示版本、完整 SHA)
```
### 3.2 跑发布预检(只读,不产生任何副作用)
### 3.2 就绪判据(预检说的「发布就绪」是什么意思)
**就绪 = 证据齐**,缺一条都不算:
| 判据 | 说明 |
|---|---|
| 候选版本**已写进目标提交** | 查 `git show <sha>:CHANGELOG.md`;只在工作区写了、没提交 ⇒ **不就绪** |
| 完整 40 位 SHA 指向准确提交 | 目标提交 == HEAD(或显式 `--sha` 逐字符一致) |
| 记录检查通过 | `check_changelog.py --tags --require-remote`(四类小节、破坏性变化带文档链接、tag 一致性) |
| **必需 context 集合全部 success** | 集合来自 `.gitea/required-contexts.txt`(缺失/空 ⇒ 没有策略 ⇒ 阻断);**失败 / 缺项 / pending / 查询异常一律阻断** |
| 远端 tag 已核对且未占用 | 查不到远端 ⇒ 阻断(未核验不等于可以发) |
| Release 不存在 | **只有 HTTP 404 才算「不存在」**;403/5xx/超时 ⇒ 查询异常 ⇒ 阻断 |
| 首发版本号 | 还没有已发布版本 ⇒ 必须**显式指定**(`--version X.Y.Z`);**不把「记录里有这个版本」当作已发布** |
预检输出分两段:**`准备建议`**(还差什么)与 **`发布就绪判定`**(是否就绪)。
⚠️ 预检是**只读**的:不执行、也不推荐自动发布路径 —— 正式发版动作由人按本文执行。
### 3.3 跑发布预检(只读,不产生任何副作用)
```bash
python3 scripts/release_preflight.py # 默认建议 patch
python3 scripts/release_preflight.py --bump minor # 或 --version 0.2.0
python3 scripts/release_preflight.py # 默认建议 patch
python3 scripts/release_preflight.py --version 0.2.0 # 首发必须显式指定版本号
python3 scripts/release_preflight.py --no-api # 显式不查远端 ⇒ 必然"不就绪"(只列事实)
```
自查(不依赖网络):`python3 scripts/release_preflight.py --self-test`
(就绪判定逐条阻断 + `git show <sha>:CHANGELOG.md` 的临时仓库验证)与
`python3 scripts/version.py --self-test`(正式构建证据的正/负例,全部在临时仓库里造 tag)。
它会打印:待发布版本 / **完整提交 SHA** / 该提交上的 CI 成功证据 / `[Unreleased]` 原文 /
同名**远端 tag 与 Release 是否已存在** / 遗留风险与未验证项,并在最后给出
**"可以发"或"不能发 + 阻塞项"**。
+10 -1
View File
@@ -130,6 +130,9 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/ta
**不要求每个提交升版本**)。过程记录放 `docs/archive/`,未合并事项放 `docs/BACKLOG.md`。
- **构建身份**:`python3 scripts/version.py --identity` —— 开发构建显示 `X.Y.Z-dev+g<短SHA>`
(没有正式基线时 `dev+g<短SHA>`),**不许只显示上一正式版本号、不许自称正式版**。
- ⚠️ **正式构建要核证据**:`--identity --release` 必须能核到 **完整 40 位 SHA + 指向该提交的注释 tag
`v<版本号>` + 该提交里 CHANGELOG 顶部版本一致**;缺一条 ⇒ **拒绝标为正式版**(rc=2)。
自查:`python3 scripts/version.py --self-test`(正例 + 无 tag / 短 SHA / tag 不符 / 轻量 tag 负例)。
- **准备发版**时,先给用户这五样,批准后才动:
1. **版本建议**(`python3 scripts/release_preflight.py` 给的候选值;
**还没有正式发布过时版本号必须由用户显式指定**,预检会把它列成阻塞项);
@@ -142,6 +145,12 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/ta
- 正式发版用**注释 tag**(`v<版本号>`)指向验收过的提交;Gitea Release 关联该 tag,
说明**来自 CHANGELOG**,不另写一份。
- 自查命令:`python3 scripts/check_changelog.py --tags --require-remote`(结构 + tag 一致性,**真查远端**);
`python3 scripts/release_preflight.py`(**只读**预检,不给"可以发"就是不能发)。
`python3 scripts/release_preflight.py`(**只读**预检:`准备建议` + `发布就绪判定`;
**不给"就绪"就是不能发**,且它**不执行也不推荐自动发布路径**)。
- ⚠️ **就绪判据**(缺一条都不算):候选版本**已写进目标提交**(工作区里写了没提交不算)、
完整 SHA 指向准确提交、记录检查通过、**必需 context 集合全部 success**
(集合在 `.gitea/required-contexts.txt`,缺失/为空 ⇒ 没有策略 ⇒ 阻断;失败/缺项/pending/查询异常都阻断)、
远端 tag 已核对未占用、**Release 查询只有 HTTP 404 才算不存在**(403/5xx/超时算异常 ⇒ 阻断)。
`.gitea/required-contexts.txt` **故意留空**:请按本仓真实的 status context 逐行填好,否则不能判就绪。
- ⚠️ **开发版本没打 tag 是正常的**:日常 CI 不许因此变红。
````
+241 -45
View File
@@ -9,6 +9,11 @@
三个都给齐、且预检没有阻塞项,才会 `git tag -a` + `git push <tag>` + 建 Release;
执行后**读回**(远端 tag SHA == 本地打的那个提交、Release 的 tag_name/version 一致)才算成功。
3. **拿不到证据就说拿不到**:没有凭据、查不到 CI 状态时打印"未核对",不写成"已通过"。
4. **输出分两段**:`准备建议`(还差什么)与 `发布就绪判定`(是否就绪);**就绪才算可发**。
5. **就绪 = 证据齐**:候选版本**已写进目标提交**、完整 SHA 指向准确提交、
**必需 context 集合全部 success**(集合来自 `.gitea/required-contexts.txt`,缺项/待定/失败/查询异常都阻断)、
远端 tag 已核对且未占用、**Release 查询只有 HTTP 404 才算「不存在」**。
6. **本预检不执行、也不推荐自动发布路径**:正式发版动作由人按 `docs/RELEASE_RUNBOOK.md` 执行。
用法:
python3 scripts/release_preflight.py # 建议 patch(默认)
@@ -138,6 +143,107 @@ def server_base() -> str:
return f"{u.scheme}://{u.netloc}"
REQUIRED_CONTEXTS_FILE = ROOT / ".gitea" / "required-contexts.txt"
def required_contexts(root: Path = ROOT) -> tuple[list[str], str]:
"""明确的必需 context 集合(一行一个,`#` 之后是注释)。
⚠️ 这份文件是"要求哪些检查"的**唯一来源**:缺失或只有注释 ⇒ **没有策略**
⇒ 就绪判定直接阻断(不许拿"体检时观察到哪些状态"顶替)。
"""
f = root / ".gitea" / "required-contexts.txt"
if not f.is_file():
return [], f"没有 {f.name}"
out = [l.split("#", 1)[0].strip() for l in f.read_text(encoding="utf-8").splitlines()]
out = [x for x in out if x]
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 就绪"的分界线:工作区里写了、没提交 ⇒ 还不是就绪。
"""
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))
def release_state(status: int) -> str:
"""Release 查询结果的语义:**只有 HTTP 404 代表「不存在」**,其余非 200 一律算查询异常。"""
if status == 200:
return "exists"
if status == 404:
return "absent"
return "error"
def readiness(f: dict) -> tuple[bool, list[str], list[str]]:
"""把**事实**翻成(是否就绪, 阻塞项, 准备建议)——纯函数,可用假事实直接自检。
就绪 = 证据齐:候选版本已写进目标提交、工作区干净、完整 SHA 指向准确提交、
记录检查通过、远端 tag 已核对且未占用、**必需 context 集合全部 success**、
Release 不存在(查询异常也不算就绪)。
"""
blockers: list[str] = []
advice: list[str] = []
cand = f.get("candidate") or "?"
if f.get("first_release") and not f.get("explicit_version"):
blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);"
f"{cand} 只是建议值,**不当作已发布**")
if not f.get("in_target_commit"):
blockers.append(f"候选版本 {cand} 还没写进目标提交(该提交的 CHANGELOG.md 里没有 `## [{cand}]`)"
f"—— 版本记录要先提交,再发版")
advice.append(f"把 `## [Unreleased]` 改成 `## [{cand}] - YYYY-MM-DD`(四类小节、"
f"破坏性变化带发布文档链接)并**提交**,然后重跑预检")
if f.get("candidate_tagged"):
blockers.append(f"远端已经有 tag v{cand} ⇒ 不能覆盖已发布的 tag(版本号只增不减)")
if f.get("dirty"):
blockers.append(f"工作区有 {f['dirty']} 处未提交")
if not f.get("head_full"):
blockers.append("HEAD 不是完整 40 位 SHA")
if not f.get("sha_is_head"):
blockers.append("目标提交 != HEAD(发版必须指向准确提交)")
if not f.get("checker_ok"):
blockers.append("CHANGELOG / 版本记录检查不通过")
if not f.get("unreleased_present"):
advice.append("CHANGELOG 里没有 `[Unreleased]` 段:发版前把这次改动写进记录")
if not f.get("remote_tags_checked"):
blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发")
if f.get("contexts_error"):
blockers.append(f"查不到该提交的 CI 状态({f['contexts_error']})")
req = f.get("required_contexts") or []
ctx = f.get("contexts")
if not req:
blockers.append("没有明确的必需 context 集合(`.gitea/required-contexts.txt` 缺失或为空)"
"—— 不许拿「体检时观察到的状态」顶替")
advice.append("把本仓真正必需的 status context 一行一个写进 `.gitea/required-contexts.txt`"
"(字符串要与 `GET /commits/<sha>/status` 里的 `context` 逐字符相同)")
elif not f.get("contexts_error"):
if ctx is None:
blockers.append("没有取到该提交的 status context")
else:
missing = [c for c in req if c not in ctx]
bad = [(c, ctx[c]) for c in req if c in ctx and ctx[c] != "success"]
if missing:
blockers.append(f"必需 context 缺项(该提交上没有这些状态):{missing}")
if bad:
blockers.append("必需 context 未全部 success:" + "、".join(f"{c}={v}" for c, v in bad))
if f.get("release_query_error"):
blockers.append(f"Release 查询异常({f['release_query_error']})—— 只有 HTTP 404 才算「不存在」,"
f"查询异常不得当作可发")
elif f.get("release_exists"):
blockers.append(f"Gitea Release {f.get('tag') or cand} 已存在")
if f.get("no_api"):
blockers.append("没有核对远端 Release / CI 状态(缺凭据或显式 --no-api)")
if not blockers:
advice.append("证据齐了:正式发版动作由人按 docs/RELEASE_RUNBOOK.md 执行(本预检只读、不执行)")
return (not blockers), blockers, advice
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--bump", default="patch", choices=["major", "minor", "patch"])
@@ -207,83 +313,93 @@ def main(argv: list[str]) -> int:
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 里出现过(版本号只增不减)")
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]})")
print(f" 必需 context : {len(required)} 条({policy_src})"
+ (":" + "、".join(required) if required else ""))
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:
remote_tags_checked = rc == 0 and not args.no_api
if not remote_tags_checked:
why = "显式 --no-api" if args.no_api else "`git ls-remote --tags origin` 失败"
print(f"\n 远端 tag : **未核对**({why})—— 不能声称 tag 已核验")
blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发")
print(f" 远端 tag : **未核对**({why})—— 不能声称 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 '(无)'}")
print(f" 远端 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 '(无)'}")
contexts = None
contexts_error = ""
release_exists = False
release_query_error = ""
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, _ = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token)
state = release_state(st)
note = {"exists": "已存在 ⛔ 不能覆盖", "absent": "不存在(可发)"}.get(
state, f"查询异常(HTTP {st})⛔ 只有 404 才算「不存在」,不得当作可发")
print(f" Gitea Release: {tag} {note}")
release_exists = state == "exists"
if state == "error":
release_query_error = f"HTTP {st}"
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 证据不足)")
contexts = {x.get("context"): x.get("status") for x in stt.get("statuses") or []}
ctx_line = "、".join(f"{c}={v}" for c, v in contexts.items()) or "(这个提交上一个状态都没有)"
else:
contexts_error = f"HTTP {st}"
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())
run_line = (f"run #{mine[0].get('run_number')} {mine[0].get('status')} "
f"{mine[0].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:
facts = {
"candidate": candidate, "tag": tag, "first_release": first_release,
"explicit_version": bool(args.version), "in_target_commit": in_target,
"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()),
"candidate_tagged": bool(remote_tags_checked and tag in remote_tags),
"remote_tags_checked": remote_tags_checked,
"required_contexts": required, "contexts": contexts, "contexts_error": contexts_error,
"release_exists": release_exists, "release_query_error": release_query_error,
"no_api": bool(args.no_api),
}
ready, blockers, advice = readiness(facts)
print("\n── 准备建议(先做完这些,才有「就绪」可言)──")
for a in (advice or ["(没有额外建议)"]):
print(f" · {a}")
print("\n── 发布就绪判定 ──")
if ready:
print(f" ✅ 发布就绪:{candidate}(tag {tag} → 提交 {head[:12]})")
print(" ⚠️ 正式发版动作**不在本预检里执行、也不推荐自动路径**:"
"步骤与读回判据见 docs/RELEASE_RUNBOOK.md,由人按批准执行。")
else:
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}")
print(f" ⇒ 现在**不能**发 {candidate}(未就绪)")
if not args.execute:
return 1 if blockers else 0
@@ -356,12 +472,92 @@ def self_test() -> int:
# 版本号推进
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("版本推进算错了")
# ── 就绪判定:全部用**假事实**驱动纯函数(不打真 tag、不碰网络)──
base_facts = {
"candidate": "0.2.0", "tag": "v0.2.0", "first_release": False,
"explicit_version": True, "in_target_commit": True, "dirty": 0,
"head_full": True, "sha_is_head": True, "checker_ok": True,
"unreleased_present": True, "candidate_tagged": False,
"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,
}
def rd(name: str, ready_want: bool, needle: str = "", **over) -> None:
f = {**base_facts, **over}
ready, blockers, _ = readiness(f)
joined = " / ".join(blockers)
if ready != ready_want or (needle and needle not in joined):
problems.append(f"就绪判定[{name}]:ready={ready}(期望 {ready_want})blockers={joined[:160]}")
rd("证据齐 ⇒ 就绪", True)
rd("必需 context pending ⇒ 不就绪", False, "未全部 success", contexts={"build / build (push)": "pending"})
rd("必需 context failure ⇒ 不就绪", False, "未全部 success", contexts={"build / build (push)": "failure"})
rd("必需 context 没被报出来(缺项)⇒ 不就绪", False, "缺项", contexts={"other / other (push)": "success"})
rd("有别的 context 成功但必需项没报 ⇒ 仍不就绪", False, "缺项",
required_contexts=["build / build (push)", "lint / lint (push)"])
rd("status 查询异常 ⇒ 不就绪", False, "查不到该提交的 CI 状态", contexts_error="HTTP 500")
rd("没有必需 context 策略 ⇒ 不就绪", False, "没有明确的必需 context 集合", required_contexts=[])
rd("Release 查询异常 ⇒ 不就绪", False, "Release 查询异常", release_query_error="HTTP 500")
rd("Release 已存在 ⇒ 不就绪", False, "已存在", release_exists=True)
rd("远端 tag 已被占用 ⇒ 不就绪", False, "不能覆盖已发布的 tag", candidate_tagged=True)
rd("没核对远端 tag ⇒ 不就绪", False, "没有核对远端 tag", remote_tags_checked=False)
rd("首发未显式指定版本 ⇒ 不就绪", False, "首个正式版本号必须由人显式指定",
first_release=True, explicit_version=False)
rd("首发**显式**指定且记录已进提交 ⇒ 就绪", True,
first_release=True, explicit_version=True, contexts={"build / build (push)": "success"})
rd("候选版本没进目标提交 ⇒ 不就绪", False, "还没写进目标提交", in_target_commit=False)
rd("工作区脏 ⇒ 不就绪", False, "未提交", dirty=3)
rd("目标提交 != HEAD ⇒ 不就绪", False, "目标提交 != HEAD", sha_is_head=False)
rd("记录检查不通过 ⇒ 不就绪", False, "检查不通过", checker_ok=False)
rd("缺凭据/--no-api ⇒ 不就绪", False, "没有核对远端 Release", no_api=True)
# 候选记录存在 ≠ 已发布:三种证据都齐时应当就绪,且阻塞项里不该出现"已发布"
_, bl, _ = readiness(base_facts)
if any("已发布" in b for b in bl):
problems.append(f"把『记录里有这个版本』误当成『已发布』:{bl}")
for st, want in ((200, "exists"), (404, "absent"), (500, "error"), (403, "error"), (0, "error")):
if release_state(st) != want:
problems.append(f"Release 查询语义错:HTTP {st} 判成了 {release_state(st)}(期望 {want})")
# ── 临时仓库:候选版本"写进提交才算数" ──
import tempfile as _tf
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## [Unreleased]\n\n### Added\n\n- x\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("未提交的版本被当成了『已写进目标提交』")
(dd / "CHANGELOG.md").write_text(
"# Changelog\n\n## [0.2.0] - 2026-02-01\n\n### Fixed\n\n- y\n", encoding="utf-8")
if version_in_commit(sha, "0.2.0", root=dd):
problems.append("工作区改了但没提交,仍被判成『已写进目标提交』")
subprocess.run(["git", "add", "-A"], cwd=dd, env=env, check=True)
subprocess.run(["git", "commit", "-q", "-m", "chore(release): 0.2.0"], cwd=dd, env=env, check=True)
sha2 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=dd, capture_output=True,
text=True).stdout.strip()
if not version_in_commit(sha2, "0.2.0", root=dd):
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 在预检路径上全部被拒;版本推进正确")
print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;"
"就绪判定 19 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、"
"tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);"
"『写进提交才算数』用临时仓库验证")
return 0
+184 -9
View File
@@ -14,6 +14,10 @@ CI 只做**核验**(tag 版本 == 它指到那个提交里的版本记录)
python3 scripts/version.py --identity --release # 声明发布构建;没有已发布版本 ⇒ 拒绝(rc=2)
python3 scripts/version.py --check # 打印版本号并跑结构自检(等价 check-changelog.py)
**正式构建身份不是"加个 --release 标志"就算**:`--identity --release` 会**核证据**——
完整 40 位 SHA + 一个**指向该提交的合规注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致;
缺任何一条 ⇒ **拒绝标为正式版**(rc=2,JSON 里带 `release_blocked` 说明缺什么)。
构建身份字段(`--identity`):
release_version 正式版本基线;本仓还没有正式发布过 ⇒ `null`
build_kind development | release(**只有显式声明**才是 release,普通 main CI 永远不是)
@@ -31,6 +35,7 @@ import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -67,13 +72,60 @@ def display_version(release_version: str | None, kind: str, sha: str) -> str:
return f"dev+g{short}" if short else "dev"
def _git(*args, cwd: Path = ROOT) -> tuple[int, str]:
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
def release_evidence(version: str | None, sha: str, root: Path = ROOT) -> list[str]:
"""正式构建要核的证据;返回缺失项(空列表 = 证据齐)。
三条都必需(缺一条就**不许**把产物标成正式版):
1. 完整 40 位提交 SHA —— 没有准确提交就谈不上"正式版";
2. 该提交上有**注释 tag** `v<版本号>`(`git tag --points-at <sha>` + `cat-file -t` 判类型);
3. 该提交里的 `CHANGELOG.md` 顶部已发布版本 == 这个版本号(版本一致性)。
"""
problems: list[str] = []
if not version:
problems.append("CHANGELOG.md 里没有已发布版本条目(没有正式版本基线)")
if not FULL_SHA.match(sha or ""):
problems.append(f"提交 SHA 不是完整 40 位({sha!r})—— 没有准确提交就不能标为正式版")
if version and FULL_SHA.match(sha or ""):
name = f"v{version}"
rc, out = _git("tag", "--points-at", sha, cwd=root)
tags = out.split() if rc == 0 else []
if name not in tags:
problems.append(f"提交 {sha[:12]} 上没有 tag {name}"
f"(正式构建必须有一个指向该提交的合规 tag;现有指向该提交的 tag:{tags or '无'})")
else:
rc, kind = _git("cat-file", "-t", f"refs/tags/{name}", cwd=root)
if rc != 0 or kind.strip() != "tag":
problems.append(f"tag {name} 不是**注释 tag**(`git tag -a`)—— 轻量 tag 不算发布证据")
rc, blob = _git("show", f"{sha}:CHANGELOG.md", cwd=root)
if rc != 0:
problems.append(f"取不到该提交里的 CHANGELOG.md({sha[:12]})—— 没法核对版本一致性")
else:
m = RELEASE_HEADING.search(blob)
top = m.group(1) if m else None
if top != version:
problems.append(f"该提交里 CHANGELOG 顶部的已发布版本是 {top!r},与 {version!r} 不一致")
return problems
def identity(*, release: bool = False, built_at: str | None = None,
root: Path = ROOT, sha: str | None = None) -> dict:
"""构建身份。`release=True` 但没有已发布版本 ⇒ 由调用方拒绝(见 main)。"""
"""构建身份(不含证据核验,供 `--identity` 的非发布路径与测试使用)。"""
return build_identity(release=release, built_at=built_at, root=root, sha=sha)[0]
def build_identity(*, release: bool = False, built_at: str | None = None,
root: Path = ROOT, sha: str | None = None) -> tuple[dict, list[str]]:
"""构建身份 + 证据缺失项。`release=True` 但证据不齐 ⇒ 身份退回 development 并给出原因。"""
rel = current_version(root / "CHANGELOG.md")
sha = sha if sha is not None else git_sha(root)
kind = "release" if (release and rel) else "development"
return {
problems = release_evidence(rel, sha, root) if release else []
kind = "release" if (release and not problems) else "development"
ident = {
"release_version": rel,
"build_kind": kind,
"commit_sha": sha,
@@ -81,18 +133,141 @@ def identity(*, release: bool = False, built_at: str | None = None,
"built_at": built_at or os.environ.get("BUILT_AT") or os.environ.get("BUILD_TIME")
or dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
if problems:
ident["release_blocked"] = problems
return ident, problems
# ── 反向自检:**全部在临时仓库里造 tag**,不给真仓造任何 tag ─────────────────
_SAMPLE = """# Changelog
## [Unreleased]
### Added
- 还没发布
## [0.2.0] - 2026-02-01
### Fixed
- y
"""
def _tmp_repo(changelog: str = _SAMPLE) -> tuple[Path, str, dict]:
"""造一个临时仓库(一个提交),返回 (目录, HEAD, git 环境)。"""
d = Path(tempfile.mkdtemp(prefix="kg-ver-"))
(d / "no-hooks").mkdir(exist_ok=True)
env = {**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(d / "no-hooks")}
(d / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
for args in (["init", "-q", "-b", "main"], ["add", "-A"],
["commit", "-q", "-m", "chore: 夹具提交"]):
subprocess.run(["git", *args], cwd=d, env=env, check=True)
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
text=True).stdout.strip()
return d, sha, env
def self_test() -> int:
problems: list[str] = []
dirs: list[Path] = []
def case(name: str, cond: bool, detail: str = "") -> None:
if not cond:
problems.append(f"{name} —— {detail}")
# ① 证据齐:注释 tag 指向该提交 + 提交内 CHANGELOG 一致 ⇒ 正式构建
d, sha, env = _tmp_repo(); dirs.append(d)
subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d, env=env, check=True)
ident, blocked = build_identity(release=True, root=d, sha=sha, built_at="t")
case("证据齐 ⇒ build_kind=release", ident["build_kind"] == "release" and not blocked, f"{ident}")
case("证据齐 ⇒ display_version=0.2.0", ident["display_version"] == "0.2.0", f"{ident['display_version']!r}")
# ② 有版本但**没有 tag** ⇒ 不许标正式
d2, sha2, _ = _tmp_repo(); dirs.append(d2)
ident2, blocked2 = build_identity(release=True, root=d2, sha=sha2, built_at="t")
case("有版本但无 tag ⇒ 不标正式",
ident2["build_kind"] == "development" and any("没有 tag" in x for x in blocked2), f"{blocked2}")
# ③ SHA 不是完整 40 位 ⇒ 不许标正式
ident3, blocked3 = build_identity(release=True, root=d, sha="unknown", built_at="t")
case("unknown SHA ⇒ 不标正式",
ident3["build_kind"] == "development" and any("不是完整 40 位" in x for x in blocked3), f"{blocked3}")
ident3b, blocked3b = build_identity(release=True, root=d, sha=sha[:12], built_at="t")
case("短 SHA ⇒ 不标正式",
ident3b["build_kind"] == "development" and any("不是完整 40 位" in x for x in blocked3b), f"{blocked3b}")
# ④ tag 指错提交 ⇒ 不许标正式(tag 打在**后一个**提交上,却按前一个提交去问)
d4, first4, env4 = _tmp_repo(); dirs.append(d4)
(d4 / "extra.txt").write_text("x", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=d4, env=env4, check=True)
subprocess.run(["git", "commit", "-q", "-m", "chore: 第二个提交"], cwd=d4, env=env4, check=True)
second4 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d4, capture_output=True,
text=True).stdout.strip()
subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release", second4], cwd=d4, env=env4, check=True)
ident4, blocked4 = build_identity(release=True, root=d4, sha=first4, built_at="t")
case("tag 指错提交 ⇒ 不标正式",
ident4["build_kind"] == "development" and any("没有 tag v0.2.0" in x for x in blocked4), f"{blocked4}")
# ⑤ 轻量 tag ⇒ 不许标正式(注释 tag 才是发布证据)
d5, sha5, env5 = _tmp_repo(); dirs.append(d5)
subprocess.run(["git", "tag", "v0.2.0"], cwd=d5, env=env5, check=True)
ident5, blocked5 = build_identity(release=True, root=d5, sha=sha5, built_at="t")
case("轻量 tag ⇒ 不标正式",
ident5["build_kind"] == "development" and any("注释 tag" in x for x in blocked5), f"{blocked5}")
# ⑥ 提交内 CHANGELOG 顶部版本与"声称的版本"不一致 ⇒ 不许标正式
# 做法:提交里的记录是 0.2.0,tag 却叫 v0.3.0(指向该提交),工作区又写着 0.3.0 ⇒ 三条中
# "版本一致性"这一条会红(tag 存在、注释、SHA 也完整,问题只出在版本对不上)。
d6, sha6, env6 = _tmp_repo(); dirs.append(d6)
subprocess.run(["git", "tag", "-a", "v0.3.0", "-m", "release 0.3.0"], cwd=d6, env=env6, check=True)
(d6 / "CHANGELOG.md").write_text(_SAMPLE.replace("0.2.0", "0.3.0"), encoding="utf-8")
ident6, blocked6 = build_identity(release=True, root=d6, sha=sha6, built_at="t")
case("声称版本与提交内记录不一致 ⇒ 不标正式",
ident6["build_kind"] == "development" and any("不一致" in x for x in blocked6), f"{blocked6}")
# ⑦ 开发构建:有基线 ⇒ X.Y.Z-dev+g<短SHA>;无基线 ⇒ dev+g<短SHA>
dev, _ = build_identity(root=d, sha=sha, built_at="t")
case("开发构建(有基线)= 0.2.0-dev+g<短SHA>",
dev["build_kind"] == "development" and dev["display_version"] == f"0.2.0-dev+g{sha[:12]}",
f"{dev['display_version']!r}")
d7, sha7, _ = _tmp_repo("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n"); dirs.append(d7)
dev7, _ = build_identity(root=d7, sha=sha7, built_at="t")
case("开发构建(无基线)= dev+g<短SHA>",
dev7["release_version"] is None and dev7["display_version"] == f"dev+g{sha7[:12]}",
f"{dev7}")
for x in dirs:
subprocess.run(["rm", "-rf", str(x)], capture_output=True)
if problems:
print("❌ 构建身份自检失败:", file=sys.stderr)
for x in problems:
print(f" · {x}", file=sys.stderr)
return 1
print("✅ 构建身份自检通过:证据齐(注释 tag + 完整 SHA + 提交内版本一致)才是正式构建;"
"无 tag / unknown 或短 SHA / tag 指错提交 / 轻量 tag / 版本不一致 逐条拒绝;"
"开发构建带显式 dev 标识")
return 0
def main(argv: list[str]) -> int:
if "--self-test" in argv:
return self_test()
release = "--release" in argv or os.environ.get("BUILD_KIND") == "release"
if "--identity" in argv:
if release and current_version() is None:
print("❌ 声明了发布构建(--release / BUILD_KIND=release),但 CHANGELOG.md 里没有已发布版本条目。\n"
" 发布构建必须基于一个已发布的版本记录(`## [x.y.z] - YYYY-MM-DD`)——\n"
" 本仓还没有正式发布过,所以现在只能产出开发构建身份(dev+g<短SHA>)。",
file=sys.stderr)
ident, problems = build_identity(release=release)
print(json.dumps(ident, ensure_ascii=False, indent=2))
if release and problems:
print("❌ 声明了正式构建(--release / BUILD_KIND=release),但发布证据不齐 —— "
"**不标为正式版**(身份已退回 development):", file=sys.stderr)
for x in problems:
print(f" · {x}", file=sys.stderr)
return 2
print(json.dumps(identity(release=release), ensure_ascii=False, indent=2))
return 0
v = current_version()