Files
eng-standards-main/01-规范-Git与CI-CD(Gitea版).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

742 lines
58 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.
# Git / 提交 / CI-CD 规范(Gitea 版)
> **性质**:系统级规范,适用于本机所有 Gitea 仓库(`Weknora/*`、`aiball/*`)。
> **正本**:本文件。同仓/同级仓库用相对路径引用;跨机请带上路径与本文件版本号。
> **样板实例**:`Weknora/knowledge-governance`(唯一把"提交闸门 + 编译 + bridge 上报"全套跑通的仓库)。
> **口径**:**必须** = 违反即不合格;**建议** = 有理由才能偏离,偏离写在代码注释里(本环境的规矩是"注释写为什么,不写是什么")。
>
> 与附件原稿的关系:原稿是 **GitLab / glab** 口径,本环境是 **Gitea 1.26.2 + act_runner**,触发体系、手动作业语义、变量作用域、CLI 全部不同。逐条映射与证据见 `04-模板评估与修正.md`。
---
## 0. 四条地基
1. **结论只来自可复现的命令**。任何数字、清单、成败,要么由脚本自己打印,要么写成断言 —— 禁止手抄进文档。手抄的东西过期时**没有任何人会发现**,而过期的表现是"守卫变松"。
2. **每个校验都要有反向自检**:故意弄坏一处,它必须红。一个"永远返回 0 的校验器"比没有校验更糟 —— 它给人一种已经检查过的错觉。
- 本环境的范例:`knowledge-adapter-service` 的 `test/bad-commit` 分支在 CI 上**必须**失败(实测已存在这样一条失败 run)。
3. **密钥只从 Gitea `Secrets` / 目标机文件进**:不入库、不回显、不进命令行参数(会进 `ps`)。
4. **提交前跑本仓门禁命令**;CI 不是第一次验证,CI 只是"再来一遍 + 部署"。
---
## 1. 仓库、分支、评审
### 1.1 分支模型
- **单主干**:默认分支 `main`,**所有改动直接进 `main`**(原稿口径)。本地只保留 `main` 这一条长期分支。
- **需要评审时**用**短命分支 + PR**,合完即删。**禁止长期分支**。
- 现状欠账:`Weknora/Enterprise-Knowledge-Base` 有 7 条分支(含 `release/*`、`develop/*`),属于"长期分支"反模式,见 `03-待配置事项.md`。
- **禁止重写 `main` 历史**(`rebase` / `push --force`)。理由:CI 以 `main` 的推送为部署触发,"线上跑的是哪个提交"这个问题靠 `main` 的历史回答;历史一改,答案就没了。
- **一切"退路"都必须是远端有的东西**:本地分支、本地 tag 都不算退路。真正的退路是"远端有那个提交、且能重跑那条流水线"。
### 1.2 什么时候开 PR(**建议**,不是门禁)
**默认直推 `main`。** 原稿 §1.1 的口径就是"所有改动直接进 `main`,需要评审时才用短命分支 + MR"。
下面这张表是**建议** —— 判断依据是"错了代价高不高、需不需要留下可追溯的记录":
| 改动落在 | 建议 |
|---|---|
| `deploy/`、`.gitea/`、`db/migrations/`、生产配置模板、`Dockerfile` | **建议**开 PR(这类改动错了会动到线上),描述里写清"怎么验的" |
| 业务代码(`internal/`、`src/`、`web/`) | 可直接提交;改动大、或想让别人能回看时开 PR |
| 纯文档 / 文案 / 注释 | 直接提交 |
⚠️ **这是建议,不是"必须"。** 把建议当强制会把 3 人小团队卡在流程上 ——
本规范早期版本正是这么错的(曾写成"业务代码必须 PR、main 禁止直接推送"),已按原稿口径改回。
上一行这句留着,是为了让下一个人不再"顺手加严"。
- PR 描述是**交付契约**,不是说明文。`gitea-workflows` 技能要求:写清 **已实现 / 安全与数据边界 / 真跑过的测试 / 尚未验证 / "未合并、未部署"**。
- 合并前必须读到**权威读回**:`GET /pulls/{n}` → `state=closed`、`merged=true`、`merge_commit_sha` 非空。
⚠️ Gitea 的合并接口**成功时可能返回空 body**,客户端解析 JSON 会误报失败 —— **永远不要重试合并**,改为读回。
- ⚠️ `mergeable=true` 只表示**没有冲突**,不代表测试、评审、迁移演练、部署门禁已满足。
### 1.3 分支保护:**默认不开**
原稿的默认是 `main` **不受保护**(它 §8 甚至写明"不要勾 Protected"),本规范沿用。
- **默认不开**。它有一个容易忽略的代价:**"必须状态检查通过"只门控 PR 合并 ——
直推 `main` 时管不住**(CI 是"推完再跑",红了也拦不住那次推送)。
既然本环境以直推为主,开保护的收益小于它带来的流程成本。
- **什么时候值得开**:某段时间改动都要评审,或需要"任何人都不能直推"(交接期、多人并行、外部协作者)。
开的时候按需勾"禁止直接推送 + 必须走 PR",**不勾强制状态检查** ——
除非该仓工作流覆盖所有分支(判据见 `06-接入既有工程.md` §2)。
- 现状:`Weknora/knowledge-governance` 曾开过一次,已按本节口径**关掉**;7 个仓现在
`GET /repos/{r}/branch_protections` 全为 `[]`。
### 1.4 `.gitignore` 是契约(五条)
必须忽略:依赖(`node_modules/`)、构建产物(`dist/`、`bin/`)、运行期数据(`data/`、`*.db`)、密钥(`secrets/`、`.env`)、发布产物(`runtime/`、`*.tar.gz`、`version.txt`、`*.tar`)。
1. **`.gitignore` 对已入库的文件无效** ⇒ "规则齐全"不等于"没被追踪"。断言要查**两件事**:规则在、且 `git ls-files` 里没有这些路径。
2. **无斜杠前缀的规则匹配任意层级**。被咬过的三次都是这个:`dist/` 吞掉 `src/admin/dist/`、`data/` 吞掉 `src/data/`、`secrets/` 吞掉 `src/secrets/`。
处理办法**不是**加 `!` 例外(例外越加越多,规则本身就没人看得懂了),而是**别在源码路径里用 `dist`/`data`/`secrets` 这些名字**。
3. 症状很轻:`git commit` 只回一句 `paths are ignored`,文件**静默没进提交**。看到这句必须回去查。
4. **产物名里带 sha**:`dist/app-<sha12>.tar` 这类发布产物一律忽略,绝不入库。
5. 反查命令(提交前跑一次,成本一秒):
```bash
git status --short --ignored | grep '^!!' | head
git ls-files | grep -E '(^|/)(dist|node_modules|data|runtime|bin)/|\.tar\.gz$|^version\.txt$' # 应输出为空
```
---
## 2. 提交规范
### 2.1 格式
**Conventional Commits,标题用中文**(本环境已有两处闸门强制,见 §2.4):
```
<type>(<scope>): 一句话说清"改了什么"
正文:为什么这么改 / 现象 / 根因 / 怎么验的。
```
- `type`:`feat` `fix` `docs` `style` `refactor` `perf` `test` `build` `ci` `chore` `revert`。
- `scope` 取**本仓库的真实模块名**(如 `dobotai-platform`:`card`、`weknora`、`coverage`、`dingtalk`、`deploy`;`knowledge-governance`:`api`、`release`、`web`、`monitor`、`deploy`)。
- 冒号后**一个空格**;描述 **≥3 字符**;标题 **≤72 字符**、**结尾不加句号**;破坏性变更在 type 后加 `!` 并在正文写 `BREAKING CHANGE:`。
- 放行自动生成的提交:`Merge ` / `Revert "` / `fixup! ` / `squash! ` / `amend! ` 开头。
- **一件事一个提交**,且**每个提交都要能各自编译/构建通过**(否则 `git bisect` 失效)。
### 2.2 正文写"为什么",不写"是什么"
diff 已经写了"是什么"。正文要回答:**当时为什么这么改、试过什么、怎么确认它对了**。四种要素可直接照抄形状:
| 要素 | 作用 |
|---|---|
| 现象(用户原话 / 报错原文) | 让后来的人对上号:我遇到的是不是同一件事 |
| 根因(具体到文件 / 行 / 机制) | 正文核心;"没渲染"和"按纯文本渲染"是两回事 |
| 取舍(为什么不用那个更自然的写法) | 防止下一个人"顺手现代化"改回去 |
| 证据(跑了什么、量到了什么) | 把结论钉在可复现的命令上 |
### 2.3 提交信息的写法禁忌
- ⚠️ **不要在提交信息里用反引号与 `$()`**:shell 会先做命令替换,轻则信息被改写,重则**执行**了里面的东西。一律用带引号的 heredoc:
```bash
git commit -F - <<'EOF'
fix(dobotai): 上游不再收到重复的客户端请求头
根因:……(写为什么)
EOF
```
`<<'EOF'` 的单引号是"里面的字符都是字面量"的唯一保证。
- ⚠️ 这条**不只**是提交信息:任何"把文本写进文件"的场合都一样。把**含反引号的文本**放进双引号交给 `python3 -c` / `node -e`,shell 会先执行反引号里的东西 —— 写进文件的句子凭空少片段,而**退出码仍是 0、没有任何报错**。要写的文本一律走**带引号的 heredoc**。
- 不要用 `--no-verify` 绕过门禁(CI 侧 `ci_gate.py` 会再拦一次)。
### 2.4 两处闸门(本环境已就位)
1. **本机 hook**:`~/.githooks/commit-msg`(由 `git config --global core.hooksPath` 生效)—— 快速反馈。
2. **CI 权威闸门**:`.gitea/scripts/ci_gate.py` —— 在**编译之前**执行,非法提交**不编译**。它优先读 `GITHUB_EVENT_PATH` 里的本次推送提交,读不到再退回 `git log`。
⚠️ 闸门失败时报告必须写明"**提交信息规范校验未通过**",否则一次被拦的推送会被误当成编译错误。
### 2.5 陷阱库(每个工程都该有的结构)
踩到的坑**不要只写在提交信息里**(会被埋掉)。结构:
- `AGENTS.md` §4 = **索引**:一行一条,编号 | 一句话 | 全文链接。
- `docs/agents/traps.md` = **全文**:事故经过、判据、反例。
- **为什么拆两份**:`AGENTS.md` 会被自动加载,而**指令有字节预算**,超出的部分**静默截断**。索引留在指令文件里,全文放旁边。
- 编号**只追加、不重排、不复用**:代码注释、测试、文档里会写"见 4.46"。
---
## 3. 本地门禁(提交前的最低要求)
### 3.1 一条命令,串行,失败即停
每个仓库**必须**有一条"跑完本地门禁"的入口,而且**只许有一条**:
`make verify`(首选名字,本环境已统一)/ `npm run verify` / `scripts/verify.sh` 都行。
⚠️ **名字可以不同,但 AGENTS、Runbook、CI 必须指向同一个**:
两边各写一套步骤的结局是"迟早只改一边",而症状是本地绿、CI 红(或反过来),谁都说不清哪边是对的。
- **必须串行**:构建会清空产物目录,而端到端/契约测试跑的是**产物** ⇒ 并行会随机报"某条路由 404"。这类假失败会把你带到完全错误的方向。
- **失败即停**:后面很多步骤依赖前面的产物,继续跑只会产生一堆同因假失败,把真正的线索埋掉。
- 跳过项要**显式警告"不算全绿"**,并以非 0 退出。
- 与 CI 的 `build` 作业**一一对应**,只允许两处刻意差异:① 依赖外部环境的对拍/联调在 CI 上是"环境跳过",本地**必须真跑**;② 对拍单独一步,好让"门禁全过、只有对拍没跑"一眼看得出来。
建议步骤形状(换语言/工具即可,**形状**才是要点):
| 步 | 示例 | 为什么在这一步 |
|---|---|---|
| 1 格式 | `gofmt -l` / `prettier --check` / `ruff format --check` | CI 会因为这一条红 |
| 2 静态检查 | `go vet` / `tsc --noEmit` / `ruff check` | 类型/静态错,后面全是噪声 |
| 3 构建 | `go build ./...` / `npm run build` | 产物会被"跑产物"的套件消费 ⇒ 那些套件必须排在它之后;构建本身**不是**所有测试的前提 |
| 4 单元测试 | `go test ./...` / `pytest -q` / `npm test` | 大多数是**测源码**:Go 自己编译测试程序、pytest 直读源码、Vitest 默认也跑源码 |
| 5 集成/端到端 | 真起服务、打真实端口 | 单测覆盖不到"TCP 上真的连得上" |
| 6 部署脚本自测 | `bash deploy/deploy_test.sh` | 真跑一次 release;守的是**静默故障** |
### 3.2 八条通用铁律
1. ⚠️ **"跑产物"的套件(端到端 / 契约 / 产物自检)必须排在构建之后**:它们读的是构建输出,
改了源码不重建,测的就是**旧代码**(第一次一定会被这个骗一次)。
⚠️ 但**别把这条推广成"所有单测都依赖预构建产物"** —— 那是错的:`go test` 自己编译测试程序、
`pytest` 直读源码、`vitest` 默认也跑源码。写错这条的代价是给每个测试套件加上假的"先构建"依赖,
白白拖慢门禁,还会制造"没构建所以红了"的假故障。(本规范早期版本就是这么写的,已改回。)
2. ⚠️ **只在"干净 clone"里出现的缺陷**:本地工作区总有"恰好能跑"的残留。要专门造一次干净树(`git clean -xdf` 后 clone / CI 的干净检出)验证。
3. ⚠️ **每个校验都要反向自检**:
- 断言"它会红":故意弄坏一处,必须失败。
- 断言"它不会假绿":给它一条**对照**(正例必须过)。
- 断言"校验器作用在目标上":曾有一条 `nginx -t -c` 静默地在读镜像自带的配置 —— 每次"通过",实际什么都没验。
4. ⚠️ **断言要有唯一命中点**:负向断言会被**别的记录**撑成假绿(判据太宽);判据太窄则永远够不着,而且报绿。
5. ⚠️ **测试夹具要打扫**:只建不删会把 `$TMPDIR` 攒到几万条,之后 `mktemp -d` 开始卡住 —— 表现像"某个用例死循环",实际是环境问题。
6. ⚠️ **跑之前先确认没有上次遗留的进程**(`pgrep -fl "app"`):否则新实例 `EADDRINUSE` 起不来,探针打到旧实例上 ⇒ 一堆**假 401/404**。
7. ⚠️ `set -o pipefail` + `grep -q` = **SIGPIPE 谎报失败**:`grep -q` 一命中就退出并关闭管道,上游随即 SIGPIPE,整条管道返回 141 —— 明明匹配上了却判失败。用**不带 `-q` 的 grep**,或让它直接读文件。
8. ⚠️ **文档里不许出现会漂移的数字**(测试条数、断言条数)。要么由脚本打印,要么写成断言(如 `EXPECTED_CHECKS`)。
---
## 4. CI 流水线(自动检查 / 测试 / 构建 / 测试环境部署)
### 4.1 触发规则(Gitea 口径)
Gitea 没有 `workflow: rules`,触发写在 `on:`。本环境只允许三类入口:
| 入口 | 写法 | 用途 |
|---|---|---|
| 推任意分支 | `on.push.branches: ["**"]` | 校验 + 编译(**不部署**) |
| 人工发起 | `on.workflow_dispatch` | 手动重跑校验;生产发布是**独立 workflow**(见 §5) |
⚠️ **CI 与 CD 分离(本环境口径)**:推送触发的流水线**只做 CI** —— 检查、测试、构建、证据。
"推 main 就自动部署测试环境"**不是**通用规范:部署目标、产物形态、迁移与回滚由**每个项目自己设计**
(§8)。通用模板里默认**不含**任何部署作业。
反例(本规范早期版本):模板里放一个 `deploy:test` 占位作业,它打印 TODO 然后返回成功 ——
那不是"还没做",那是**假报成功**:网页上看起来部署过了。
**硬约束**:
- ⚠️ **生产部署必须是独立 workflow,只由 `workflow_dispatch` 触发**,不得塞进 push 流水线里做成一个"永远等不到人点"的作业。
原稿那条"永不结束的流水线"教训在 Gitea 下的等价形式:Gitea 里 `if:` 不成立的作业是 **skipped**(不是 GitLab 的 blocked),不会把流水线挂死;**但** `concurrency.cancel-in-progress` 会把正在跑的部署**取消**,而**「取消流水线」不是回滚** —— 取消不改任何已经发生的事,测试环境/生产上跑着的还是那个版本。这两点都必须在文档里说清,否则一定会有人拿它当回滚用。
- **不要接 tag 触发**。要按 tag 上线,就在 `workflow_dispatch` 的 `ref` 里选那个 tag。
- 部署类作业**必须**显式等齐校验作业(Gitea 用 `needs:`)。
### 4.2 作业划分
| 作业 | 阶段 | 做什么 | 为什么单独占一个 |
|---|---|---|---|
| `gate` | 校验 | `.gitea/scripts/ci_gate.py` 提交信息闸门 | 失败就**不编译**,一次被拦的推送不会被误当编译错误 |
| `build` | 构建 | 装依赖 → 静态检查 → 单测 → 构建 → **产物自检** → 打包/送产物 | 唯一产出交付物的作业 |
| `validate` | 校验 | 环境参数不变量、配置语法、规范守门 | 只读仓库、几秒钟,没理由拖到部署阶段才发现 |
| (无) | 部署 | **通用 CI 里没有部署作业** | CD 由项目独立设计(§8);占位作业不许假报成功 |
要点:
- **校验类作业与 `gate` 归"校验/构建"**,用 `needs:` 显式等齐再进部署。
- 每个作业显式声明 `runs-on:` 标签(本环境是 `ubuntu-latest`);**Runner 侧不要勾 "Run untagged jobs"**。
- 单 Runner `capacity: 1` ⇒ **同一标签的作业串行**。这是天然的编译互斥,但**不能**替代部署互斥(见 §5.4)。
- **必须自己 clone**:Weknora 三仓的 runner 配置下,act_runner **不会**自动检出代码,工作区初始为空 ⇒ 工作流里要 `git init` + 加带令牌的 remote + `fetch --depth 50` + `checkout -B`。(见 `templates/gitea-build.yml`)
### 4.3 第三方 action 的取舍(原稿这条要修正)
原稿写"**不用任何第三方 action**"。本环境实测**不成立**:`aiball/software-pc` 的 `validate.yml` 用了 `actions/checkout@v4` + `actions/setup-node@v4`,run#1 结论 **success**(该 runner 能取到 action)。
所以规则改成**按 Runner 配置区分**:
- **默认(Weknora 三仓 runner)**:**不用**第三方 action,全部逻辑写在 `run:` 里,用镜像自带工具 + 主机挂载的 `/opt/citools`。理由是该 runner 上 `github_mirror` 为空、且实测过 `uses:` 拉取不稳定;宁可慢,不要随机红。
- **例外(aiball 的 `runner-software-01`)**:允许 `uses:`,但必须在 workflow 顶部注释里写明"该 runner 支持取 action",并**不得**用需要凭据/网络的 marketplace action 做发布步骤。
- ⚠️ 判据:如果一个作业**时红时绿、报错是拉取失败**,先怀疑 `uses:` 而不是代码。
### 4.4 act 执行语义(本环境的硬事实)
- 每步以 `bash -e -o pipefail` 执行 ⇒ 禁止 `cmd | head` 这种会 SIGPIPE 提前退出的管道。需要拿退出码时用 `set +e; set +o pipefail` + `${PIPESTATUS[0]}`(现有模板已这么做)。
- 进镜像先补齐工具并**一次性把缺的都报出来**(`bash`/`git`/`curl`/`tar`…),别等到某一行才 `command not found`。alpine 默认只有 busybox 的 `sh`,**没有 `bash`**。
- `LANG=C.UTF-8` / `LC_ALL=C.UTF-8`:默认 C locale,中文提示会乱码。
- **校验/上报逻辑放 `.gitea/scripts/` 脚本里,YAML 只负责调用**:深层内嵌 Python 在 YAML 里很脆,被编辑器重排缩进就静默坏掉。
- 日志格式统一:`── 小标题 ──`、`✓ / ✗ / ⚠️ / ❌`。**失败信息必须包含"下一步怎么办"**(看哪个文件、跑哪条命令、找谁)。
### 4.5 工具链与缓存
- 工具链来自主机**只读挂载**:容器内 `/opt/citools`(真实路径 `/srv/aiball/ci/toolcache`)。
- 缓存写**可写挂载**:容器内 `/opt/citools-cache`(真实路径 `/srv/aiball/ci/toolcache/cache`),导出 `GOCACHE` / `GOMODCACHE` / `UV_CACHE_DIR` / `GOPATH`。
- 网络参数:`GOPROXY=https://goproxy.cn,direct`、`GOTOOLCHAIN=local`、npm 走国内源。
- ⚠️ **不缓存 `node_modules`**(或等价的语言级依赖目录):它是平台相关的产物,缓存里混进另一套架构/运行时版本的二进制时,症状是"某次构建突然报原生模块加载失败",而那次构建的 diff 里什么都没有。
### 4.6 测试环境部署(**项目自己的 CD**,不属于通用 CI)
> ⚠️ 本节描述的是**项目 CD 的目标形态**,不是通用 CI 的一部分。通用模板不会生成这些作业;
> 项目要做部署时**另写独立 workflow**(`workflow_dispatch` 触发、`cancel-in-progress: false`)。
- **触发方式由项目自己决定**(例如"CI 全绿之后由人工发起")。通用 CI **不**替你决定,也**不**自动部署。
⚠️ 本规范早期版本写的是"推 `main` → 自动部署测试环境"—— 那是把某个项目的选择当成了通用规则,已改。
- 部署后**必须读回**(不是"启动成功"就算):
1. 容器镜像 ID == 本次构建产物的镜像 ID(`docker inspect --format '{{.Id}}'`);
2. `GET /healthz` 返回 `{"status":"ok"}`;
3. `GET /version` 的 `commit_sha` == 本次 `main` 的短 sha;
4. 关键 env 读回(`docker exec <ctr> env | grep -E '^DOBOTAI_'`)。
- 测试环境部署**不等于**验收:验收的门是"测试机上一轮真实业务跑通",由人确认,见 §5.1。
### 4.7 上报(通知 / 预览)
- 主机侧 `ci-bridge` 负责落报告 + 推微信 + 托管静态预览;作业容器里没有 CLI、没有微信凭据,**通知只能由主机侧发**。
- 上报步骤用 `if: always()`(失败的构建也要报),通知失败**不能**让作业失败。
- ⚠️ **兜底地址不要只写一个**:作业里按 `CI_BRIDGE_URL` → `http://172.17.0.1:8899` → `http://192.168.50.134:8899` → `http://100.92.49.78:8899` 顺序探测 `/healthz`,取第一个可达(现有模板已这么做,保留)。
- ⚠️ 报告里给出的每个 URL(报告页、预览页)**都要探测到 200** 才算发出去了,否则会收到一条没有预览链接的通知。
- ⚠️ **通知里的链接基址**用非秘密配置 `CI_PUBLIC_BASE_URL`(由 workflow 传给上报脚本):CI 运行 /
提交 / 变更 三类链接**统一**用它拼,链接里的仓库、SHA、run_id 仍是真实值。**不覆盖
`GITHUB_SERVER_URL`**(检出与所有内部调用继续用它)。配置缺失或非法 ⇒ 上报脚本**明确回退**
`GITHUB_SERVER_URL`,并把基址来源写进 `link_base_source`(便于事后核对)。
bridge 侧只渲染**允许来源**(`ci_base_url`,支持多个)的链接;**禁止**对消息文本做全局 URL 替换 ——
提交标题、日志摘要里的 URL 是事实,一个字符都不许改。
- ⚠️ **报告入口用 Gitea 的运行页**:消息里的"查看"给 CI 运行 / 变更(push 才有)/ 预览;
**不再**附 bridge 自己的 `/r/<id>` 页(重复且在内网)。只有拿不到运行链接(缺失或非可信来源)时
才退回 bridge 报告页 —— 不留"没有任何证据入口"的通知。
- ⚠️ 静态产物打包**不要产生 `.` 根成员**:`tar -czf x.tgz -C dir .` 会写入 `.` 和 `./index.html`;严格解包器可能拒绝或报错,而流水线**仍显示成功** —— 通知到了、预览没有。bridge 侧应**规范化**(剥掉 `./`,丢弃归一化为 `.`/空的成员),而不是直接拒绝整个包。
### 4.8 必需检查清单与结果口径(汇总不许自欺)
每个项目声明自己的**必需检查清单**,并让清单能被打印出来(如 `make list`),
CI 与本地都按**清单**逐项核对:
| ID | 必需检查 | 判据 |
|---|---|---|
| identity | 触发提交 / 检出身份 | 实际 `HEAD` 等于**事件完整 SHA**(不是短 sha,也不是"当时的分支 HEAD") |
| commits | 提交信息 | 本次推送范围内的提交全部通过规则 |
| deps-install | 依赖安装 | **按锁文件**装;缺锁即失败,不回落 `npm install` / `-mod=mod` |
| format | 格式 | 不合规即非零退出;CI **不自动修复** |
| static | 静态检查 / 类型 | 声明的语言模块全部通过 |
| build | 构建 | 声明的模块构建成功 |
| tests | 自动化测试 | 必需套件**实际执行**且通过;空套件 / 全跳过不算通过 |
| contracts | 项目契约 | 版本与产物/接口契约通过(被 SKIP 的断言**不算**通过) |
| security | 密钥 / 依赖漏洞 | 无泄漏;无未获授权的适用高危风险;**扫描器没跑成也不许算过** |
| manifests | 依赖清单 | 门禁过程中 `go.mod` / `go.sum` / 锁文件没有被任何一步改写 |
结果口径(三条,都是踩出来的):
1. **失败即停,但汇总照做**:第一个必需项失败后,剩下的记为 `not_run` 并**如实写进报告**,
不许从报告里消失(消失了,下游就会当成"本来就没有这一项")。
2. **`not_run` / `skipped` 都不算过**:汇总只认 `success`;
不适用要**在清单里声明**(比如"本地没有事件负载,identity 只在 CI 必需"),不能在运行时悄悄跳过。
3. **汇总本身故障 ⇒ 不能报绿**:读不到 / 解析不了报告时,结论是"质量无从判定",不是"没问题"。
预览与通知是**旁路**:预览只在全部必需项 `success` 之后发布(并且要断言产物包里真的有入口文件、
报告里给出的每个地址都探测到 200);通知失败**不改质量结论**,只单独标注 `notification_status`。
---
## 5. 生产发布(人工确认 / 健康检查 / 互斥 / 回滚 / 同一产物)
### 5.1 生产使用测试验收过的**同一份构建产物**(本环境新增硬约束)
> ⚠️ 原稿只讲"产物分层 + 回滚",没有把"同一份产物"写成硬约束。而本环境现状是**在目标机上各自 build**(测试机 build 一次、生产机再 clone 源码 build 一次),**两份二进制并不逐字节相同**。这必须改。
**铁律**:构建**只做一次**,产出**不可变产物**;测试环境与生产环境部署的是**同一份**(同 `sha`、同镜像 ID、同 tar sha256)。
落地流程:
```
① 构建(只此一次,在仓库 Runner 上)
docker build --build-arg VERSION=<v> --build-arg COMMIT_SHA=<sha12> --build-arg BUILD_TIME=<ts> \
-t dobotai-platform:sha-<sha12> <SRC>
IMAGE_ID=$(docker image inspect --format '{{.Id}}' dobotai-platform:sha-<sha12>) # sha256:...
docker save dobotai-platform:sha-<sha12> -o dist/dobotai-platform-<sha12>.tar
TAR_SHA=$(sha256sum dist/dobotai-platform-<sha12>.tar | cut -d' ' -f1)
# 写发布清单(唯一真值,后续两个环境都对着它核对)
→ release-<sha12>.json { sha12, commit_sha, version, build_time, image_id, tar_sha256 }
② 部署测试环境:docker load < tar → 断言 image_id == 清单 → 起容器 → 读回 /version
③ 人工验收:在测试环境跑一轮真实业务,通过后在 release issue/PR 里留下验收记录
④ 部署生产:取**同一个 tar**(按 tar_sha256 校验收到的文件)→ docker load → 断言 image_id == 清单 → 切镜像
```
**证据链(缺一即不合格)**:
| 环节 | 断言的命令 | 通过判据 |
|---|---|---|
| 测试机加载 | `docker image inspect --format '{{.Id}}' <tag>` | == 清单 `image_id` |
| 测试机运行 | `curl -fsS 127.0.0.1:8090/version` | `commit_sha` == 清单 `sha12` |
| 生产机加载 | `sha256sum <tar>` 然后 `docker image inspect --format '{{.Id}}'` | 分别 == 清单 `tar_sha256` / `image_id` |
| 生产机运行 | `curl -fsS 127.0.0.1:8090/version` | `commit_sha` == 清单 `sha12` |
| 两端一致 | 比较两次 `image_id` | **完全相同** |
- **例外(必须写明)**:数据库迁移**不属于**镜像产物,是独立步骤,顺序是"**先迁移、后切镜像**"(见 §5.5)。
- **回退用镜像回退**,**不是**把源码改回去重编。
### 5.2 生产发布的人工确认
Gitea 1.26.2 的 Actions **没有** GitLab 的 `when: manual` + `manual_confirmation`。人工确认按三层落地:
1. **平台层**:生产部署是**独立 workflow**,只有 `on.workflow_dispatch`,不存在自动触发路径。
2. **参数层**:dispatch 必须显式传入 `sha`(要发的短 sha)与 `confirm`(必须等于字面量 `deploy-production`),workflow 第一步就校验;不匹配直接红。防误点、防机器人误触。
3. **人层**:**最后一步"切镜像"由本人在生产机执行**(或由本人在生产机确认脚本输出)。CI 只负责"传产物 + 备好命令",不替人按下那一刻。
(若实例已启用 Environment 审批之类能力,可作为额外一道;**本规范不假设它存在**,需在实例上实测,见 `03-待配置事项.md` 第 8 项。)
### 5.3 健康检查(读回证据,缺一即不合格)
生产切换后**必须**取全下列证据,不能只看"容器起来了":
```bash
curl -fsS http://127.0.0.1:8090/healthz # {"status":"ok"}
curl -fsS http://127.0.0.1:8090/version # version/commit_sha/build_time
docker inspect dobotai-dingtalk --format '{{.Config.Image}}' # 新 tag
docker inspect dobotai-dingtalk --format '{{.Id}}' # == 清单 image_id
docker inspect dobotai-dingtalk --format '{{.State.Status}}' # running(该容器无 healthcheck 段落,别读 .State.Health)
docker exec dobotai-dingtalk env | grep -E '^DOBOTAI_(DEFAULT_MODE|REASONING_AGENT_ID|AGENT_BINDINGS)='
docker exec dobotai-dingtalk grep -aqF '<字面量>' /proc/1/root/usr/local/bin/dobotai # 只 grep 字符串字面量
docker logs dobotai-dingtalk --since 60s | tail
```
- ⚠️ 构建用了 `-ldflags "-s -w"`,Go 符号被剥离 ⇒ **grep 符号证明不了任何事**,只能 grep **字符串字面量**。
- ⚠️ **loopback 健康 ≠ 业务验收**:最后的门是**本人在生产钉钉机器人上发一条真实消息**,再回读 `turns.agent_id`。
### 5.4 部署互斥(原稿的 `resource_group` 在本环境不存在)
- Gitea 1.26.2 **没有** `resource_group`。可用的第一层是 `concurrency:`,但它只对"同一 workflow 的同一 group"生效,**跨 workflow、跨人手手动执行时挡不住**。
- **真正的互斥放在目标机**(可靠性只在这一层):
```bash
exec 9>/var/lock/dobotai-deploy.lock
flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
```
- ⚠️ **不要**用 `concurrency.cancel-in-progress` 去"保证互斥":它取消的是**正在进行的部署**,而**取消不是回滚** —— 镜像可能已经换了。生产的 concurrency 组**必须** `cancel-in-progress: false`。
- ⚠️ 测试与生产**两个锁是两个文件**;而且两个环境共用同一套阿里云 RDS ⇒ **压测/并发高的动作不要同时在两边跑**(会扰动生产)。
### 5.5 数据库迁移(与镜像分开发布)
本环境(DobotAI)**应用不会在启动时自动迁移**;新的 `db/migrations/0000NN_*.sql` 必须人工按序执行:
1. **幂等门**:先数 `information_schema.columns` 里有没有新列 —— `0` 才继续,`1` 直接中止(已应用),其它值中止。
2. 版本匹配:RDS 是 **PostgreSQL 17.9** ⇒ 用 `postgres:17-alpine`(`postgres:16-alpine` 会 `server version mismatch` 失败)。
3. DDL 前先 schema-only dump 到 `container/backups/`(0600)。
4. 暂存迁移文件到 `container/migrations/`(部署集 = 本地记录)。
5. 在**一个事务**里执行,带 `DO $$ ... RAISE EXCEPTION` 守卫。
6. 读回精确契约(如 `is_nullable|column_default` = `NO|false`)。
7. **凭据不进 argv**:写 0600 临时文件 `PGURL=<url>`,`docker run --rm -i --network host --env-file <file> ... 'exec psql -v ON_ERROR_STOP=1 "$PGURL"'`。
8. 顺序:**迁移完成并读回后**,才切镜像。
**回滚**:加列型迁移(新列可空/有默认)**不必**跑 `.down.sql` 就能回滚应用(应用回退后旧代码不看新列)。
### 5.6 回滚
- 切换前先把**当前在跑的镜像**打回滚标签:`docker tag <current> dobotai-platform:rollback-<old12>-before-<new>`。
- 回滚 = 恢复回滚标签 + `docker compose up -d --force-recreate` + 读回 health/image/env(脚本 `rollback_dobotai_prod.sh`)。
- 回退到几天前的版本 = 找到那个 commit,用 `workflow_dispatch` 把 `ref` 指向那个 commit(或直接用它的产物 tar)。
- ⚠️ **「取消流水线」不是回滚**(重复三遍也不多):取消不改任何已经发生的事。
- ⚠️ 回滚**不改** `version` 常量/源码;回滚发生在部署层。
### 5.7 目标机布局(三层,是回滚/清理/排障的前提)
| 层 | 内容 | 说明 |
|---|---|---|
| `state/` | 数据库、密钥、备份、日志 | ★ **唯一不可再生**,任何自动化脚本都不许碰 |
| `runtime/` | 程序本体 + 启动器 | 可随时重装;回退靠它 |
| `deploy/` | compose、模板、脚本、env、密钥中转 | CI **每次都会重写**;整个删掉不会丢任何东西 |
(DobotAI 实际落在 `/opt/dobotai/{config,container,secrets}`:`secrets/` 是不可再生的 `state/`,`container/backups/` 是备份层。)
### 5.8 目标机操作细节(都是踩出来的)
- **目标机上只跑一个自包含的部署脚本**,把这一串放在同一个脚本里、**顺序不许挪**:
`① 校验产物 ② 备份当前版本 ③ 加载并原子替换 ④ 渲染配置 ⑤ 升级前备份数据库 ⑥ 体检 ⑦ 起容器 → 等健康 → 读回`
把"停容器、再换目录"分在两个地方写,**一定会有人改错一处**。CI 只负责"传文件 + 调它"。
- **幂等**:每一步都是可重跑的(`mv`/`load`/`tag`),中途失败重跑一次即可。(脚本要**带门**:迁移列已存在就拒跑、镜像 tar sha 不符就拒跑,防止部分失败后重跑造成双重应用。)
- ⚠️ **非交互 ssh 的 PATH 与交互登录不同**:`ssh host "命令"` 是非交互、非登录 shell,**不读 `/etc/profile`** ⇒ 会出现"机器上敲 `docker compose -v` 正常,流水线里却 `docker: command not found`"。两条保险:远端命令显式补 PATH;脚本内部把 docker 解析成绝对路径(`sudo` 也会重置 PATH)。
- ⚠️ **连通性预检要分诊**:按 stderr 特征分别提示"公钥被拒 / 解析不了主机 / 连不上 / 主机密钥变了 / 私钥格式坏了",并打印**在 Runner 那台机器上**复现的命令(强调 `-F /dev/null` —— 忽略本机 `~/.ssh/config`,否则会连到别的地址,报出来的错与真实原因无关)。
- ⚠️ **长脚本不要走 SSH heredoc**:嵌套引号会塌。本地写好脚本 → `scp` → 按绝对路径执行。多步远程作业一律如此。
---
## 6. 版本号与发版
> **本节 2026-10-12 重写**(原稿 §6 只有"构建注入"与"默认不打 tag"两条):
> ① 权威来源从笼统的"构建时注入"改成**每个项目显式声明一个来源**;
> ② 正式发版改为**注释 tag + Gitea Release**(原稿写的是"默认不打 tag");
> ③ 新增"CI 成功 / 正式发版 / 部署"三态区分;
> ④ 新增 CI 自动检查与**只读**的发布预检入口。
>
> **本节 2026-10-13 二次修正**(对着真实试点仓 `knowledge-governance` 落地时发现的问题):
> ① 版本条目形状由"**四个自定义必填节**(改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题)"
> 改为"**固定四类小节**(Added / Changed / Fixed / Security,分类可省略、不许空栏目)"——
> 原来的自定义节会把审查过程、测试命令、待办都塞进发布记录;过程记录改放 `docs/archive/`、待办放 `docs/BACKLOG.md`;
> ② **破坏性变化**改为固定写在 `### Changed` 里、以 `**破坏性**` 开头并**必须链接发布文档**(链接目标会被对账,不存在即红);
> ③ 新增**构建身份**(`release_version` / `build_kind` / `commit_sha` / `display_version` / `built_at`):
> 开发构建显示 `X.Y.Z-dev+g<短SHA>` 或 `dev+g<短SHA>`,**不许只显示上一正式版本号**,也不许自称正式版;
> ④ **还没有正式版本**(新仓常态)必须放行:`version` 检查不判红、构建落入 `dev+g<短SHA>`、
> 首个正式版本号**由人指定**(预检把它列为阻塞项,不替你编一个号);
> ⑤ **没核对远端 tag 就不许说"校验成功"**:CI 用 `--require-remote`,拿不到远端清单即红;
> ⑥ 修正"CI 取不到 tag 所以不用 tag"的过时口径:**正式版本就是要用注释 tag**,
> 只是**构建时不要用 `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 不存在且查询无异常。首发按**显式指定版本**处理,**不把"记录里有这个版本"当作已发布**。
> ⚠️ 预检**只读**:**自动发布路径已禁用** —— `--execute` / `--approve-*` 只保留参数兼容
> (不报 unknown argument),传了就**明确拒绝**(rc=2,且拒绝发生在收集事实之前 ⇒ **零写操作、零 API 调用**);
> 输出里也不推荐自动路径。正式发版动作由人按 `07-发版操作说明` 执行。
> ⑩ **CI 就绪判据**:必需 context 集合来自 `.gitea/required-contexts.txt`(缺失/为空 ⇒ **没有策略**,
> 直接阻断,不许拿"体检时观察到的状态"顶替);**失败 / 缺项 / pending / 查询异常一律阻断**。
> **Release 查询只有 HTTP 404 才算「不存在」**,403/5xx/超时都算查询异常,不得当作可发。
>
> **本节 2026-10-13 四次修正**(版本递增方式):
> ⑪ **开发版本号自动递增**:`<主>.<次>.<自 `v<正式版本>` 以来的提交数>`(`0.1.1`、`0.1.12`…),
> 由 git 算出来、**不写进 CHANGELOG** ⇒ 每个提交都有自己的版本号,而**发布版本仍由人批准**
> (CI 不自动升版本、不打 tag —— 两条同时成立);
> ⑫ **浅克隆里拒绝算号**(退回 `-dev+g<短SHA>`),检出步骤在有 tag 时 `--unshallow` 补全历史;
> ⑬ **发布时次版本加一**(`0.1.x` → 下一个 `0.2.0`),预检挡住"候选 <= 当前开发号"。
>
> **本节 2026-10-13 五次修正**(抬档判据硬化):
> ⑭ **抬哪一档由规则定、由预检拦**(硬门禁):破坏性条数取自**目标提交里候选版本那一段**(不看工作区)——
> **`0.x` 阶段:有破坏性必须抬次版本(或主版本)**,只抬修订 ⇒ 阻断;
> **`≥1.0.0` 以后:有破坏性必须抬主版本**,抬次/修订 ⇒ 阻断;
> 无破坏性时抬哪一档不限,但候选号必须**严格大于**上一个正式版本、且大于当前开发号(号只增不减)。
> ⑮ **`1.0.0` 首见 = 对外承诺契约稳定**,必须有写实的《稳定契约承诺》(覆盖 接口 / 数据模型 /
> 配置 / CLI 输出与退出码 四项、**不许留 `待填`/`TODO`/`TBD`**)**且候选发布记录里链接它**,
> 否则阻断;`1.x` 之后抬 `2.0.0` 不重复要求。
> 判据的模板落点:`templates/release-preflight.py`(`bump_level()` / `breaking_in_commit()` /
> `stability_contract_problems()`)+ 项目 `docs/RELEASE_RUNBOOK.md` 的《稳定契约承诺》段。
>
> 操作步骤见同级 `07-发版操作说明(tag与Release).md`。
### 6.1 版本号只有一个权威来源(每个项目**显式声明**它)
- 每个项目必须**声明**自己的版本号权威来源,并写进该仓的 `AGENTS.md`;
其他位置一律**读取或生成**,禁止多处手工同步。
- 本环境已声明的:
- `knowledge-governance` —— **仓库根 `CHANGELOG.md` 顶部那条已发布版本**
(`scripts/version.py` 读它;构建时 `-ldflags -X …/internal/version.Number=<该值>` 注入)。
- 其他 Go 项目 —— 默认同样以 `CHANGELOG.md` 为权威来源;要换成别的来源,必须在 `AGENTS.md` 里写明。
- ⚠️ **禁止**:仓库根再放一个 `VERSION` 文件、把版本号写进源码常量、用 `git describe` 取版本
(CI 检出是 `git fetch --depth 50`,**取不到 tag** ⇒ `git describe` 静默给错值)。
- 前端子工程(`knowledge-governance/web`)的 `package.json.version` 只表示前端包版本,**不是**服务版本。
- 版本号**只增不减**;回退走部署层回滚(§5.6),**不改版本历史**。
- 运行时 `/version` 报出的 `version` / `commit_sha` / `build_time` 是"线上跑的是哪个提交"的**权威答案**;
CI 断言"注入进二进制的号 == 权威来源"。
- **没有 HTTP 面的项目**(纯脚本/内部服务)至少要能用 `python3 scripts/version.py --identity`
(或 `make version`)打印构建身份 JSON;把"用户可见标识"与"仓库/提交事实"一起给出来。
### 6.2 版本号格式、日常改动与构建身份
- 三段十进制 `主版本.次版本.修订版本`;**不许前导零**(`01.2.3` 不合格);**只增不减**。
- 日常改动记入 `CHANGELOG.md` 的 `[Unreleased]`(按 §6.3 的四类小节);
**不要求每个提交都改本文件** —— 有使用者可见的变化才记。
- `[Unreleased]` 段若有,必须在文件**最上面**;发版时把它改成 `## [x.y.z] - YYYY-MM-DD`。
- **构建身份**(开发构建也要能说清"这是哪个开发版"):
| 字段 | 含义 |
|---|---|
| `release_version` | 正式版本基线;**还没有正式发布过就是空** |
| `build_kind` | `development` / `release` —— **只有显式发布上下文**(如 `BUILD_KIND=release`)才是 release |
| `commit_sha` | 完整 40 位提交 SHA |
| `display_version` | 用户可见标识:正式 `X.Y.Z` | **开发 `<主>.<次>.<自 v<正式版本> 以来的提交数>`**(如 `0.1.12`)|无基线/取不到 tag `dev+g<短SHA>` |
| `commits_since_release` | 自 `v<release_version>` 以来的提交数(无 tag / 浅克隆 ⇒ 空) |
| `built_at` | 构建时间 |
⚠️ 开发构建**不能**只显示上一正式版本号;普通 `main` 构建**不许**自称正式版
(判据是显式上下文,不是"CHANGELOG 顶部有版本号")。
- **开发版本自动递增(零人工)**:`<正式版本的主.次>.<自 `v<正式版本>` 以来的提交数>` ——
例:正式 `0.1.0` 之后第 1 个提交 = `0.1.1`、第 12 个 = `0.1.12`;恰在发布提交上 = `0.1.0`。
它**不写进 CHANGELOG**(发布记录只在发版时动),由 `scripts/version.py` 从 git 算出来 ⇒
**每个提交都有自己的版本号、数字只增**;自动升的是**开发标识**,发布版本与 tag 仍由人批准
(与"CI 不自动升版本/不打 tag"不冲突)。
⚠️ **浅克隆里拒绝算号**(`--depth` 截断会把条数算少 ⇒ 会报错号):退回 `-dev+g<短SHA>`;
检出步骤在仓库有 tag 时补全历史(`--unshallow`)。
接线(三处都已接好,别只改一处):`scripts/version.py --identity` → 门禁 `--internal-build` 的
`-ldflags -X …CommitsSinceRelease=<n>`;`Makefile` 的 `COMMITS_SINCE_RELEASE`;`Dockerfile` 的同名 build-arg。
契约测试会**交叉核对**:注入进来的提交数必须等于 git 算出的(防注入错号)。
⚠️ **下一次正式发布请把次版本加一**(`0.1.x` 之后发 `0.2.0`),保证正式号严格大于此前所有开发号、不撞号;
发版预检会挡住"候选 <= 当前开发号"的候选。
### 6.3 条目形状:固定四类小节(**不写过程记录**)
标题形如 `## [x.y.z] - YYYY-MM-DD`。小节**只认四类**:
| 小节 | 写什么 |
|---|---|
| `### Added` | 新增能力 |
| `### Changed` | 行为调整(**破坏性变化写在这里**) |
| `### Fixed` | 修复 |
| `### Security` | 安全相关变化 |
- **分类可省略**(有哪类写哪类),**但不要空栏目** —— 写了就得有内容,不需要就整节删掉。
- 面向**使用者**描述变化,**不逐条复制提交**;不写"第几轮审查"、不贴命令与测试输出。
- **过程记录**(审查轮次、命令输出、逐条修正明细)放 `docs/archive/`;
**未合并事项**放 `docs/BACKLOG.md`,不作为已完成变更写进变更记录。
- **破坏性变化 / 数据迁移**:写在 `### Changed` 里,**以 `**破坏性**` 开头**,并**链接发布文档**
(升级/迁移步骤与回退办法写在那份文档里):
```
### Changed
- **破坏性**:接口 `/api/old` 下线,升级步骤见 [迁移步骤](docs/RELEASE_RUNBOOK.md)
```
⚠️ 检查器会**对账**仓内链接:`docs/….md` 不存在 ⇒ 红(防止链接到一个不存在的 runbook)。
### 6.4 正式发版 = 注释 tag + Gitea Release(**要人批准**)
- 正式发布使用**注释 tag**(`git tag -a`),名称 `v<版本号>`,指向**验收过的准确提交**(完整 40 位 SHA)。
- Gitea Release **关联该 tag**;Release 说明**来自 CHANGELOG**,不维护第二份独立正文。
- ⚠️ **只有主人明确批准后**才打 tag / 建 Release。**CI 不自动升版本、不打 tag、不发布**。
- ⚠️ **不接 tag 触发流水线**(要把某个 tag 上线,就在 `workflow_dispatch` 的 `ref` 里选它)。
- ⚠️ 已发布的 tag **不移动、不覆盖**(需要"重发"就发一个新版本号);回滚恢复旧产物,不改版本历史。
### 6.5 三个状态必须分开说(不许合并成一个"绿")
| 状态 | 含义 | 证据(缺一即不算) |
|---|---|---|
| **CI 成功** | 这次提交通过了必需检查 | 提交上的 status context + run 链接 + **完整 SHA** |
| **正式发版** | 批准后打了 tag / 建了 Release | 远端 tag 的 SHA == 验收过的提交(**注释 tag**);Release 的 `tag_name` 读回一致;构建身份 `build_kind=release` 且 `release_version` 有 tag 证据(§6.2 ⑧) |
| **部署** | 某个环境真的在跑这个版本 | `/version` 的 `commit_sha` + 部署读回证据(§5.3) |
"CI 绿"不等于"发过版","发过版"不等于"部署了" —— 汇报时说清是哪一个。
### 6.6 自动检查(CI 必查,失败即红)
1. `CHANGELOG.md` 格式与版本号合法:三段十进制、无前导零、**日期合法**、顺序严格递减、不重复;
2. 小节只属四类、**没有空栏目**、**破坏性变化在 Changed 里且带可用的发布文档链接**(§6.3);
3. **构建身份 == 权威来源**:发布构建的 `release_version` 必须等于 CHANGELOG 顶部;
开发构建必须是 `dev` 形状且**不许自称正式版**(这条要有一条"自称正式版 ⇒ 必须红"的负例);
**注入的断言被 SKIP 也算不通过**;
4. **存在正式 tag 时**:tag 版本 == 它指到的那个提交里的版本记录;正式 tag 必须是**注释 tag**
(轻量 tag 不合格);**核对必须真的问远端**(`--require-remote`)——
拿不到远端清单 ⇒ 红(没核对就不许说"校验成功");非正式 tag(如本地 `x`)不当作发布版本;
5. 缺失 / 格式错 / 版本漂移 ⇒ **失败**;
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 一致性)
与 `scripts/version.py --identity`(构建身份);
清单里的检查项 id 叫 `version`(与 `contracts` 分开:一个是"记录是否合规",一个是"注入是否等于来源")。
### 6.7 发布预检入口(**只读**,不产生副作用)
正式发版前先跑预检,它只**列出事实**:
- 待发布版本(默认按 `--bump` 给建议值,也可 `--version` 显式指定);
- **完整提交 SHA**(HEAD 或 `--sha`);
- 对应的 **CI 成功证据**(该提交上的 status context + 最近一次运行);
- CHANGELOG 里对应内容(`[Unreleased]` 原样打印);
- 同名**远端 tag / Gitea Release 是否已存在**;
- 遗留风险与未验证项(指向 CHANGELOG 的『已知问题』与本次声明的未验证项 —— 预检**不替你判断**可否接受)。
预检有阻塞项(工作区脏 / 检查不过 / 没有 Unreleased / 版本已存在 / tag 或 Release 已存在 /
没有 CI 成功证据 / 拿不到凭据 ⇒ 只能写"未核对")时,**不给"可以发"的结论**。
批准后才执行:`--execute --approve-version X.Y.Z --approve-sha <完整SHA>`(两个批准参数必须逐字符等于预检结果),
顺序是**打注释 tag → 推送 tag → 建 Release → 读回**(读取不到 tag SHA、或 Release 的 `tag_name` 不一致 ⇒ 不算完成;
tag 推送成功但建 Release 失败时**不要重复推 tag**)。
参考实现:`scripts/release_preflight.py`(`--self-test` 断言"写类 git 命令与 POST/PUT/DELETE/PATCH
在预检路径上被拒")。
---
## 7. 密钥、变量与配置
### 7.1 分类放置
| 类别 | 放哪 | 为什么 |
|---|---|---|
| 非密钥参数(目录、端口、容器名、镜像 tag) | 版本库 `deploy/env/*.env` 或目标机 `/opt/.../config/`(视项目而定,见下) | 可 review、有历史、有 diff |
| 密钥与连接信息 | Gitea 仓库 **Secrets** | 不入库、可轮换 |
| 全项目共用的可选值(播报地址等) | Gitea 仓库 **Variables** | 与环境无关 |
- ⚠️ **Gitea 1.26.2 没有 GitLab 的"environment scope"**:同名变量**不能**按环境取不同值。要用**命名后缀**区分:
`DEPLOY_TEST_HOST` / `DEPLOY_PROD_HOST`、`DEPLOY_TEST_SSH_KEY` / `DEPLOY_PROD_SSH_KEY`。
- ⚠️ **Gitea 1.26.2 也没有 "File" 类型 secret**:私钥内容整体存为 secret,运行时由脚本写文件并 `chmod 600`。
- ⚠️ 变量/密钥的**上报接口形状**(实测):变量用 `POST/PUT /repos/{r}/actions/variables/{name}`,body `{"value": …}`;密钥只有 `PUT /repos/{r}/actions/secrets/{name}`(upsert),body `{"data": …}`。**集合级 POST 返回 405 且空 body**,那看起来像权限问题、实际不是。写完必须 `GET` 读回。
(`/srv/aiball/runner/attach_repo.sh` 已封装这个形状,复用它。)
### 7.2 启动时先校验变量清单,再上传任何字节
缺必需变量就直接红 —— 否则 CI 会把 `${XXX}` 替换成空串,容器照样起得来但日志里全是"配置为空",排查很费劲。
检查时**只打印变量名与 ✓/✗,绝不回显值**。
### 7.3 把密钥写到目标机的规矩
- 走 **stdin 重定向**写文件(`ssh host "cat > path" < file`),**不要塞进 ssh 命令行** —— 那会让密钥出现在远端的 `ps` 输出里。
- 落地即 `chmod 600`;权限**显式**设置,**不依赖目标机 umask**。
- 私钥文本先 `sed -i 's/\r$//'` 去 CRLF、并补结尾换行,否则 ssh 会报误导性的 `error in libcrypto` / `invalid format`。
- 密钥写入与"渲染成配置"**只在一处加引号**:两边都加会变成 `''value''`,引号本身成了值的一部分。
---
## 8. 新仓库接入清单
照着做一遍(`Weknora/Enterprise-Knowledge-Base` 就是下一个要接入的):
- [ ] **1. 仓库与默认分支**:默认分支定 `main`;分支收敛到 `main` + 短命分支(长期分支要么合要么删)。
- [ ] **2. `.gitignore`**:依赖 / 构建产物 / 运行期数据 / 密钥 / 发布产物各一条;再用 §1.4 的两条反查命令确认**没有已入库的漏网文件**。
- [ ] **3. 门禁入口**:`make verify`(首选;`scripts/verify.sh` 亦可)—— 一条命令跑完、**串行**、失败即停、末尾汇总;跳过项显式警告"不算全绿"。**本地与 CI 必须是同一条命令**。
- [ ] **4. CI 骨架**:复制 `templates/gitea-build.yml` → `.gitea/workflows/build.yml`;随仓带 `.gitea/scripts/{ci_gate.py,ci_report.py}`(**逐字节复制**已验证的版本,不要重写)。
- [ ] **5. 仓库级 Runner**:`bash /srv/aiball/runner/attach_repo.sh <owner/repo> <runner-name>`(幂等;注册令牌走文件挂载,不落容器 env)。
- [ ] **6. bridge 变量/密钥**:`CI_BRIDGE_URL`(variable)、`CI_BRIDGE_TOKEN`(secret),由上面的脚本写入;写完 `GET` 读回。
- [ ] **7. 分支保护(可选,默认不开)**:见 §1.3。只在"需要任何人都不许直推"时开,且**不要**勾强制状态检查(除非该仓工作流覆盖所有分支)。
- [ ] **8. `AGENTS.md` 指针**:仓库 `AGENTS.md` 写一行指回本规范;陷阱库拆 `AGENTS.md`(索引)+ `docs/agents/traps.md`(全文)。
- [ ] **9. 自检一遍**:推一个**故意违规**的提交,确认 `gate` 红且 `build` 没跑(反向自检,§3.2-3)。
- [ ] **10. 首次推 `main` 因为缺变量而红 —— 这是设计**:变量检查在任何上传动作**之前**,目标机一个字节都不会被碰。
> **跨仓引用**:正本仓库 `zheromiku/eng-standards-main`(Gitea);本机工作副本 `~/eng-standards/`。
> ⚠️ **指令文件不会跨仓库自动加载** —— 每个仓库要在自己的 `AGENTS.md` 里写一行指针,否则那个仓库里的代理看不到这份规范。
---
## 9. 速查表
```bash
# ── 提交前 ────────────────────────────────────────────────
make verify # 本仓门禁(本地与 CI 同一条),串行、失败即停
git status --short --ignored # 确认没有该入库却被忽略的文件
git commit -F - <<'EOF' # ⚠️ 不用 -m:正文里的反引号/$() 会被 shell 吃掉
feat(scope): 标题(中文)
为什么这么改、试过什么、怎么验的。
EOF
# ── 看 CI(只读)──────────────────────────────────────────
python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/tasks?limit=5"
giteactl runs <ns>/<repo> # 最近运行
giteactl logs <ns>/<repo> <runId> # 单个作业日志
giteactl rerun <ns>/<repo> <runId> # 重跑(不推新提交即可验证整条链路)
# ── 发版(人工,CI 不代劳;详见 §6 与 07-发版操作说明)──────
# 0. CHANGELOG:把 [Unreleased] 改成 ## [x.y.z] - 日期,按四类小节写(破坏性变化带发布文档链接)
python3 scripts/check_changelog.py --tags --require-remote # 结构 + 正式 tag 与提交内版本一致(真查远端)
python3 scripts/version.py --identity # 构建身份(开发/发布、显示版本、完整 SHA)
python3 scripts/release_preflight.py # **只读**预检:版本/完整SHA/CI证据/CHANGELOG/远端tag与Release/遗留项
# 1. 主人批准(版本号 + 完整 SHA + 那次 CI 的 run 链接)之后才执行:
python3 scripts/release_preflight.py --execute --version <x.y.z> --approve-version <x.y.z> --approve-sha <完整SHA>
# 内部:打注释 tag → 推 tag → 建 Release(说明来自 CHANGELOG)→ **读回** tag SHA 与 tag_name
# 2. 部署是另一件事:构建注入版本 → 写 release-<sha12>.json → 部署测试 → 验收 → dispatch 生产
# ── 上线与回退 ───────────────────────────────────────────
# 推任意分支 → gate + build(**只做 CI,不部署**)
# 生产 → Gitea「Actions → 生产 workflow → Run workflow」选 ref=main,填 sha 与 confirm
# 回退(生产机上) → bash /opt/dobotai/container/ops/rollback_dobotai_prod.sh
# ⚠️「取消流水线」不是回滚
```
---
## 10. 附录:通用陷阱(继承原稿 + 本环境新增)
| # | 一句话 | 通用性 |
|---|---|---|
| T1 | `set -o pipefail` + `grep -q` ⇒ SIGPIPE 谎报失败 | CI 脚本通用 |
| T2 | 测试夹具太小 / 自出自判 ⇒ 假绿 | 通用 |
| T3 | 只在"干净 clone"里出现的缺陷 —— 本地必须专门造一次干净树 | 通用 |
| T4 | 手抄的清单会过期,而过期的表现是守卫变松 | 文档通用 |
| T5 | 断言"我这条守卫会红"之外,还要断言**不会假绿**:给它一条对照 | 通用 |
| T6 | 测试里的**负向断言**会被"别的记录"撑成假绿(判据太宽) | 通用 |
| T7 | 测试夹具只建不删 ⇒ `$TMPDIR` 攒到几万条,`mktemp -d` 开始卡住 | 通用 |
| T8 | "跑产物"的套件要排在构建之后;但**别**推广成"单测都依赖预构建产物" | 通用 |
| **G1** | **act_runner 不自动检出代码**(Weknora 三仓)⇒ 工作流必须自 clone | 本环境 |
| **G2** | **act 以 `bash -e -o pipefail` 执行每步** ⇒ `cmd \| head` 会假红 | 本环境 |
| **G3** | **`uses:` 第三方 action 在部分 runner 上不可用**(实测 aiball runner 可用) | 本环境 |
| **G4** | **Gitea 无 `resource_group`、无 environment scope、无 File secret** ⇒ 互斥/环境区分要自己实现 | 本环境 |
| **G5** | **`cancel-in-progress` 会取消正在进行的部署,而取消 ≠ 回滚** | 本环境 |
| **G6** | **Gitea 合并成功可能返回空 body** ⇒ 解析 JSON 会误报失败,**永不重试**,改读回 | 本环境 |
| **G7** | **目标机各不相同地 build 会得到不同二进制** ⇒ 必须"构建一次、两个环境部署同一份" | 本环境 §5.1 |
| **G8** | **作业容器走 docker 网关 `172.17.0.1:8899` 才到得了 bridge** | 本环境 |
| **G9** | **静态产物包不要带 `.` 根成员** ⇒ 看似成功、实际没有预览 | 本环境 |
| **G10** | **生产脚本必须幂等带门**(迁移列已存在就拒跑、镜像 sha 不符就拒跑) | 本环境 |