3b26457165
- **抬哪一档成为硬门禁**:破坏性条数取**目标提交里候选那一段** ⇒ `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。
742 lines
58 KiB
Markdown
742 lines
58 KiB
Markdown
# 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 不符就拒跑) | 本环境 | |