按草案逐条落到正本,并把三处已知错误的口径改回: ① 测试语义:删掉"单测跑的是构建产物,不是源码"这条被过度推广的说法。 只有"跑产物"的套件(端到端 / 契约 / 产物自检)才必须排在构建之后; go test 自己编译测试程序、pytest 直读源码、vitest 默认也跑源码。 写错这条的代价是给每个套件加假依赖、拖慢门禁并制造假故障。T8 同步改。 ② CI 与 CD 分离:触发表删掉"推默认分支 → 自动部署测试环境",作业划分表删掉 deploy:test 行, §4.6 标注为"项目自己的 CD、不属于通用 CI",速查表同步。 ③ 门禁入口:统一为 make verify(名字可不同,但 AGENTS/Runbook/CI 必须指同一个), §3.1、§8 清单、§9 速查表一致。 ④ 新增 §4.8「必需检查清单与结果口径」:10 项必需检查的判据表 + 三条结果口径 (失败即停但汇总照做、not_run/skipped 不算过、汇总故障不能报绿), 以及预览/通知作为旁路的规则。 ⑤ 接入文档:修掉"直推也拦得住"这句错口径,并写明接入器不再自动合并、 --merge-pr 会先核对状态;试点记录里那一步标注为"当时的自动合并行为已改"。 ⑥ 03/04/05 里的同源表述一并同步(生成物 jobs 与假成功占位的判据)。 验证:本批 18 处精确替换在脚本里逐处断言"恰好命中 1 次"(DRY-RUN 先过一遍再应用); 替换后全仓 grep 复查,旧口径只在"说明它已被改掉"的上下文里出现。
42 KiB
Git / 提交 / CI-CD 规范(Gitea 版)
性质:系统级规范,适用于本机所有 Gitea 仓库(
Weknora/*、aiball/*)。 正本:本文件。同仓/同级仓库用相对路径引用;跨机请带上路径与本文件版本号。 样板实例:Weknora/knowledge-governance(唯一把"提交闸门 + 编译 + bridge 上报"全套跑通的仓库)。 口径:必须 = 违反即不合格;建议 = 有理由才能偏离,偏离写在代码注释里(本环境的规矩是"注释写为什么,不写是什么")。与附件原稿的关系:原稿是 GitLab / glab 口径,本环境是 Gitea 1.26.2 + act_runner,触发体系、手动作业语义、变量作用域、CLI 全部不同。逐条映射与证据见
04-模板评估与修正.md。
0. 四条地基
- 结论只来自可复现的命令。任何数字、清单、成败,要么由脚本自己打印,要么写成断言 —— 禁止手抄进文档。手抄的东西过期时没有任何人会发现,而过期的表现是"守卫变松"。
- 每个校验都要有反向自检:故意弄坏一处,它必须红。一个"永远返回 0 的校验器"比没有校验更糟 —— 它给人一种已经检查过的错觉。
- 本环境的范例:
knowledge-adapter-service的test/bad-commit分支在 CI 上必须失败(实测已存在这样一条失败 run)。
- 本环境的范例:
- 密钥只从 Gitea
Secrets/ 目标机文件进:不入库、不回显、不进命令行参数(会进ps)。 - 提交前跑本仓门禁命令;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)。
.gitignore对已入库的文件无效 ⇒ "规则齐全"不等于"没被追踪"。断言要查两件事:规则在、且git ls-files里没有这些路径。- 无斜杠前缀的规则匹配任意层级。被咬过的三次都是这个:
dist/吞掉src/admin/dist/、data/吞掉src/data/、secrets/吞掉src/secrets/。 处理办法不是加!例外(例外越加越多,规则本身就没人看得懂了),而是别在源码路径里用dist/data/secrets这些名字。 - 症状很轻:
git commit只回一句paths are ignored,文件静默没进提交。看到这句必须回去查。 - 产物名里带 sha:
dist/app-<sha12>.tar这类发布产物一律忽略,绝不入库。 - 反查命令(提交前跑一次,成本一秒):
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:featfixdocsstylerefactorperftestbuildcichorerevert。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:
git commit -F - <<'EOF'
fix(dobotai): 上游不再收到重复的客户端请求头
根因:……(写为什么)
EOF
<<'EOF' 的单引号是"里面的字符都是字面量"的唯一保证。
- ⚠️ 这条不只是提交信息:任何"把文本写进文件"的场合都一样。把含反引号的文本放进双引号交给
python3 -c/node -e,shell 会先执行反引号里的东西 —— 写进文件的句子凭空少片段,而退出码仍是 0、没有任何报错。要写的文本一律走带引号的 heredoc。 - 不要用
--no-verify绕过门禁(CI 侧ci_gate.py会再拦一次)。
2.4 两处闸门(本环境已就位)
- 本机 hook:
~/.githooks/commit-msg(由git config --global core.hooksPath生效)—— 快速反馈。 - 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 八条通用铁律
- ⚠️ "跑产物"的套件(端到端 / 契约 / 产物自检)必须排在构建之后:它们读的是构建输出,
改了源码不重建,测的就是旧代码(第一次一定会被这个骗一次)。
⚠️ 但别把这条推广成"所有单测都依赖预构建产物" —— 那是错的:
go test自己编译测试程序、pytest直读源码、vitest默认也跑源码。写错这条的代价是给每个测试套件加上假的"先构建"依赖, 白白拖慢门禁,还会制造"没构建所以红了"的假故障。(本规范早期版本就是这么写的,已改回。) - ⚠️ 只在"干净 clone"里出现的缺陷:本地工作区总有"恰好能跑"的残留。要专门造一次干净树(
git clean -xdf后 clone / CI 的干净检出)验证。 - ⚠️ 每个校验都要反向自检:
- 断言"它会红":故意弄坏一处,必须失败。
- 断言"它不会假绿":给它一条对照(正例必须过)。
- 断言"校验器作用在目标上":曾有一条
nginx -t -c静默地在读镜像自带的配置 —— 每次"通过",实际什么都没验。
- ⚠️ 断言要有唯一命中点:负向断言会被别的记录撑成假绿(判据太宽);判据太窄则永远够不着,而且报绿。
- ⚠️ 测试夹具要打扫:只建不删会把
$TMPDIR攒到几万条,之后mktemp -d开始卡住 —— 表现像"某个用例死循环",实际是环境问题。 - ⚠️ 跑之前先确认没有上次遗留的进程(
pgrep -fl "app"):否则新实例EADDRINUSE起不来,探针打到旧实例上 ⇒ 一堆假 401/404。 - ⚠️
set -o pipefail+grep -q= SIGPIPE 谎报失败:grep -q一命中就退出并关闭管道,上游随即 SIGPIPE,整条管道返回 141 —— 明明匹配上了却判失败。用不带-q的 grep,或让它直接读文件。 - ⚠️ 文档里不许出现会漂移的数字(测试条数、断言条数)。要么由脚本打印,要么写成断言(如
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→ 自动部署测试环境"—— 那是把某个项目的选择当成了通用规则,已改。 - 部署后必须读回(不是"启动成功"就算):
- 容器镜像 ID == 本次构建产物的镜像 ID(
docker inspect --format '{{.Id}}'); GET /healthz返回{"status":"ok"};GET /version的commit_sha== 本次main的短 sha;- 关键 env 读回(
docker exec <ctr> env | grep -E '^DOBOTAI_')。
- 容器镜像 ID == 本次构建产物的镜像 ID(
- 测试环境部署不等于验收:验收的门是"测试机上一轮真实业务跑通",由人确认,见 §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 才算发出去了,否则会收到一条没有预览链接的通知。
- ⚠️ 静态产物打包不要产生
.根成员: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 / 锁文件没有被任何一步改写 |
结果口径(三条,都是踩出来的):
- 失败即停,但汇总照做:第一个必需项失败后,剩下的记为
not_run并如实写进报告, 不许从报告里消失(消失了,下游就会当成"本来就没有这一项")。 not_run/skipped都不算过:汇总只认success; 不适用要在清单里声明(比如"本地没有事件负载,identity 只在 CI 必需"),不能在运行时悄悄跳过。- 汇总本身故障 ⇒ 不能报绿:读不到 / 解析不了报告时,结论是"质量无从判定",不是"没问题"。
预览与通知是旁路:预览只在全部必需项 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。人工确认按三层落地:
- 平台层:生产部署是独立 workflow,只有
on.workflow_dispatch,不存在自动触发路径。 - 参数层:dispatch 必须显式传入
sha(要发的短 sha)与confirm(必须等于字面量deploy-production),workflow 第一步就校验;不匹配直接红。防误点、防机器人误触。 - 人层:最后一步"切镜像"由本人在生产机执行(或由本人在生产机确认脚本输出)。CI 只负责"传产物 + 备好命令",不替人按下那一刻。
(若实例已启用 Environment 审批之类能力,可作为额外一道;本规范不假设它存在,需在实例上实测,见
03-待配置事项.md第 8 项。)
5.3 健康检查(读回证据,缺一即不合格)
生产切换后必须取全下列证据,不能只看"容器起来了":
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、跨人手手动执行时挡不住。 - 真正的互斥放在目标机(可靠性只在这一层):
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 必须人工按序执行:
- 幂等门:先数
information_schema.columns里有没有新列 ——0才继续,1直接中止(已应用),其它值中止。 - 版本匹配:RDS 是 PostgreSQL 17.9 ⇒ 用
postgres:17-alpine(postgres:16-alpine会server version mismatch失败)。 - DDL 前先 schema-only dump 到
container/backups/(0600)。 - 暂存迁移文件到
container/migrations/(部署集 = 本地记录)。 - 在一个事务里执行,带
DO $$ ... RAISE EXCEPTION守卫。 - 读回精确契约(如
is_nullable|column_default=NO|false)。 - 凭据不进 argv:写 0600 临时文件
PGURL=<url>,docker run --rm -i --network host --env-file <file> ... 'exec psql -v ON_ERROR_STOP=1 "$PGURL"'。 - 顺序:迁移完成并读回后,才切镜像。
回滚:加列型迁移(新列可空/有默认)不必跑 .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. 版本号与发版
6.1 版本号只有一个来源
本环境主语言是 Go(dobotai-platform、knowledge-governance),版本不是 package.json:
- 唯一来源 = 构建时注入:
-ldflags "-X main.Version=<v> -X main.CommitSHA=<sha12> -X main.BuildTime=<ts>",值取自git describe --tags --always/ 短 sha / CI 时间。 - 运行时由
/version端点报出(version/commit_sha/build_time)—— 这是"线上跑的是哪个提交"的权威答案。 - ⚠️ 不要再放一个仓库根的
VERSION文件:那是第二份副本,发版要改两处,忘一处就漂移。 - 前端子工程(
knowledge-governance/web)的package.json.version只表示前端包版本,不是服务版本。 - 版本号只增不减。回退用部署层回滚(§5.6),不是把 version 改回去。
- CI 里断言:
/version的commit_sha== 本次构建的短 sha(少这条,version会静默显示dev)。
6.2 打不打 tag
- 本环境默认不打 tag:部署由
main推送驱动,版本靠构建注入 +/version回读锚定。 - 一旦引入 tag,必须同时定下三件事:注释 tag 还是轻量 tag、谁负责打、CI 怎么用。否则会出现"tag 指着的提交从没部署过"。
- ⚠️ 不要接 tag 触发流水线。
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. 速查表
# ── 提交前 ────────────────────────────────────────────────
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> # 重跑(不推新提交即可验证整条链路)
# ── 发版 ─────────────────────────────────────────────────
# 构建注入版本 → 写 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 不符就拒跑) | 本环境 |