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

58 KiB
Raw Permalink Blame History

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. 反查命令(提交前跑一次,成本一秒):
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:
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 健康检查(读回证据,缺一即不合格)

生产切换后必须取全下列证据,不能只看"容器起来了":

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 必须人工按序执行:

  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. 速查表

# ── 提交前 ────────────────────────────────────────────────
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 不符就拒跑) 本环境