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:
@@ -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 一致性)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user