# 模板评估与修正(附件 `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: ` 防并发。 - **实测**: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/%2F` 显式路径坑(细粒度只读 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/..`;取不到基准 ⇒ 明确失败 | `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 :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 条 |