Files
eng-standards-main/04-模板评估与修正.md
t 3b26457165 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。
2026-10-13 17:13:44 +08:00

252 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模板评估与修正(附件 `eng-standards-main.zip`)
> 结论先说:**原稿的"地基"(四条铁律、反向自检、"为什么"写在正文、门禁串行失败即停)全部保留**;
> 但它整体是 **GitLab / glab** 口径,而本环境是 **Gitea 1.26.2 + act_runner + 自建 ci-bridge + 阿里云双机**。
> 逐条评估结果如下。每条的"证据"都是本次在本机/实例上**实测**到的,不是推断。
## 一、结论
| 分类 | 数量 | 处理 |
|---|---|---|
| A. 平台错配(GitLab 语义在本环境不存在) | 6 | 全部改写为 Gitea 口径 |
| B. 与实测事实矛盾(原稿断言不成立) | 3 | 修正断言,写清成立条件 |
| C. 模板内部自相矛盾 / 数值不可信 | 3 | 改掉矛盾点 |
| D. 具体取值是他人环境的 | 3 | 全表换成"本环境事实"(`README.md` §1) |
| E. 覆盖缺口(原稿根本没约束) | 1 | **新增铁律**(同一份构建产物,规范 §5.1) |
---
## A. 平台错配(6 处)
### A1. 触发体系:`workflow: rules` / `$CI_*` 全部不存在
- **原稿**:`.gitlab-ci.yml` 的 `workflow.rules` 用 `$CI_PIPELINE_SOURCE == "web"` 与 `$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH` 两条件。
- **实测**:本实例是 Gitea Actions,`.gitea/workflows/*.yml`,触发在 `on:`;可用变量是 `GITHUB_*`(`GITHUB_REF_NAME`、`GITHUB_REPOSITORY`、`GITHUB_SERVER_URL`)。
- **修正**:见规范 §4.1,三类入口写成 `on.push.branches` 与 `on.workflow_dispatch`。原稿那条"刻意不接 tag"的**判断保留**,理由改写(见 A2)。
### A2. 手动作业语义:`when: manual` 的"阻塞型"论证是 GitLab 专有
- **原稿**:手动作业没有 `allow_failure: true` 时流水线永远到不了 `success`(停在 `blocked`),取消会把已成功作业一起标 `canceled`。
- **实测**:Gitea 里 `if:` 不成立的作业是 **skipped**,不会把运行挂死;生产发布的正确形态是**独立的 `workflow_dispatch` 运行**。
- **修正**:保留原稿的**真问题**("取消 ≠ 回滚"、"不要重写会抹掉证据"),但改挂到 Gitea 的真实风险上:`concurrency.cancel-in-progress` 会取消**正在进行的部署**。生产 workflow 必须 `cancel-in-progress: false`。
### A3. `resource_group` 在 Gitea 1.26.2 不存在
- **原稿**:同一环境的部署用 `resource_group: <env>` 防并发。
- **实测**:Gitea 没有 `resource_group`;`concurrency:` 只对同 workflow 同 group 生效,**跨 workflow / 人手手动执行挡不住**。
- **修正**:互斥**下移到目标机**(`flock -n`,规范 §5.4)。这是"可靠性只在这一层"的取舍,必须写明。
### A4. 变量作用域:Gitea 没有 environment scope,也没有 File 类型 secret,勾选坑不同
- **原稿**:GitLab CI/CD Variables 用 `environment scope` 区分环境;有 "Expand variable reference" / "Masked" / "Protected" 三个勾选坑;私钥用 **File 类型**(因为多行勾不了 Masked)。
- **实测**:Gitea 仓库变量/密钥**没有环境作用域**;私钥只能整体存 secret(无 File 类型);三个勾选坑**不存在**。
- **修正**:环境区分改用**命名后缀**(`DEPLOY_TEST_*` / `DEPLOY_PROD_*`);私钥由运行时脚本落盘 0600(规范 §7.1 / §7.3)。
另有**接口形状**要写对(实测):变量 `POST/PUT …/actions/variables/{name}` body `{"value":…}`;密钥只有 `PUT …/actions/secrets/{name}` body `{"data":…}`;**集合级 POST 返回 405 空 body**(像权限问题,实际不是)。
### A5. CLI:`glab` → `giteactl` / `gitea_api.py`
- **原稿**:§6 整章讲 `glab`,含 `projects/<ns>%2F<repo>` 显式路径坑(细粒度只读 token 下 `:id` 占位符会 403)。
- **实测**:本机**没有 `glab`**;有 `~/.local/bin/giteactl`(`runs/show/logs/watch/rerun`)、`~/.hermes/scripts/gitea_api.py`、`giteacli`(本机 token 缺 `read:user`,`whoami`/`login` 会失败但仓库功能可用)。
- **修正**:§9 速查表与 §02 Runbook 换成 giteactl/gitea_api.py 的真实命令。原稿"读日志用显式路径、不用交互式 TUI、trace 要清洗"的经验**保留**,但换成 Gitea 的日志接口地址。
### A6. `GIT_DEPTH: 1` / `after_script` + `$CI_JOB_STATUS` 通知 / `tags: [docker]`
- **原稿**:CI 变量 `GIT_DEPTH: 1`;通知用 `after_script` 拿 `$CI_JOB_STATUS`;Runner 标签 `docker`。
- **实测**:本环境是**自 clone 模型**(`git fetch --depth 50`);通知走 `.gitea/scripts/ci_report.py` + `if: always()` 步骤 + 主机侧 bridge;Runner 标签是 `ubuntu-latest/24.04/22.04`。
- **修正**:全部替换。原稿"通知失败不能让作业失败"、"可能的话两条通知链路分开"的判断**保留**。
---
## B. 与实测事实矛盾(3 处)
### B1. "不用任何第三方 action" —— 本环境不成立
- **原稿**:文件头写"不用任何第三方 action(作业容器不联网拉 action)",并把它当铁律。
- **实测**:`aiball/software-pc` 的 `.gitea/workflows/validate.yml` 用了 `actions/checkout@v4` + `actions/setup-node@v4`,**run#1 = success**(`GET /repos/aiball/software-pc/actions/runs` 显示 `completed success`)。而 Weknora 三仓的 runner 上工作流刻意自 clone。
- **修正**:规则改成**按 Runner 配置区分**(规范 §4.3):默认不用 `uses:`;例外要在文件头写明该 runner 支持取 action。并给出一条判据:"时红时绿且报拉取失败"先怀疑 `uses:`。
### B2. `.gitignore` 模板与正本条款自相矛盾
- **原稿**:正本 §1.3 明说"处理办法**不是**加 `!` 例外(例外越加越多,规则本身就没人看得懂了)";但 `templates/gitignore` 里就给了 `!src/secrets/` 和 `!src/secrets/**`。
- **实测**:原稿自己的 `spec-check.mjs` 的 `ARTIFACT_PREFIX` 正则包含 `secrets/` —— 于是"按模板加了例外、又真把 `src/secrets/` 入库"的工程会被 `tracked-artifacts` **判红**。
- **修正**:模板去掉 `!` 例外,改为一句话:"别在源码路径里用 `dist`/`data`/`secrets` 这些名字"(见 `templates/gitignore`)。
### B3. `spec-check.mjs` 的 `AGENTS_MAX_BYTES = 32768` 与正本数字不一致
- **原稿**:正本 §2.4 写"实测 **65983** 字节时尾部 739 字节被静默截断";而守门判据硬编码 `AGENTS_MAX_BYTES = 32768`。
- **矛盾点**:正本自己立了规矩"**文档里不许出现会漂移的数字**,要么由脚本打印要么写成断言"(§4.3),却在这里放了一个来源不明的常量。
- **修正**:本次不引入该脚本(任务边界是"只制定规范"),但在 `03-待配置事项.md` 第 16 项写明:改写 Gitea 版守门时,**上限值必须由一次真实测量得出并写成断言**,不许手抄。
---
## C. 模板内部问题(3 处)
### C1. `templates/verify.mjs` 写死跨机路径
- **原稿**:门禁第 5 步 `cmd: [process.execPath, '__STANDARDS_DIR__/scripts/spec-check.mjs', ...]` —— 一个**绝对路径**,在 CI 容器里根本不存在(原稿自己的 `validate:repo` 注释也承认了这一点)。
- **修正**:门禁入口只跑**本仓**的东西;规范守门要么随仓携带(`.gitea/scripts/`),要么在 CI 里先 `git clone` 规范仓库到固定路径。见 `templates/gitea-build.yml` 的 `validate` 作业注释。
### C2. `spec-check.mjs` 两条判据过窄/无对象
- `ci-cache-node-modules`:只匹配 `- node_modules/` 这一种写法;本环境的缓存是**主机挂载目录**(`/opt/citools-cache`),判据完全够不着。
- `glab-placeholder`:在 Gitea 环境里没有 `glab`,判据**无对象**(永远是 ok)。
- **修正**:说明见 `03-待配置事项.md` 第 16 项。同时保留原稿两条**好经验**:判据要认"这件事"而不是"那种写法"(原稿自己在 v1.0.4 里踩过)、并且"报错文案里也不能出现针的字面量"(会被下一次扫描命中)。
### C3. `check_env.sh` 的必需键是按"网关 + Cookie"项目定的
- **原稿**:`REQUIRED="ENV_NAME APP_DIR DOMAIN COOKIE_NAME"`,并断言"两个环境的父域 + Cookie 名不能同时相同"。
- **实测**:该不变量属于原稿样板项目(带 nginx + Cookie 的网关)。本环境的项目是 Go 服务(DobotAI / knowledge-governance / adapter),没有这一对不变量;DobotAI 的环境参数在**生产机 `/opt/dobotai/config/dobotai.env`**(不入库),也不是 `deploy/env/*.env`。
- **修正**:本规范不照搬该脚本。保留它的**形状**("仓库层面的不变量放在 CI 校验,在传任何字节之前失败"),但要按项目定键(规范 §7.2)。
---
## D. 具体取值是他人环境的(3 处 → 全表替换)
| 原稿取值 | 本环境实际 |
|---|---|
| `https://gitlab.artificialheart.cn/liuzhufu/...`、默认组 `liuzhufu` | `http://192.168.50.223:3000`(`https://gitea.zheromiku.top`);组 `Weknora` / `aiball` |
| Runner 标签 `docker`;镜像 `node:24-alpine` | 标签 `ubuntu-latest/24.04/22.04` → `docker://docker.gitea.com/runner-images:*` |
| `APP_DIR=/volume1/docker/...`(群晖 NAS)、域名 `*.artificialheart.cn` / `*.dobotoa.cn` | 阿里云 ECS:测试 `120.76.217.51`、生产 `120.76.218.137`,路径 `/opt/dobotai/...` |
以上全部换成了 `README.md` §1 的**唯一取值表**,并给了取数命令(避免手抄漂移)。
---
## E. 覆盖缺口(1 处 → 新增铁律)
### E1. 「生产使用测试验收过的同一份构建产物」原稿**没有**约束
- **原稿**:§5.5 讲产物按生命周期拆包、§5.7 讲目标机三层布局与回滚,但**没有**要求"测试与生产部署同一份产物"。
- **实测**:本环境现状恰恰相反 —— DobotAI 的既有流程是**在目标机上各自 build**(生产机 clone 源码 + `docker build`),测试与生产**不是同一份二进制**。
- **修正**:作为本次规范的**第一优先级新增**(规范 §5.1):构建只做一次 → 产出 `image_id` + `tar_sha256` 写进发布清单 → 测试与生产都 `docker load` 同一份 tar 并各自断言 `image_id` 相同。并给出完整的证据链表(§5.1)与"迁移不属于镜像、先迁移后切镜像"的例外。
---
## F. 保留的(原稿最有价值的部分,不要因为"平台换了"就丢掉)
1. **§0 四条地基**:结论只来自可复现命令 / 每个校验都要反向自检 / 密钥不进命令行 / 提交前跑门禁。
2. **§3.2 八条通用铁律**:只有"跑产物"的套件要排在构建之后(**不是**"所有单测都依赖预构建产物")、干净 clone、反向自检三问(会红吗 / 会假绿吗 / 真的作用在目标上吗)、断言要有唯一命中点、夹具要打扫、先确认无遗留进程。
3. **§2.2 正文写"为什么"** 的四种要素(现象/根因/取舍/证据)与示例形状。
4. **§2.3 heredoc 禁忌**(反引号 `$()` 会被 shell 先执行,且退出码仍是 0)。
5. **§1.3 `.gitignore` 是契约** 与两条反查命令。
6. **§5.4 密钥只走 stdin 写文件**、`chmod 600`、去 CRLF。
7. **§5.7 目标机三层布局** 与"停容器/换目录必须在同一个脚本里、顺序不许挪"。
8. **§5.6 不缓存 `node_modules`**。
9. **§2.4 陷阱库拆两份**(指令文件放索引、旁边放全文,因为指令有字节预算)。
10. **§11 规范自己怎么被管理**(单一来源、投影、可检验、有版本)—— 见本目录的 `README.md` 与 `03-待配置事项.md` 第 13 项。
---
## G. 第二轮审查修正(模板/生成器/接入器,2026-10-12)
审查(GPT,第二轮)用**模拟项目**逐个打穿了模板的假检查。逐条修法与判据:
| 编号 | 问题(实际证据) | 修法 | 负例在哪 |
|---|---|---|---|
| T-1 | **通用 Python 检查空跑**:模拟 Python 项目,依赖/格式/构建/测试全返回成功,实际没执行 | `templates/ci-gate.py` 的 Python 分支真跑 `uv sync --frozen` / `uv run ruff format --check` / `compileall` / `uv run pytest`;工具缺失 ⇒ FAILURE(`need()` 抛错) | 夹具 `gate-py-neg`:抹掉 `uv` 后五项全红;补上工具后五项真绿;把一个用例改失败 ⇒ `pytest 退出码 1` 红 |
| T-2 | **前端测试忽略进程失败**:测试进程退出 1、JSON 写 pass,门禁仍绿 | `judge_frontend()` 把退出码当判据;空套件 / skip / todo / 没报告 ⇒ 红;框架读 `test-scope.json` 声明,不写死 vitest | 夹具 `gate-node-neg`:A 例(rc=1 + 报告写 pass)红、B 例(rc=0 + 报告全过)绿 |
| T-3 | **通用清单没有安全项** | 清单新增 `security`(密钥扫描 + 依赖漏洞,工具缺失/输出解析不了 ⇒ 红);模板带 `templates/check-secrets.py`,生成器落到 `scripts/check_secrets.py` | `--self-test` 含清单比对 |
| T-4 | **test-scope 可用空对象逃逸**:条目缺 package/test 时会通配任意跳过 | 严格 schema:必须给 `package` 或 `test` 至少一个 + `reason` + `owner`;package 级还要 `expected_skips`(跳过数涨了要重新声明);`review_by` 过期 ⇒ 整份失效 | `--self-test` 分支「范围声明有问题 ⇒ 红」 |
| T-5 | **预览早于报告核对**(模板里还 `cat /tmp/ci-sha`、跨作业 `/tmp` 假契约) | 模板 workflow 收成**单作业**;预览改调 `.gitea/scripts/ci_preview.py`(先 `verify()`,不过则 0 请求) | 项目侧 `scripts/ci_preview_negative_test.py`(假 bridge 数请求,10 分支) |
| T-6 | **协议被拼成 http** | 检出抽成 `templates/ci_checkout.sh`:remote 用 `GITHUB_SERVER_URL` 原样拼 + 断言不降级 + URL 不带凭据 + 顺带取事件 `before` | `ci_checkout.sh --print-remote`:https/http 保留、`ftp://` 拒绝;https 真跑一次检出验证 |
| T-7 | **合并必需 context 可为空** | `adopt-existing.py`:必需 context 只认显式策略(`--require-context` / `.gitea/required-contexts.txt`),空/缺失 ⇒ 拒绝;并核对 base/state/head 仓库 | `--self-test-chain`(假 Gitea + 真实 `merge_pr()`,13 分支) |
| T-8 | **提交范围可能漏查** | `.gitea/scripts/ci_gate.py` 以 Git 范围为准(`before..after` / 新分支 `after --not --remotes`);声明与范围不一致、空范围、浅克隆缺 before、列表提交不在检出 ⇒ 拒绝;手工运行只核 HEAD 并写明 | `--self-test` 11 分支(含"声明 6 条 / 列表 1 条 / Git 2 条 ⇒ 报错") |
| T-9 | **通知里的链接指向内网地址**(群里点不开/换网就失效) | 上报脚本新增链接基址 `CI_PUBLIC_BASE_URL`(非秘密,workflow 传给上报脚本):run/提交/变更链接统一用它拼;缺失/非法**明确回退** `GITHUB_SERVER_URL` 并写 `link_base_source`;bridge 可信来源(`ci_base_url`,支持多个)同步登记;**不做消息文本的全局 URL 替换** | 项目侧真实链路用例:配了公网域名 ⇒ 三类链接都用它且保留真实 repo/SHA/run_id、链接目标只有可信来源;未配/非法(`ftp://`、带 userinfo、带空白)⇒ 回退且消息里不出现非法域名;提交标题里的 URL 仍是文本;真实对象的链接可达(匿名与带 token 各测) |
**生成器同步**:`new-project.sh` 现在会落 `ci_preview.py`、`ci_checkout.sh`、`check_secrets.py`、
`test-scope.json`(合法但空的骨架)、`.gitea/required-contexts.txt`(**故意留空**并要求你填 —— 不预填猜出来的 context,
否则会把没跑过必需检查的 PR 放过去)。
**口径修正(文档)**:模板不再声称"CI 能拦住直推";"所有单测都依赖预构建产物"改成"只有跑产物的套件才排在构建之后";
通用模板默认不携带任何部署步骤,占位一律 `exit 1`(不许假报成功)。
---
## H. 第三轮审查修正(启动阻断与范围,2026-10-12)
| 编号 | 问题(实际复现) | 修法 | 负例在哪 |
|---|---|---|---|
| U-1 | **空工作区起不来**:模板 workflow 第一步 `bash .gitea/scripts/ci_checkout.sh` —— Runner 不自动检出,工作区为空时脚本不存在,实测 **127** | 模板 workflow 第一步改成**最小 bootstrap**(只用 runner 环境变量:协议/凭据校验 → `git init`+`remote add` → 取**事件完整 SHA** → checkout → 断言 HEAD==事件 SHA);检出后才调 `ci_checkout.sh --post-checkout` | `kg` 的 `scripts/ci_chain_fixture.py`:**把模板/YAML 里那段 `run:` 原样抽出来**在空目录执行;并复现旧写法的 127 |
| U-2 | **按任意 SHA `fetch` 不可用**:分支前进时 `git fetch origin $SHA` → `服务器不允许请求未公开的对象` | 改为"取分支历史 + `--deepen` 再核事件 SHA",仍找不到才失败(提示提高深度) | 同上(C 例:取不到 SHA 必须非 0) |
| U-3 | **新分支范围恒为空**:`after --not --remotes` 把 `origin/<本分支>`(= after)当排除集,排除自身 ⇒ 0 条并失败 | 新分支基准 = **远端默认分支**:`refs/remotes/origin/<default>..<after>`;取不到基准 ⇒ 明确失败 | `ci_gate.py --self-test` 加了回归用例(`origin/<本分支> == after` 时仍须算出那几条)+ 调用链夹具 D 例(合规 0 / 违规 1,且红在那条违规提交上) |
| U-4 | **Python 未锁定/未隔离**:缺 `uv.lock` 回退 `uv sync`;requirements 路径用 `uv pip install --system`;`uv run` 未冻结 | 必须 `uv.lock` ⇒ `uv sync --frozen`;所有检查 `uv run --frozen`;断言 `sys.prefix != sys.base_prefix`(项目内 `.venv`);只有 `requirements.txt` ⇒ 明确失败并要求先生成锁 | 夹具 `gate-py-neg`(无锁红 / 有锁绿 + 隔离打印)+ 夹具脚本第 21 节(静态查 `--system` 必须不存在) |
**顺序口径(模板注释里也钉住了)**:`最小 bootstrap → 仓内脚本 → 环境 → 门禁入口 → 权威门禁 → 核对报告 → 预览 → 上报`。
**不能在检出前依赖仓内文件**;预览仍然排在"报告核对"之后。
| U-5 | **把 `total_commits` 当声明条数**:真实新分支推送时 Gitea 给 `total_commits=0`、列表被截断(5 条),Git 范围 12 条 ⇒ 严判让门禁在真实 CI 首跑就红(与代码质量无关) | **Git 范围是唯一权威**:`total_commits > 0` 才交叉核对;`<=0`/缺失视为"未声明",以 Git 范围为准并把"负载条数不可信"写进来源;`--self-test` 加 `total_commits=0` 分支 | 真实 run #16(`ci-trial-20261012`);自检 15 分支 |
---
## I. 第四轮修正(版本与变更记录规范化,2026-10-13)
按试点仓 `knowledge-governance` 落地时暴露的三个问题(CHANGELOG 混入审查流水与命令;
文档说"不使用 tag"但 CI 已在核验 tag;开发构建沿用上一正式版本号)做的口径与模板修正。
| 编号 | 问题 | 修法 | 负例在哪 |
|---|---|---|---|
| V-1 | **变更记录变成过程日志**:`[Unreleased]` 里塞着审查轮次、测试命令、待办 ⇒ 发布记录读不出"这次发了什么" | 条目形状由"四个自定义必填节"改为**固定四类**(`Added`/`Changed`/`Fixed`/`Security`,分类可省略、**不许空栏目**);过程记录迁 `docs/archive/`,未合并事项迁 `docs/BACKLOG.md`;**不要求每个提交都改** | `check-changelog.py --self-test`:自创栏目 / 空栏目 / 重复 / 顺序倒置 / 非法日期 / 前导零 逐条报红;项目夹具真改文件再恢复 |
| V-2 | **破坏性变化无处安放**:混在"兼容性与迁移影响"里,且可能链接到不存在的文档 | 固定写在 `### Changed` 里、以 `**破坏性**` 开头,**必须链接发布文档**;仓内 `docs/….md` 链接**对账**(不存在即红) | 夹具:无链接 ⇒ 红;链接 `docs/NO_SUCH_DOC.md` ⇒ 红;写在 `### Added` 里 ⇒ 红(位置固定) |
| V-3 | **开发构建沿用上一正式版本号**,分不清开发版与正式版;且"还没有正式版本"会被当成错误 | 新增**构建身份**(`release_version` / `build_kind` / `commit_sha` / `display_version` / `built_at`):开发显示 `X.Y.Z-dev+g<短SHA>`、无基线 `dev+g<短SHA>`、发布才是 `X.Y.Z`;`build_kind=release` 只认**显式上下文**;**还没有正式版本一律放行**(不判红,首个版本号由人指定) | `internal/version` 契约:开发构建带 dev 标识且**不等于**正式版本号;**负例**"自称正式版 ⇒ 必须红"(门禁里真跑这条负例);夹具另有 `--identity --release` 无基线 ⇒ rc=2 |
| V-4 | **"没核对远端"却声称校验成功**(拿不到远端 tag 清单时照打✅) | `check_changelog.py --tags --require-remote`:CI 与预检都强制**真查远端**(`.ci-remote-tags` 或 `ls-remote`);查不到 ⇒ 红;本地模式明确写「未核对远端」,不再打印"校验成功" | 夹具:临时仓库里 6 个场景(无 tag 绿 / 注释 tag 一致绿 / 版本不符红 / 轻量 tag 红 / 拿不到远端+require-remote 红 / 未发布段写日期红);**测试 tag 只在临时仓库创建** |
**生成器同步**:`new-project.sh` 现在也落 `scripts/version.py`(构建身份),CHANGELOG 骨架改成四类小节 +
构建身份说明;`templates/ci-gate.py` 的 `version` 项改用 `--tags --require-remote`。
**口径变更(`01-规范` §6 二次修正)**:① 条目形状四节 → 四类;② 破坏性变化固定位置 + 必须带文档链接;
③ 新增构建身份与"开发构建不许自称正式版";④ "还没有正式版本"放行、首个版本号由人指定;
⑤ 拿不到远端 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。
---
## K. 第六轮修正(候选版本取顶部 / 禁用自动发布路径,2026-10-13)
| 编号 | 问题 | 修法 | 负例在哪 |
|---|---|---|---|
| V-9 | **只判候选版本"在提交的历史里出现过"**:旧版本条目还列在下面时,也会被当成"已写进目标提交" | `version_in_commit` 改为比**该提交里的顶部已发布版本**:`RELEASE_HEADING.search(blob)` 取第一条 `## [x.y.z]` 并要求 == 候选版本(顺带兜住"最新在最上面 / 只增不减") | 自检临时仓库:顶部 `0.3.0` + 历史 `0.2.0` ⇒ 候选 `0.2.0` **拒绝**、`0.3.0` 通过;夹具 §23 同场景两态都跑(顶部 vs 历史) |
| V-10 | **预检里还留着能把仓改动的自动发布路径**(`--execute` 会打 tag/推送/建 Release) | 按只读约定**删掉写路径**:`--execute` / `--approve-*` 只保留参数兼容并**明确拒绝**(rc=2),拒绝发生在收集事实之前 ⇒ **零写操作、零 API 调用**;自检里用 spy 断言"拒绝路径一个子进程都没跑、一个 API 都没发",并扫描生产路径不许出现直接执行 git 写命令的行 | `release_preflight.py --self-test`(spy 计数 + 源码扫描);夹具 §23:`--execute` rc=2、stderr 明说已禁用、stdout 为空、**跑完仓里 tag 不变** |
---
## L. 第七轮修正(版本自动递增,2026-10-13)
使用者要求"每提交一次自动升一个版本",且版本规则由实现方定义。要点:**自动升的是开发标识,
不是发布版本** —— 这样与"CI 不自动升版本、不打 tag"并存。
| 编号 | 事项 | 做法 | 负例/断言在哪 |
|---|---|---|---|
| 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 条 |