commit 082afcf673d6c17f065fa4803bd2e80041c4ba80 Author: 姚章浩 Date: Sat Oct 10 14:28:21 2026 +0800 chore(repo): 规范仓库化 —— 从项目目录移到独立仓 原先把规范放在 /srv/aiball/ci/standards/ 是分类错误:规范是跨仓的 (Weknora 三个仓 + aiball),却挂在单个项目(AI球)的目录下, Weknora 侧的 AGENTS.md 要引用一个 aiball 路径,读起来就是错的。 改法:正本移到独立仓 ~/eng-standards(Gitea 远端 zheromiku/eng-standards 待本人网页创建),文档内 4 处旧路径引用一并更新,并补 .gitignore。 验证:git status 干净;grep 文档已无旧路径;提交信息闸门(~/.githooks/commit-msg)放行。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9c6ea9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 本仓库是纯文档(Markdown + 模板),没有构建产物。 +# 但按规范自己的规矩,关键忽略规则仍照写 —— 与其为个例把判据写宽 +# (那正是"守卫变松"的起点),不如照写一条零成本的规则。 +node_modules/ +dist/ +runtime/ +*.tar.gz +*.tar +version.txt +release-*.json + +# 本机与临时 +.DS_Store +*.log +.tmp/ +*.bak.* \ No newline at end of file diff --git a/01-规范-Git与CI-CD(Gitea版).md b/01-规范-Git与CI-CD(Gitea版).md new file mode 100644 index 0000000..3a745af --- /dev/null +++ b/01-规范-Git与CI-CD(Gitea版).md @@ -0,0 +1,493 @@ +# 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`;需要评审时用**短命分支 + PR**,合完即删。**禁止长期分支**。 + - 现状欠账:`Weknora/Enterprise-Knowledge-Base` 有 7 条分支(含 `release/*`、`develop/*`),属于"长期分支"反模式,见 `03-待配置事项.md`。 +- **禁止重写 `main` 历史**(`rebase` / `push --force`)。理由:CI 以 `main` 的推送为部署触发,"线上跑的是哪个提交"这个问题靠 `main` 的历史回答;历史一改,答案就没了。 +- **一切"退路"都必须是远端有的东西**:本地分支、本地 tag 都不算退路。真正的退路是"远端有那个提交、且能重跑那条流水线"。 + +### 1.2 评审(按风险分级,不搞一刀切) + +本环境是 3 人小团队 + AI 代理,多加一道"人人评审"会直接卡死流程;所以按**改动路径**分级: + +| 改动落在 | 评审要求 | +|---|---| +| `deploy/`、`.gitea/`、`db/migrations/`、生产配置模板、`Dockerfile` | **必须**异人评审(不得自评合并),必须在 PR 描述里写"怎么验的" | +| 业务代码(`internal/`、`src/`、`web/`) | **必须** PR;自评合并允许,但 PR 描述必须含"改动范围 + 已跑的门禁 + 未验证项" | +| 纯文档 / 文案 / 注释 | 可直接提交,**不**要求 PR | + +- PR 描述是**交付契约**,不是说明文。`gitea-workflows` 技能要求:写清 **已实现 / 安全与数据边界 / 真跑过的测试 / 尚未验证 / "未合并、未部署"**。 +- 合并前必须读到**权威读回**:`GET /pulls/{n}` → `state=closed`、`merged=true`、`merge_commit_sha` 非空。 + ⚠️ Gitea 的合并接口**成功时可能返回空 body**,客户端解析 JSON 会误报失败 —— **永远不要重试合并**,改为读回。 +- ⚠️ `mergeable=true` 只表示**没有冲突**,不代表测试、评审、迁移演练、部署门禁已满足。 + +### 1.3 分支保护(Gitea 侧) + +`main` **必须**设分支保护,至少勾:禁止直接推送、必须走 PR、必须 CI 状态通过。 +现状:**四个仓库全部没有分支保护**(`GET /repos/{r}/branch_protections` 返回 `[]`),见 `03-待配置事项.md` 第 1 项。 + +### 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-.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`:`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 check` / `npm run verify` / `scripts/verify.sh`)。 + +- **必须串行**:构建会清空产物目录,而端到端/契约测试跑的是**产物** ⇒ 并行会随机报"某条路由 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` | 跑的是构建产物,不是源码 | +| 5 集成/端到端 | 真起服务、打真实端口 | 单测覆盖不到"TCP 上真的连得上" | +| 6 部署脚本自测 | `bash deploy/deploy_test.sh` | 真跑一次 release;守的是**静默故障** | + +### 3.2 八条通用铁律 + +1. ⚠️ **单测跑的是构建产物,不是源码**:改了源码不重建,测的是**旧代码**(第一次一定会被这个骗一次)。 +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.push.branches: [main]` | 校验 + 编译 + **自动部署测试环境** | +| 人工发起 | `on.workflow_dispatch` | 生产发布(**独立 workflow**,见 §5) | + +**硬约束**: + +- ⚠️ **生产部署必须是独立 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` | 校验 | 环境参数不变量、配置语法、规范守门 | 只读仓库、几秒钟,没理由拖到部署阶段才发现 | +| `deploy:test` | 部署 | 推 `main` 自动部署测试环境 | 见 §4.1 | + +要点: +- **校验类作业与 `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 测试环境部署 + +- 推 `main` → `gate` + `build` 通过 → 自动部署测试环境。 +- 部署后**必须读回**(不是"启动成功"就算): + 1. 容器镜像 ID == 本次构建产物的镜像 ID(`docker inspect --format '{{.Id}}'`); + 2. `GET /healthz` 返回 `{"status":"ok"}`; + 3. `GET /version` 的 `commit_sha` == 本次 `main` 的短 sha; + 4. 关键 env 读回(`docker exec 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** 才算发出去了,否则会收到一条没有预览链接的通知。 +- ⚠️ 静态产物打包**不要产生 `.` 根成员**:`tar -czf x.tgz -C dir .` 会写入 `.` 和 `./index.html`;严格解包器可能拒绝或报错,而流水线**仍显示成功** —— 通知到了、预览没有。bridge 侧应**规范化**(剥掉 `./`,丢弃归一化为 `.`/空的成员),而不是直接拒绝整个包。 + +--- + +## 5. 生产发布(人工确认 / 健康检查 / 互斥 / 回滚 / 同一产物) + +### 5.1 生产使用测试验收过的**同一份构建产物**(本环境新增硬约束) + +> ⚠️ 原稿只讲"产物分层 + 回滚",没有把"同一份产物"写成硬约束。而本环境现状是**在目标机上各自 build**(测试机 build 一次、生产机再 clone 源码 build 一次),**两份二进制并不逐字节相同**。这必须改。 + +**铁律**:构建**只做一次**,产出**不可变产物**;测试环境与生产环境部署的是**同一份**(同 `sha`、同镜像 ID、同 tar sha256)。 + +落地流程: + +``` +① 构建(只此一次,在仓库 Runner 上) + docker build --build-arg VERSION= --build-arg COMMIT_SHA= --build-arg BUILD_TIME= \ + -t dobotai-platform:sha- + IMAGE_ID=$(docker image inspect --format '{{.Id}}' dobotai-platform:sha-) # sha256:... + docker save dobotai-platform:sha- -o dist/dobotai-platform-.tar + TAR_SHA=$(sha256sum dist/dobotai-platform-.tar | cut -d' ' -f1) + # 写发布清单(唯一真值,后续两个环境都对着它核对) + → release-.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}}' ` | == 清单 `image_id` | +| 测试机运行 | `curl -fsS 127.0.0.1:8090/version` | `commit_sha` == 清单 `sha12` | +| 生产机加载 | `sha256sum ` 然后 `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=`,`docker run --rm -i --network host --env-file ... 'exec psql -v ON_ERROR_STOP=1 "$PGURL"'`。 +8. 顺序:**迁移完成并读回后**,才切镜像。 + +**回滚**:加列型迁移(新列可空/有默认)**不必**跑 `.down.sql` 就能回滚应用(应用回退后旧代码不看新列)。 + +### 5.6 回滚 + +- 切换前先把**当前在跑的镜像**打回滚标签:`docker tag dobotai-platform:rollback--before-`。 +- 回滚 = 恢复回滚标签 + `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= -X main.CommitSHA= -X main.BuildTime="`,值取自 `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 check` 或 `scripts/verify.sh` —— 一条命令跑完、**串行**、失败即停、末尾汇总;跳过项显式警告"不算全绿"。 +- [ ] **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 `(幂等;注册令牌走文件挂载,不落容器 env)。 +- [ ] **6. bridge 变量/密钥**:`CI_BRIDGE_URL`(variable)、`CI_BRIDGE_TOKEN`(secret),由上面的脚本写入;写完 `GET` 读回。 +- [ ] **7. 分支保护**:`main` 禁止直接推送 + 必须 PR + 必须 CI 通过。 +- [ ] **8. `AGENTS.md` 指针**:仓库 `AGENTS.md` 写一行指回本规范;陷阱库拆 `AGENTS.md`(索引)+ `docs/agents/traps.md`(全文)。 +- [ ] **9. 自检一遍**:推一个**故意违规**的提交,确认 `gate` 红且 `build` 没跑(反向自检,§3.2-3)。 +- [ ] **10. 首次推 `main` 因为缺变量而红 —— 这是设计**:变量检查在任何上传动作**之前**,目标机一个字节都不会被碰。 + +> **跨仓引用**:正本仓库 `zheromiku/eng-standards`(Gitea);本机工作副本 `~/eng-standards/`。 +> ⚠️ **指令文件不会跨仓库自动加载** —— 每个仓库要在自己的 `AGENTS.md` 里写一行指针,否则那个仓库里的代理看不到这份规范。 + +--- + +## 9. 速查表 + +```bash +# ── 提交前 ──────────────────────────────────────────────── +make check # 或 npm run verify —— 本仓门禁,串行、失败即停 +git status --short --ignored # 确认没有该入库却被忽略的文件 +git commit -F - <<'EOF' # ⚠️ 不用 -m:正文里的反引号/$() 会被 shell 吃掉 +feat(scope): 标题(中文) + +为什么这么改、试过什么、怎么验的。 +EOF + +# ── 看 CI(只读)────────────────────────────────────────── +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///actions/tasks?limit=5" +giteactl runs / # 最近运行 +giteactl logs / # 单个作业日志 +giteactl rerun / # 重跑(不推新提交即可验证整条链路) + +# ── 发版 ───────────────────────────────────────────────── +# 构建注入版本 → 写 release-.json → 部署测试 → 验收 → dispatch 生产 + +# ── 上线与回退 ─────────────────────────────────────────── +# 推 main → gate + build + deploy:test 自动跑 +# 生产 → 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 不符就拒跑) | 本环境 | \ No newline at end of file diff --git a/02-流程说明与Runbook.md b/02-流程说明与Runbook.md new file mode 100644 index 0000000..9491bf8 --- /dev/null +++ b/02-流程说明与Runbook.md @@ -0,0 +1,266 @@ +# 流程说明与 Runbook + +> 配套 `01-规范-Git与CI-CD(Gitea版).md`。这里只写**怎么做**:逐步命令 + 每一步的通过判据。 +> 所有涉及凭据的命令都**从文件读**,不出现在命令行参数里。 + +## A. 日常开发循环 + +### A1. 开工前 + +```bash +cd +git fetch origin --prune +git status --short --branch # 必须干净 +git log --oneline origin/main..HEAD # 本地领先的提交 = 本次要发的范围 +``` + +**判据**:工作区干净;`origin/main..HEAD` 的每一条都能说清属于本次需求(不能把"顺手带上"的历史提交混进 PR 范围)。 + +### A2. 改完、提交前 + +```bash +make check # 或 npm run verify / scripts/verify.sh —— 本仓门禁,串行、失败即停 +``` + +**判据**:末尾汇总表全绿;有跳过项会显式警告"不算全绿"且非 0 退出 —— 那就不是绿。 + +```bash +git status --short --ignored | grep '^!!' | head +git ls-files | grep -E '(^|/)(dist|node_modules|data|runtime|bin)/|\.tar\.gz$|^version\.txt$' # 必须为空 +``` + +**判据**:第二条命令**输出为空**。 + +### A3. 提交 + +```bash +git add -A +git commit -F - <<'EOF' +(): 一句话(中文,≤72 字符,结尾不加句号) + +现象 / 根因 / 取舍 / 证据(写"为什么",不写"是什么")。 +EOF +``` + +**判据**:本机 hook `~/.githooks/commit-msg` 未报错。报错就照它的提示改,**不要** `--no-verify`(CI 侧会再拦一次)。 + +### A4. 推分支、开 PR + +```bash +git push -u origin HEAD +# PR:网页或 API +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///pulls?state=open" +# 创建用 POST /api/v1/repos///pulls body: {title, head, base:"main", body} +``` + +**PR 描述必须含**:已实现 / 安全与数据边界 / 真跑过的测试 / 尚未验证 / 明确写"未部署"。 + +**判据(读回,不看 POST 的返回就当成功)**: + +```bash +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///pulls/" +# state=open, merged=false, head.ref/base.ref 正确, head.sha == git rev-parse HEAD +``` + +### A5. 看 CI(失败先读日志,再改代码) + +```bash +giteactl runs / +giteactl logs / # 或 giteacli action job view --repo / --index +``` + +Gitea API 等价路径(`gitea_api.py` 只拼 `/api/v1` 前缀): + +``` +GET /repos/{r}/actions/tasks?limit=5 # 最近运行(注意:是 per-run task,可能多行同一条) +GET /repos/{r}/actions/runs/{id}/jobs # 某次运行的作业 +GET /repos/{r}/actions/jobs/{jobId}/logs # 单个作业日志 +``` + +**判据**:追到第一处 `❌`,读它给出的"下一步怎么办"。 + +⚠️ 两个易混点: +- `status=pending` 且 `status_count=0` **不是**失败 —— 是"这个提交没有任何 status 检查"。别当成 CI 挂了。 +- 重跑整条链路(不推新提交)用 `POST /repos/{r}/actions/runs/{id}/rerun`;**先筛 `path` 是真工作流的那条 run**,诊断/临时工作流重跑出来的绿或红不代表任何事。 + +### A6. 合并 + +只在**评审(§1.2)**与 **CI 状态**都满足后合并。合并后**立即读回**: + +```bash +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///pulls/" +# 判据:state=closed, merged=true, merge_commit_sha 非空 +git fetch origin main +git merge-base --is-ancestor origin/main # 必须成功 +``` + +⚠️ 合并 POST 可能返回**空 body** → 客户端解析 JSON 会误报失败。**永不重试**,改读回。 + +--- + +## B. 接入一个新仓库(照 `01-规范` §8) + +```bash +bash /srv/aiball/runner/attach_repo.sh runner-<...>-01 +``` + +它会(幂等地):校验仓库存在 → 起仓库级 Runner 容器 → 写 `CI_BRIDGE_URL`(variable)/`CI_BRIDGE_TOKEN`(secret) → 从模板仓拷 `.gitea/workflows/build.yml` + `.gitea/scripts/{ci_gate.py,ci_report.py}` → 提交推送 → 读回第一次运行。 + +**判据**: +```bash +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///actions/tasks?limit=3" +docker ps --format '{{.Names}}' | grep runner +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///actions/secrets" # CI_BRIDGE_TOKEN 在 +``` +⚠️ 若 run 一直 `queued`,先查**该仓库是否真的拥有一个 Runner**(标签是否匹配、Runner 是不是**这个仓库**的),再怀疑工作流或提交。 + +--- + +## C. 构建与测试环境部署 + +### C1. 自动(推 main) + +``` +git push origin main +→ gate(提交信息闸门)→ build(依赖/静态/单测/构建/产物自检)→ deploy:test +``` + +### C2. 测试环境读回(每次部署都做全) + +```bash +# 在测试机 120.76.217.51(key ~/.ssh/weknora-aliyun-test-ed25519) +curl -fsS http://127.0.0.1:8090/healthz +curl -fsS http://127.0.0.1:8090/version +docker inspect --format '{{.Config.Image}}' +docker inspect --format '{{.Id}}' +``` + +**判据**:`/version.commit_sha` == 本次 main 的短 sha;容器镜像 ID == 本次构建产物的镜像 ID。 + +### C3. 人工验收 + +在测试环境跑**一轮真实业务**(DobotAI:在测试钉钉机器人上真实发一条消息,再回读 `turns.agent_id`)。 +通过后在 release issue / PR 里写下:验收人、时间、观察到的现象、结论。 + +**判据**:有这条记录。没有这条记录就不算验收过 —— 后面的生产发布不允许开始。 + +--- + +## D. 生产发布(人工确认 + 同一产物) + +> 前提:§C3 的人工验收已完成、release 清单 `release-.json` 已生成、迁移(若有)已单独走完 §E。 + +### D1. 发起(人工确认第一、二层) + +Gitea 网页 → 仓库 → **Actions → 生产 workflow → Run workflow**: +- `ref` = `main`(或要发布的那条 commit/tag) +- `sha` = 要发布的短 sha(12 位) +- `confirm` = 字面量 `deploy-production` + +workflow 第一步就断言 `confirm` 与 `sha` 合法,不匹配直接红。 + +### D2. 生产机上的动作(人工确认第三层:最后一步由本人执行) + +```bash +# 在生产机 120.76.218.137(key ~/.ssh/weknora-aliyun-prod-ed25519) +# 1) 互斥锁(防两人/两条流水线同时改同一目录) +exec 9>/var/lock/dobotai-deploy.lock +flock -n 9 || { echo "❌ 另一个部署正在进行"; exit 1; } + +# 2) 校验产物(按清单核对,不核对不加载) +sha256sum /opt/dobotai/container/incoming/dobotai-platform-.tar +# 判据:== release-.json 的 tar_sha256 + +# 3) 备份当前版本(回滚用) +docker tag dobotai-platform:rollback--before- + +# 4) 加载并断言镜像 ID +docker load -i /opt/dobotai/container/incoming/dobotai-platform-.tar +docker image inspect --format '{{.Id}}' dobotai-platform:sha- +# 判据:== release-.json 的 image_id(且与测试机读到的相同) + +# 5) 切镜像(唯一动线上流量的动作) +cd /opt/dobotai/container +# 用 awk 计数替换 DOBOTAI_IMAGE=,替换数必须为 1,否则中止;改前先备份 0600 +docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk + +# 6) 读回(§5.3 全套证据,见下) +``` + +### D3. 健康检查(缺一即不合格) + +```bash +curl -fsS http://127.0.0.1:8090/healthz # {"status":"ok"} +curl -fsS http://127.0.0.1:8090/version # commit_sha == +docker inspect dobotai-dingtalk --format '{{.Config.Image}}' +docker inspect dobotai-dingtalk --format '{{.Id}}' # == image_id +docker inspect dobotai-dingtalk --format '{{.State.Status}}' # running +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 +docker logs dobotai-dingtalk --since 60s | tail +python3 ~/.hermes/scripts/gitea_api.py GET ... # (可选)RDS 侧读回:turns.agent_id 等 +``` + +⚠️ 只 grep **字符串字面量**(构建剥了符号)。 +⚠️ 最后的门:**本人在生产钉钉机器人上发一条真实消息**,再回读 `turns.agent_id`。 + +### D4. 回滚 + +```bash +ssh -i ~/.ssh/weknora-aliyun-prod-ed25519 root@120.76.218.137 \ + 'bash /opt/dobotai/container/ops/rollback_dobotai_prod.sh' +``` +判据:health ok + 镜像 tag 回到 `` + `/version.commit_sha` == `` + env 读回。 + +⚠️ **「取消流水线」不是回滚**。取消不改任何已经发生的事。 + +--- + +## E. 数据库迁移(与镜像分开发布) + +```bash +# 0) 幂等门:数新列是否存在(0=继续;1=已应用,中止;其它=中止) +# 1) DDL 前 schema-only dump 到 /opt/dobotai/container/backups/(0600) +# 2) 暂存迁移到 /opt/dobotai/container/migrations/ +# 3) 一个事务内执行,带 DO $$ ... RAISE EXCEPTION 守卫 +# 凭据走 0600 env 文件,不进 argv: +# docker run --rm -i --network host --env-file <0600 file> postgres:17-alpine \ +# sh -ec 'exec psql -v ON_ERROR_STOP=1 "$PGURL"' +# 4) 读回精确契约(如 is_nullable|column_default = NO|false) +# 5) 迁移读回通过后,才做 D2 的切镜像 +``` + +**顺序铁律:先迁移、后切镜像。** 加列型迁移回滚应用时**不必**跑 `.down.sql`。 + +--- + +## F. bridge / 通知排障 + +```bash +curl -s http://127.0.0.1:8899/healthz # {"ok":true,"service":"hermes-ci-bridge",...} +ls /srv/aiball/ci/reports/ | tail # 报告落盘 +ls /srv/aiball/ci/reports/pending/ /srv/aiball/ci/reports/undelivered/ +tail -n 50 /srv/aiball/ci/logs/bridge.log +``` + +- 通知发不出去时:先看 `undelivered/`。它是**入队即落盘**的设计,进程重启不丢;`pending/` 是重试中。 +- ⚠️ 微信侧只能在"用户最近发过消息"的**会话窗口内**主动推送;窗口外发送会 `ret=-2 prepare failed`(**伪限流**,不是本机故障)。恢复办法是**本人给 bot 发一句**;队列会由 20 分钟一次的补投自己清空。**不要**为此重启任何服务。 +- ⚠️ 通知正文要**空行分隔**:单换行会被客户端的 Markdown 折叠成一段。 +- ⚠️ bridge 暴露的地址**每个都要能 200**(LAN `192.168.50.134:8899` 与 Tailscale `100.92.49.78:8899`),否则收到的是打不开的链接。 + +--- + +## G. 常见故障 → 先怀疑什么 + +| 现象 | 先怀疑 | 查法 | +|---|---|---| +| run 一直 `queued` | 该仓库**没有**匹配标签的 Runner | `GET /repos/{r}/actions/runners`、`docker ps \| grep runner` | +| 作业时红时绿,报拉取失败 | 用了 `uses:` 第三方 action,该 runner 取不到 | 改成 `run:` 内联(§4.3) | +| `command not found: bash/docker` | 非交互 ssh 的 PATH / alpine 无 bash | 显式补 PATH、用绝对路径 | +| 同上一条,但本地正常 | `ssh host "cmd"` 不读 `/etc/profile` | 见 §5.8 | +| 明明匹配却报失败 | `pipefail` + `grep -q` 的 SIGPIPE | 去掉 `-q` | +| 合并报错但 PR 其实合并了 | Gitea 成功返回空 body | **不要重试**,`GET /pulls/{n}` 读回 | +| 通知到了但预览 404 | 产物包的 `.` 根成员被拒绝 | 规范化成员名(§4.7) | +| 构建随机报原生模块加载失败 | 缓存里混了平台相关依赖 | 不缓存 `node_modules` | +| "登录成功却又要重新登录" | 两个环境共用「父域 + Cookie 名」 | 改其中一个 DOMAIN 或换 COOKIE_NAME | +| 境外请求全 000 | mihomo 节点被钉死 | `PUT /proxies/三毛机场 {"name":"<活节点>"}` | \ No newline at end of file diff --git a/03-待配置事项.md b/03-待配置事项.md new file mode 100644 index 0000000..fa1723e --- /dev/null +++ b/03-待配置事项.md @@ -0,0 +1,163 @@ +# 待配置事项清单 + +> 每项:**现状(已核实)→ 要做什么 → 为什么 → 怎么验证 → 谁来做**。 +> 优先级:**P0** = 不做则规范形同虚设;**P1** = 影响发布可靠性;**P2** = 一致性/卫生。 +> 现状全部来自本次实测(命令在括注里)。 + +--- + +## P0 + +### 1. 分支保护(4 个仓库全无) + +- **现状**:`GET /api/v1/repos/{r}/branch_protections` 对 `Weknora/dobotai-platform`、`Weknora/knowledge-governance`、`Weknora/knowledge-adapter-service`、`aiball/software-pc` 全部返回 `[]`。 +- **要做什么**:Gitea → 仓库 → Settings → Branches → 给 `main` 加规则:禁止直接推送、必须走 PR、必须状态检查通过。 +- **为什么**:没有保护,"提交规范 + 评审分级"就全是自觉;`main` 被 force-push 一次,"线上跑的是哪个提交"就永久失去答案。 +- **怎么验证**:重跑 `GET /repos/{r}/branch_protections`,返回非空且 `enable_push=false`、`required_approvals` 符合 §1.2。 +- **谁来做**:本人(网页操作,需仓库管理权限)。 + +### 2. `knowledge-adapter-service` 的工作流不在 `main` 上 + +- **现状**:`main` 的 `.gitea/workflows/` **不存在**;`build.yml` 只在 `ci/setup` 分支(`GET .../contents/.gitea/workflows?ref=ci/setup` → 200,`ref=main` → 404)。仓库已有 `runner-weknora-ka-01`。 +- **要做什么**:把 `.gitea/workflows/build.yml` + `.gitea/scripts/{ci_gate.py,ci_report.py}` 合到 `main`;关掉/删掉 `ci/setup` 分支。 +- **为什么**:现在推 `main` **不触发任何校验**——这个仓库的 `main` 处于"裸奔"状态。 +- **怎么验证**:`GET /repos/Weknora/knowledge-adapter-service/contents/.gitea/workflows` 在默认分支返回 `build.yml`;推一个提交,`actions/tasks` 出现新 run。 +- **谁来做**:开发(走正常 PR);合并前用 `attach_repo.sh` 的模板拷贝逻辑。 + +### 3. 生产发布的"人工确认"落地方式二选一(需在实例上实测) + +- **现状**:Gitea **没有** GitLab 的 `when: manual` + `manual_confirmation`;生产部署目前**完全不经过 CI**(DobotAI 是人工在生产机 clone + build)。 +- **要做什么**: + - 方案 A:确认 Gitea 1.26.2 的 Actions 是否支持 `environment:` 及其审批;支持就在生产 workflow 上用。 + - 方案 B(不依赖平台):生产 workflow 只 `on.workflow_dispatch`,必填 `sha` + `confirm=deploy-production`,最后一步"切镜像"由本人在生产机执行。 +- **为什么**:规范 §5.2 的三层确认里,第二层(参数)与第三层(人)已可落地;第一层要不要平台能力必须先实测,**不能假设**。 +- **怎么验证**:在**非生产**仓库建一个 `environment: test-protect` 的最小 workflow 试跑;或直接采用方案 B 并留一条演练记录。 +- **谁来做**:本人确认 + 开发实现。 + +### 4. "同一份构建产物"的产物落地方式(本环境当前**不满足**) + +- **现状**:DobotAI 的做法是**在目标机上各自 build**(测试机 build 一次、生产机再 clone 源码 build 一次)——**两份二进制并不逐字节相同**,违反 §5.1。 +- **要做什么**: + 1. 构建移进 CI(仓库 Runner 上 `docker build`)或至少**只在一台机器 build 一次**; + 2. `docker save` 出 `dist/-.tar` + 写 `release-.json`(含 `image_id` / `tar_sha256`); + 3. 产物放到一个**两个环境都取得到**的托管点(见第 5 项); + 4. 测试与生产都 `docker load` 同一份 tar,并各自断言 `image_id` 相同。 +- **为什么**:这是本次规范最核心的新增要求。"测试验收过"必须等于"生产跑的就是那份字节",否则验收结论对生产**无效**。 +- **怎么验证**:测试机与生产机各跑 `docker image inspect --format '{{.Id}}'`,两个值**相同**;且都 == 清单 `image_id`。 +- **谁来做**:开发(改造构建 + 部署脚本);本人验收。 + +--- + +## P1 + +### 5. 构建产物的托管点 + +- **现状**:`ci-bridge` 的 `/api/ci/publish` 是给**静态网页预览**(tar.gz 解包 + URL)用的,不适合放镜像 tar / 大文件;`/srv/aiball/ci/previews/` 下面只有 3 个目录。DobotAI 的 tar 目前不在任何统一位置。 +- **要做什么**:定一个产物目录(建议 `/srv/aiball/ci/artifacts////`,与 bridge 复用同机同 token,按 sha 分目录 + 保留 N 份),或走对象存储/OSS。 +- **为什么**:§5.1 要求"生产取同一个 tar",就必须有一个**两个环境都能取到、且可按 sha 寻址**的地方。 +- **怎么验证**:从测试机和生产机各 `curl` 一次同一个 `tar_sha256` 地址,都能下到且 `sha256sum` 与清单一致。 +- **谁来做**:本人定位置 + 开发接。 + +### 6. 生产/测试的部署凭据尚不存在 + +- **现状**:只有 3 个 Weknora 仓库配了 `CI_BRIDGE_URL`(variable) / `CI_BRIDGE_TOKEN`(secret),**没有任何部署凭据**(`GET /repos/{r}/actions/secrets`)。DobotAI 的部署靠本人在生产机手动执行。 +- **要做什么**:按命名后缀建变量/密钥(Gitea 无 environment scope,见规范 §7.1): + - Variables:`DEPLOY_TEST_HOST` `DEPLOY_TEST_PORT` `DEPLOY_TEST_USER` `DEPLOY_PROD_HOST` `DEPLOY_PROD_PORT` `DEPLOY_PROD_USER` `ARTIFACT_BASE_URL` + - Secrets:`DEPLOY_TEST_SSH_KEY` `DEPLOY_PROD_SSH_KEY` `ARTIFACT_TOKEN` +- **为什么**:CI 要有能力"传产物"到目标机;同时 Gitea 无 File 类型 secret,私钥内容整体存 secret、由脚本落盘 0600。 +- **怎么验证**:`GET /repos/{r}/actions/variables` 与 `/actions/secrets` 列出全部名字(值不显示);在一个 dry-run workflow 里只在**变量名 + ✓/✗** 层面校验(绝不回显值)。 +- **谁来做**:本人在网页/用 `attach_repo.sh` 的接口写入;凭据环节**必须**由本人操作。 + +### 7. `Weknora/Enterprise-Knowledge-Base` 接入(含分支收敛) + +- **现状**:7 条分支(`main` + `release/graph-guard-v0.7.1-r1`、`release/server-running-v0.7.1`、`develop/enterprise-kb`、`chore/db-pool-tuning`、`feature/dingtalk-richtext-image`、`feature/dingtalk-image-only`);**无 workflow、无 Runner**;语言 Go+Vue。 +- **要做什么**:分支收敛(该合的合、该删的删)→ 跑规范 §8 的 10 步接入清单。 +- **为什么**:长期分支 + `release/*` 正是规范 §1.1 的反模式;且它没有任何校验。 +- **怎么验证**:`GET /repos/{r}/branches` 只剩 `main` + 短命分支;`actions/tasks` 有新 run。 +- **谁来做**:开发 + 本人确认分支删除范围(删分支不可逆)。 + +### 8. Runner 覆盖盘点 + +- **现状**:Weknora 三仓**各一个**仓库级 Runner(kg/dp/ka);`aiball` 组织级 1 个(`runner-software-01`,覆盖 `protocol-docs` 与 `software-pc`);`Weknora` 组织级 **0 个**。 +- **要做什么**:每个新接入仓库都要有**归属该仓库**的 Runner(否则 run 永远 `queued`)。用 `deploy_repo_runner.sh` 起。 +- **为什么**:规范 §4.2 —— "run 一直 queued" 的头号原因是仓库没有自己的 Runner,而不是工作流写错。 +- **怎么验证**:`GET /repos/{r}/actions/runners` 的 `runners` 非空且 `status=online`。 +- **谁来做**:开发(`bash /srv/aiball/runner/deploy_repo_runner.sh `)。 + +### 9. `aiball/software-pc` 的 runner 依赖第三方 action + +- **现状**:`validate.yml` 用 `actions/checkout@v4` + `actions/setup-node@v4`,在 `runner-software-01` 上 run#1 = success(**该 runner 能取 action**);但 Weknora 三仓 runner 上刻意不用。 +- **要做什么**:在 `validate.yml` 顶部加注释说明"本 runner 支持取 action";并补上 `npm ci`(现在用 `npm install`,不可重现)。 +- **为什么**:规范 §4.3 要求"按 Runner 配置区分"且要写明理由,否则下一个人会照抄到不支持 `uses:` 的 runner 上。 +- **怎么验证**:把 `npm install` 改成 `npm ci` 后重跑 run,仍 green。 +- **谁来做**:开发。 + +### 10. 生产迁移与回滚脚本的"幂等门"复核 + +- **现状**:DobotAI 已有 `ops/` 下的 `deploy_dobotai_prod.sh` / `rollback_dobotai_prod.sh` / `prod_verify.sh` / `prod_last_turn.sh`(在生产机 `/opt/dobotai/container/ops/`)。 +- **要做什么**:对照规范 §5.8 / §5.5 逐条核对:产物 sha 校验、迁移列 COUNT 门、agent 配置 hash CAS、`flock` 互斥、0600 备份。 +- **为什么**:规范要求"部分失败后重跑不会双重应用",这是**脚本级**保证,必须在脚本里看到。 +- **怎么验证**:每个门都**故意触发**一次(喂一个已存在的列 / 错的 sha),确认脚本**拒绝执行**而不是静默继续。 +- **谁来做**:开发(只读复核 + 演练);演练在测试机做。 + +--- + +## P2 + +### 11. `aiball/upper-tool`、`zheromiku/protocol-docs` 是空仓 + +- **现状**:两个仓库 `empty=true`。 +- **要做什么**:要么初始化(README + `.gitignore` + 门禁骨架),要么归档。 +- **为什么**:空仓出现在仓库列表里,会让人"以为这里该有东西"。 +- **怎么验证**:`GET /repos/{r}` 的 `empty=false`,或仓库已归档。 +- **谁来做**:本人决定去留。 + +### 12. `docs/提交规范.md` 缺失 + +- **现状**:`~/.githooks/commit-msg` 与 `.gitea/scripts/ci_gate.py` 的提示文案都写着"详见 `docs/提交规范.md`",但仓库里**没有这个文件**。 +- **要做什么**:把规范 §2 的内容落成各仓的 `docs/提交规范.md`(或把提示文案改成指向本规范)。 +- **为什么**:一条指不到地方的指路,比不指路更费时间(原稿 §2.2 的原话)。 +- **怎么验证**:`test -f docs/提交规范.md` 通过;或 grep 提示文案确认已改为可达路径。 +- **谁来做**:开发(顺手改)。 + +### 13. 规范自身的仓库化(进行中) + +- **现状**:本地 git 仓已建:`~/eng-standards/`(默认分支 `main`,内容已提交)。**Gitea 远端尚未创建**。 +- **要做什么**:① 本人在网页建仓 `zheromiku/eng-standards`(空仓,**不要**勾"初始化仓库");② 我加 remote 并 push;③ 各仓 `AGENTS.md` 加一行指针。 +- **为什么**:规范要"不用每次重复说一遍",靠的是可被引用的单一来源(原稿 §11)。此前放在 `/srv/aiball/ci/standards/` 下是分类错误 —— 跨仓规范不该挂在单个项目(AI球)的目录里。 +- **怎么验证**:`git ls-remote origin` 能看到 `main`;各仓 `AGENTS.md` 里的指针路径可达。 +- **谁来做**:**建仓 = 本人**(本机 repo-scoped token 建仓会 403);push 与指针 = 开发。 + +### 14. `aiball` 固件(ESP32-P4)的流水线尚未定义 + +- **现状**:`/srv/aiball/firmware` 有固件工程,但 Gitea 上**没有对应仓库**;`aiball` 组织下只有 `protocol-docs` / `software-pc` / `upper-tool`。 +- **要做什么**:固件入仓后,按同一套 §5.1 建固件流水线:`idf.py build` 一次 → 记录 `.bin` 的 sha256 → 测试机烧录测试版 → **量产/发布烧同一份 `.bin`**。 +- **为什么**:固件同样适用"同一份产物"铁律;烧录"只写不回读",所以**产物哈希是唯一可信的同一性证据**(AIxProbe 经验)。 +- **怎么验证**:`sha256sum` 两次产物文件一致;烧录日志里的字节数与 `OpenOCD wrote` 一致。 +- **谁来做**:本人定仓库归属 + 开发建流水线。 + +### 15. `ci-bridge` 的公开地址与实际可达性核对 + +- **现状**:`config.json` 的 `public_base=http://192.168.50.134:8899`、`public_base_alt=http://100.92.49.78:8899`;本次实测 `127.0.0.1:8899/healthz` 正常(`reports:19, previews:7, notify:true`)。 +- **要做什么**:核对 `192.168.50.134` 是否仍是本机当前 LAN 地址(网段变化会让所有通知里的链接失效)。 +- **为什么**:报告里给的每个 URL 都必须能 200,否则通知等于没发。 +- **怎么验证**:`curl -m 5 http://192.168.50.134:8899/healthz` 与 `curl -m 5 http://100.92.49.78:8899/healthz` 都返回 `ok:true`。 +- **谁来做**:本人/开发。 + +### 16. 规范守门脚本的 Gitea 版(可选) + +- **现状**:附件里的 `spec-check.mjs` 是 GitLab 口径(判据含 `glab` 占位符、`.gitlab-ci.yml`、`workflow.rules`),直接拿来自Gitea 环境跑会**假红**。 +- **要做什么**:改写判据:`.gitlab-ci.yml` → `.gitea/workflows/*.yml`;`workflow.rules` → `on:` 三类入口;`glab-placeholder` → `giteactl/gitea_api.py` 等价约束;`resource_group` → `flock`。保留 `--self-test` 的反向自检。 +- **为什么**:规范的"可执行"部分最好变成退出码(原稿 §11.2),但不改写就会假红,而假红和假绿一样要修。 +- **怎么验证**:`--self-test` 造两个夹具(合规全绿、违规每条都红)。 +- **谁来做**:开发(本次未做,见任务边界)。 + +--- + +## 不建议做的事(写下来防止被"顺手加上") + +- ❌ 不要为了能 push 去改 Gitea 凭据/token scope —— 权限没配好要**问人**,自己换 remote/换账号会把"权限没配好"变成"动作做了一半"。 +- ❌ 不要把 `deploy:test` 也挂到 `workflow_dispatch` 上"顺便重部署测试环境"(生产 workflow 就是生产 workflow)。 +- ❌ 不要给生产 workflow 设 `concurrency.cancel-in-progress: true`(会取消正在进行的部署,而取消不是回滚)。 +- ❌ 不要在生产与测试**同时**跑压测(两服共用同一套 RDS,会扰动生产)。 +- ❌ 不要在源码路径里用 `dist` / `data` / `secrets` 这些名字(`.gitignore` 会静默吞掉)。 \ No newline at end of file diff --git a/04-模板评估与修正.md b/04-模板评估与修正.md new file mode 100644 index 0000000..044ecd7 --- /dev/null +++ b/04-模板评估与修正.md @@ -0,0 +1,136 @@ +# 模板评估与修正(附件 `eng-standards-main.zip`) + +> 结论先说:**原稿的"地基"(四条铁律、反向自检、"为什么"写在正文、门禁串行失败即停)全部保留**; +> 但它整体是 **GitLab / glab** 口径,而本环境是 **Gitea 1.26.2 + act_runner + 自建 ci-bridge + 阿里云双机**。 +> 逐条评估结果如下。每条的"证据"都是本次在本机/实例上**实测**到的,不是推断。 + +## 一、结论 + +| 分类 | 数量 | 处理 | +|---|---|---| +| A. 平台错配(GitLab 语义在本环境不存在) | 6 | 全部改写为 Gitea 口径 | +| B. 与实测事实矛盾(原稿断言不成立) | 3 | 修正断言,写清成立条件 | +| C. 模板内部自相矛盾 / 数值不可信 | 3 | 改掉矛盾点 | +| D. 具体取值是他人环境的 | 3 | 全表换成"本环境事实"(`README.md` §1) | +| E. 覆盖缺口(原稿根本没约束) | 1 | **新增铁律**(同一份构建产物,规范 §5.1) | + +--- + +## A. 平台错配(6 处) + +### A1. 触发体系:`workflow: rules` / `$CI_*` 全部不存在 + +- **原稿**:`.gitlab-ci.yml` 的 `workflow.rules` 用 `$CI_PIPELINE_SOURCE == "web"` 与 `$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH` 两条件。 +- **实测**:本实例是 Gitea Actions,`.gitea/workflows/*.yml`,触发在 `on:`;可用变量是 `GITHUB_*`(`GITHUB_REF_NAME`、`GITHUB_REPOSITORY`、`GITHUB_SERVER_URL`)。 +- **修正**:见规范 §4.1,三类入口写成 `on.push.branches` 与 `on.workflow_dispatch`。原稿那条"刻意不接 tag"的**判断保留**,理由改写(见 A2)。 + +### A2. 手动作业语义:`when: manual` 的"阻塞型"论证是 GitLab 专有 + +- **原稿**:手动作业没有 `allow_failure: true` 时流水线永远到不了 `success`(停在 `blocked`),取消会把已成功作业一起标 `canceled`。 +- **实测**:Gitea 里 `if:` 不成立的作业是 **skipped**,不会把运行挂死;生产发布的正确形态是**独立的 `workflow_dispatch` 运行**。 +- **修正**:保留原稿的**真问题**("取消 ≠ 回滚"、"不要重写会抹掉证据"),但改挂到 Gitea 的真实风险上:`concurrency.cancel-in-progress` 会取消**正在进行的部署**。生产 workflow 必须 `cancel-in-progress: false`。 + +### A3. `resource_group` 在 Gitea 1.26.2 不存在 + +- **原稿**:同一环境的部署用 `resource_group: ` 防并发。 +- **实测**:Gitea 没有 `resource_group`;`concurrency:` 只对同 workflow 同 group 生效,**跨 workflow / 人手手动执行挡不住**。 +- **修正**:互斥**下移到目标机**(`flock -n`,规范 §5.4)。这是"可靠性只在这一层"的取舍,必须写明。 + +### A4. 变量作用域:Gitea 没有 environment scope,也没有 File 类型 secret,勾选坑不同 + +- **原稿**:GitLab CI/CD Variables 用 `environment scope` 区分环境;有 "Expand variable reference" / "Masked" / "Protected" 三个勾选坑;私钥用 **File 类型**(因为多行勾不了 Masked)。 +- **实测**:Gitea 仓库变量/密钥**没有环境作用域**;私钥只能整体存 secret(无 File 类型);三个勾选坑**不存在**。 +- **修正**:环境区分改用**命名后缀**(`DEPLOY_TEST_*` / `DEPLOY_PROD_*`);私钥由运行时脚本落盘 0600(规范 §7.1 / §7.3)。 + 另有**接口形状**要写对(实测):变量 `POST/PUT …/actions/variables/{name}` body `{"value":…}`;密钥只有 `PUT …/actions/secrets/{name}` body `{"data":…}`;**集合级 POST 返回 405 空 body**(像权限问题,实际不是)。 + +### A5. CLI:`glab` → `giteactl` / `gitea_api.py` + +- **原稿**:§6 整章讲 `glab`,含 `projects/%2F` 显式路径坑(细粒度只读 token 下 `:id` 占位符会 403)。 +- **实测**:本机**没有 `glab`**;有 `~/.local/bin/giteactl`(`runs/show/logs/watch/rerun`)、`~/.hermes/scripts/gitea_api.py`、`giteacli`(本机 token 缺 `read:user`,`whoami`/`login` 会失败但仓库功能可用)。 +- **修正**:§9 速查表与 §02 Runbook 换成 giteactl/gitea_api.py 的真实命令。原稿"读日志用显式路径、不用交互式 TUI、trace 要清洗"的经验**保留**,但换成 Gitea 的日志接口地址。 + +### A6. `GIT_DEPTH: 1` / `after_script` + `$CI_JOB_STATUS` 通知 / `tags: [docker]` + +- **原稿**:CI 变量 `GIT_DEPTH: 1`;通知用 `after_script` 拿 `$CI_JOB_STATUS`;Runner 标签 `docker`。 +- **实测**:本环境是**自 clone 模型**(`git fetch --depth 50`);通知走 `.gitea/scripts/ci_report.py` + `if: always()` 步骤 + 主机侧 bridge;Runner 标签是 `ubuntu-latest/24.04/22.04`。 +- **修正**:全部替换。原稿"通知失败不能让作业失败"、"可能的话两条通知链路分开"的判断**保留**。 + +--- + +## B. 与实测事实矛盾(3 处) + +### B1. "不用任何第三方 action" —— 本环境不成立 + +- **原稿**:文件头写"不用任何第三方 action(作业容器不联网拉 action)",并把它当铁律。 +- **实测**:`aiball/software-pc` 的 `.gitea/workflows/validate.yml` 用了 `actions/checkout@v4` + `actions/setup-node@v4`,**run#1 = success**(`GET /repos/aiball/software-pc/actions/runs` 显示 `completed success`)。而 Weknora 三仓的 runner 上工作流刻意自 clone。 +- **修正**:规则改成**按 Runner 配置区分**(规范 §4.3):默认不用 `uses:`;例外要在文件头写明该 runner 支持取 action。并给出一条判据:"时红时绿且报拉取失败"先怀疑 `uses:`。 + +### B2. `.gitignore` 模板与正本条款自相矛盾 + +- **原稿**:正本 §1.3 明说"处理办法**不是**加 `!` 例外(例外越加越多,规则本身就没人看得懂了)";但 `templates/gitignore` 里就给了 `!src/secrets/` 和 `!src/secrets/**`。 +- **实测**:原稿自己的 `spec-check.mjs` 的 `ARTIFACT_PREFIX` 正则包含 `secrets/` —— 于是"按模板加了例外、又真把 `src/secrets/` 入库"的工程会被 `tracked-artifacts` **判红**。 +- **修正**:模板去掉 `!` 例外,改为一句话:"别在源码路径里用 `dist`/`data`/`secrets` 这些名字"(见 `templates/gitignore`)。 + +### B3. `spec-check.mjs` 的 `AGENTS_MAX_BYTES = 32768` 与正本数字不一致 + +- **原稿**:正本 §2.4 写"实测 **65983** 字节时尾部 739 字节被静默截断";而守门判据硬编码 `AGENTS_MAX_BYTES = 32768`。 +- **矛盾点**:正本自己立了规矩"**文档里不许出现会漂移的数字**,要么由脚本打印要么写成断言"(§4.3),却在这里放了一个来源不明的常量。 +- **修正**:本次不引入该脚本(任务边界是"只制定规范"),但在 `03-待配置事项.md` 第 16 项写明:改写 Gitea 版守门时,**上限值必须由一次真实测量得出并写成断言**,不许手抄。 + +--- + +## C. 模板内部问题(3 处) + +### C1. `templates/verify.mjs` 写死跨机路径 + +- **原稿**:门禁第 5 步 `cmd: [process.execPath, '__STANDARDS_DIR__/scripts/spec-check.mjs', ...]` —— 一个**绝对路径**,在 CI 容器里根本不存在(原稿自己的 `validate:repo` 注释也承认了这一点)。 +- **修正**:门禁入口只跑**本仓**的东西;规范守门要么随仓携带(`.gitea/scripts/`),要么在 CI 里先 `git clone` 规范仓库到固定路径。见 `templates/gitea-build.yml` 的 `validate` 作业注释。 + +### C2. `spec-check.mjs` 两条判据过窄/无对象 + +- `ci-cache-node-modules`:只匹配 `- node_modules/` 这一种写法;本环境的缓存是**主机挂载目录**(`/opt/citools-cache`),判据完全够不着。 +- `glab-placeholder`:在 Gitea 环境里没有 `glab`,判据**无对象**(永远是 ok)。 +- **修正**:说明见 `03-待配置事项.md` 第 16 项。同时保留原稿两条**好经验**:判据要认"这件事"而不是"那种写法"(原稿自己在 v1.0.4 里踩过)、并且"报错文案里也不能出现针的字面量"(会被下一次扫描命中)。 + +### C3. `check_env.sh` 的必需键是按"网关 + Cookie"项目定的 + +- **原稿**:`REQUIRED="ENV_NAME APP_DIR DOMAIN COOKIE_NAME"`,并断言"两个环境的父域 + Cookie 名不能同时相同"。 +- **实测**:该不变量属于原稿样板项目(带 nginx + Cookie 的网关)。本环境的项目是 Go 服务(DobotAI / knowledge-governance / adapter),没有这一对不变量;DobotAI 的环境参数在**生产机 `/opt/dobotai/config/dobotai.env`**(不入库),也不是 `deploy/env/*.env`。 +- **修正**:本规范不照搬该脚本。保留它的**形状**("仓库层面的不变量放在 CI 校验,在传任何字节之前失败"),但要按项目定键(规范 §7.2)。 + +--- + +## D. 具体取值是他人环境的(3 处 → 全表替换) + +| 原稿取值 | 本环境实际 | +|---|---| +| `https://gitlab.artificialheart.cn/liuzhufu/...`、默认组 `liuzhufu` | `http://192.168.50.223:3000`(`https://gitea.zheromiku.top`);组 `Weknora` / `aiball` | +| Runner 标签 `docker`;镜像 `node:24-alpine` | 标签 `ubuntu-latest/24.04/22.04` → `docker://docker.gitea.com/runner-images:*` | +| `APP_DIR=/volume1/docker/...`(群晖 NAS)、域名 `*.artificialheart.cn` / `*.dobotoa.cn` | 阿里云 ECS:测试 `120.76.217.51`、生产 `120.76.218.137`,路径 `/opt/dobotai/...` | + +以上全部换成了 `README.md` §1 的**唯一取值表**,并给了取数命令(避免手抄漂移)。 + +--- + +## E. 覆盖缺口(1 处 → 新增铁律) + +### E1. 「生产使用测试验收过的同一份构建产物」原稿**没有**约束 + +- **原稿**:§5.5 讲产物按生命周期拆包、§5.7 讲目标机三层布局与回滚,但**没有**要求"测试与生产部署同一份产物"。 +- **实测**:本环境现状恰恰相反 —— DobotAI 的既有流程是**在目标机上各自 build**(生产机 clone 源码 + `docker build`),测试与生产**不是同一份二进制**。 +- **修正**:作为本次规范的**第一优先级新增**(规范 §5.1):构建只做一次 → 产出 `image_id` + `tar_sha256` 写进发布清单 → 测试与生产都 `docker load` 同一份 tar 并各自断言 `image_id` 相同。并给出完整的证据链表(§5.1)与"迁移不属于镜像、先迁移后切镜像"的例外。 + +--- + +## F. 保留的(原稿最有价值的部分,不要因为"平台换了"就丢掉) + +1. **§0 四条地基**:结论只来自可复现命令 / 每个校验都要反向自检 / 密钥不进命令行 / 提交前跑门禁。 +2. **§4.2 六条铁律**:单测跑产物不跑源码、干净 clone、反向自检三问(会红吗 / 会假绿吗 / 真的作用在目标上吗)、断言要有唯一命中点、夹具要打扫、先确认无遗留进程。 +3. **§2.2 正文写"为什么"** 的四种要素(现象/根因/取舍/证据)与示例形状。 +4. **§2.3 heredoc 禁忌**(反引号 `$()` 会被 shell 先执行,且退出码仍是 0)。 +5. **§1.3 `.gitignore` 是契约** 与两条反查命令。 +6. **§5.4 密钥只走 stdin 写文件**、`chmod 600`、去 CRLF。 +7. **§5.7 目标机三层布局** 与"停容器/换目录必须在同一个脚本里、顺序不许挪"。 +8. **§5.6 不缓存 `node_modules`**。 +9. **§2.4 陷阱库拆两份**(指令文件放索引、旁边放全文,因为指令有字节预算)。 +10. **§11 规范自己怎么被管理**(单一来源、投影、可检验、有版本)—— 见本目录的 `README.md` 与 `03-待配置事项.md` 第 13 项。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e66fbb --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Gitea 版工程规范(eng-standards 适配版) + +> **性质**:跨仓库的系统级规范,按**本机真实环境**(自建 Gitea 1.26.2 + act_runner + ci-bridge + 阿里云测试/生产)改写。 +> **来源**:评估并适配 `eng-standards-main.zip`(原稿是 GitLab/glab 口径),模板问题与修正见 `04-模板评估与修正.md`。 +> **状态**:本次**只制定规范**。不部署生产、不运行附件原脚本、不改任何仓库。 +> **正本仓库**:`zheromiku/eng-standards`(Gitea);本机工作副本 `~/eng-standards/`。 +> +> **怎么读**:`README` 三十秒版 → `01-规范` 全文 → `02-流程说明与Runbook`(怎么操作)→ `03-待配置事项`(还差什么)→ `04-模板评估与修正`(原稿哪里不对)。 + +## 交付物 + +| 文件 | 是什么 | +|---|---| +| `README.md` | 本文件:索引 + 三十秒版 + 本环境**唯一取值表** | +| `01-规范-Git与CI-CD(Gitea版).md` | **正本**:分支/提交/评审/触发、自动检查与测试环境部署、生产发布与回滚、版本与产物 | +| `02-流程说明与Runbook.md` | 日常动作的**逐步命令**(提交前、看 CI、接入新仓、发布、回滚) | +| `03-待配置事项.md` | 逐项:现状 / 要做什么 / 为什么 / 怎么验证 / 谁来做 | +| `04-模板评估与修正.md` | 附件 15 处问题的证据与改法(含实测反证) | +| `templates/` | 修正后的可直接落仓骨架:`gitea-build.yml`、`gitignore`、`commit-msg`、`AGENTS.md` | + +## 0. 三十秒版(只记五句) + +1. **结论只来自可复现的命令**。数字、清单、成败一律由脚本打印或断言,禁止手抄进文档(原稿 §0-1,保留)。 +2. **每个校验都要有反向自检**:故意弄坏一处,它必须红。永远返回 0 的校验器比没有校验更糟。 +3. **密钥只从 Gitea Secrets / 目标机文件进**,不入库、不回显、不进命令行参数(会进 `ps`)。 +4. **提交前跑本仓门禁命令**;CI 不是第一次验证,CI 只是"再来一遍 + 部署"。 +5. **生产部署只部署"测试验收过的那一份产物"**(同 sha、同镜像 ID)。这条是本环境新增的硬约束,见 §6.1。 + +## 1. 本环境事实(**唯一取值表**;以命令输出为准,不要手抄到别处) + +```bash +# 取这张表的命令(改完环境重跑,别靠记忆) +python3 ~/.hermes/scripts/gitea_api.py GET /api/v1/version +python3 ~/.hermes/scripts/gitea_api.py GET /api/v1/repos/search?limit=50 +docker ps --format '{{.Names}}\t{{.Image}}' | grep -E 'runner|bridge' +curl -s http://127.0.0.1:8899/healthz +``` + +| 项 | 值 | +|---|---| +| Gitea | 自建 **1.26.2**;LAN `http://192.168.50.223:3000`;公网别名 `https://gitea.zheromiku.top` | +| 账号 / 组织 | `zheromiku`;组织 `Weknora`、`aiball` | +| 仓库 | `Weknora/{dobotai-platform, knowledge-governance, knowledge-adapter-service, Enterprise-Knowledge-Base}`;`aiball/{protocol-docs, software-pc, upper-tool(空)}`;`zheromiku/{hello, protocol-docs(空)}` | +| CI 引擎 | Gitea Actions(act_runner,**仓库级** Runner 容器) | +| Runner | `runner-weknora-kg-01`→knowledge-governance、`runner-weknora-dp-01`→dobotai-platform、`runner-weknora-ka-01`→knowledge-adapter-service、`runner-software-01`(aiball 组织级,覆盖 protocol-docs/software-pc) | +| Runner 配置 | `capacity: 1`(同时只跑 1 个作业)、`force_pull: false`、labels `ubuntu-latest/24.04/22.04` → `docker://docker.gitea.com/runner-images:*`、注册令牌**文件挂载**(`/run/secrets/registration_token:ro`)、挂 `docker.sock` | +| 工具链/缓存 | 主机 `/srv/aiball/ci/toolcache` 只读挂载为容器内 `/opt/citools`;可写缓存 `/srv/aiball/ci/toolcache/cache` → `/opt/citools-cache` | +| ci-bridge | `/srv/aiball/ci/bridge/bridge.py`,监听 `0.0.0.0:8899`;容器内可达地址 `http://172.17.0.1:8899`;局域网 `http://192.168.50.134:8899`;Tailscale `http://100.92.49.78:8899` | +| bridge 接口 | `POST /api/ci/report`(作业结果→落盘+微信通知)、`POST /api/ci/publish`(静态产物 tar.gz→URL)、`GET /p////…`、`GET /r/`、`GET /healthz` | +| bridge 落盘 | 报告 `/srv/aiball/ci/reports/`(含 `pending/`、`undelivered/`);预览 `/srv/aiball/ci/previews/` | +| 仓库变量/密钥 | `CI_BRIDGE_URL`(variable)、`CI_BRIDGE_TOKEN`(secret)——仅 Weknora 三仓已配 | +| 提交闸门 | 本机 hook `~/.githooks/commit-msg`(`core.hooksPath`)+ CI 侧 `.gitea/scripts/ci_gate.py`(同一套规则,绕不过) | +| 仓库接入脚本 | `/srv/aiball/runner/attach_repo.sh `、`/srv/aiball/runner/deploy_repo_runner.sh` | +| Gitea API 客户端 | `~/.hermes/scripts/gitea_api.py`(库/CLI)、`~/.local/bin/giteactl`(`runs/show/logs/watch/rerun`)、`giteacli`(**只读用途,本机 token 无 read:user**) | +| 测试服务器 | 阿里云 ECS `120.76.217.51`(key `~/.ssh/weknora-aliyun-test-ed25519`) | +| 生产服务器 | 阿里云 ECS `120.76.218.137`(key `~/.ssh/weknora-aliyun-prod-ed25519`) | +| 生产布局 | `/opt/dobotai/{config/dobotai.env, container/{compose.yaml,runtime.env,migrations/,backups/,ops/}, secrets/}`;镜像由 `container/runtime.env` 的 `DOBOTAI_IMAGE=` 选择 | +| 健康/版本 | `http://127.0.0.1:8090/healthz`(`{"status":"ok"}`)、`http://127.0.0.1:8090/version`(`version/commit_sha/build_time`) | +| 数据库 | **永远是阿里云 RDS(PostgreSQL 17.9)**;目标机 Docker 只跑应用容器,禁止本地库 | +| 构建受限 | 目标机 **不可达 Docker Hub** → 用 daocloud 镜像源;Go 用 `GOPROXY=https://goproxy.cn,direct` | +| 本机网络 | 出网走 mihomo;境外全 000 时先查 `~/.clashctl` 节点(`PUT /proxies/三毛机场 {"name":"<活节点>"}`) | + +> ⚠️ 上表里**只有一个数字是"规范"**(如"生产路径 /opt/dobotai"),其余都是"现状快照"。现状会漂;所以每项都给了取数命令。 \ No newline at end of file diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..d4e1733 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,101 @@ +# templates/ —— 可直接落仓的骨架 + +> 每个文件都替换掉附件原稿的 GitLab 口径;改法见 `../04-模板评估与修正.md`。 +> ⚠️ 复制到仓库后**不要就地重写**:`ci_gate.py` / `ci_report.py` 请从已验证的仓库**逐字节复制** +> (`Weknora/knowledge-governance/.gitea/scripts/` 或 `/srv/aiball/ci/attach/`)。 + +| 文件 | 落仓位置 | 说明 | +|---|---|---| +| `gitea-build.yml` | `.gitea/workflows/build.yml` | 提交触发的校验+编译+预览+上报;`deploy:test` 是待填骨架 | +| `gitea-prod-deploy.yml` | `.gitea/workflows/deploy-production.yml` | **生产发布唯一入口**,只 `workflow_dispatch`,不构建 | +| `gitignore` | `.gitignore` | 去掉原稿自相矛盾的 `!src/secrets/` 例外 | +| `commit-msg` | `~/.githooks/commit-msg` | 本机提交闸门;规则必须与 `ci_gate.py` 一致 | +| `AGENTS.md` 骨架 | `AGENTS.md` | 见下(agent 指令文件有写入保护,故内联在此) | +| —— 随仓携带 | `.gitea/scripts/ci_gate.py`
`.gitea/scripts/ci_report.py` | **逐字节复制已验证版本**,不要重写 | + +## 落仓后的自检(反向自检,规范 §3.2-3) + +```bash +# 1) 故意违规的提交必须被闸门拦住,且 build 不跑 +git commit -F - <<'EOF' +update stuff +EOF +# 判据:本地 hook 报 ❌;push 后 CI 的 gate 红、build 未执行 + +# 2) 合规提交必须过(正例对照:证明不是"永远红") +git commit -F - <<'EOF' +ci(deploy): 接入 Gitea Actions 生产发布 workflow + +生产发布改成独立 workflow(只 workflow_dispatch),避免塞进 push 流水线; +concurrency 明确 cancel-in-progress: false —— 取消不是回滚。 +验证:手动发起一次,confirm 不匹配时 guard 作业必须红。 +EOF + +# 3) 产物地址每个都要 200 +curl -m 5 -o /dev/null -w '%{http_code}\n' http://192.168.50.134:8899/healthz +curl -m 5 -o /dev/null -w '%{http_code}\n' http://100.92.49.78:8899/healthz +``` + +## `AGENTS.md` 骨架(复制到仓库根,按本工程改写 TODO) + +````markdown +# AGENTS.md — 给在 里干活的 AI 代理 + +> 这是**操作手册**,不是给用户看的介绍。改代码前先读「硬约束」与「已知陷阱」。 +> +> **系统级规范(本机所有 Gitea 仓库都适用)**:仓库 `zheromiku/eng-standards`(本机工作副本 `~/eng-standards/`) +> —— `01-规范-Git与CI-CD(Gitea版).md`(正本)+ `02-流程说明与Runbook.md`。 +> 做 git / 提交 / CI / 发布 / 回滚 / 密钥 这些事之前先读它;本文件只写**本工程特有**的东西。 + +## 0. 这个工程是什么 + +(一句话说清做什么、给谁用、部署在哪)。 + +## 1. 硬约束(违反其中任何一条,这次改动就不合格) + +1. TODO:不许改变对外可观测行为(接口、报文、退出码、配置语义) +2. TODO:本工程最硬的那条证据是什么(对拍 / 契约 / 字节一致 / 快照) +3. **同一含义的代码只准有一份**(禁止同义复制) +4. **注释写"为什么"**,不写"是什么" +5. 提交按"一件事一个提交",conventional commits,正文用中文,标题 ≤72 字符 +6. **生产使用测试验收过的同一份构建产物**(同 sha / 同镜像 ID)—— 不许在生产机上另行 build + +## 2. 本地验证闭环(提交前的最低要求) + +```bash +make check # 或 npm run verify —— 一条命令跑完本地门禁;跳过项会显式警告"不算全绿" +``` + +- 门禁**串行**、失败即停:构建会清空产物目录,而端到端测的是构建产物 —— 并行会制造假失败。 +- 改到部署脚本或产物形状时,必须另外跑一次真实的装配/启动自检。 + +## 3. CI 闭环 + +```bash +giteactl runs / # 最近运行 +giteactl logs / # 作业日志 +python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///actions/tasks?limit=5" +``` + +- 失败时**先读日志再改代码**;追到第一处 `❌`。 +- `status=pending` 且 `status_count=0` **不是**失败(那是"没有配置任何状态检查")。 +- run 一直 `queued` ⇒ 先查**本仓库有没有自己的 Runner**,别怀疑工作流。 +- 合并读回:`GET /pulls/{n}` → `state=closed` + `merged=true` + `merge_commit_sha` 非空。 + ⚠️ 合并接口成功时可能返回**空 body** → **永不重试**,改读回。 + +## 4. 已知陷阱(索引) + +全文在 `docs/agents/traps.md`。**只追加、不重排、不复用旧号**;编号会被代码注释与测试引用。 +⚠️ 新增陷阱要改两处(traps.md 加全文 + 这张表加一行)。 + +| # | 一句话 | 全文 | +| --- | ------ | ---- | +| 1 | TODO | [读](docs/agents/traps.md#trap-1) | + +## 5. 提交与推送 + +- conventional commits,正文中文,**说清"为什么"**;一件事一个提交。 +- 提交信息不要用反引号 / `$()`(会触发命令替换):用 `git commit -F -` + heredoc。 +- 推之前跑完第 2 节;推送后用第 3 节确认流水线,失败就**先读 trace 再改代码**。 +- 生产发布**不走 push**:它是独立的 `deploy-production` workflow,只由人手动发起(见规范 §5.2)。 +```` \ No newline at end of file diff --git a/templates/commit-msg b/templates/commit-msg new file mode 100755 index 0000000..b74250b --- /dev/null +++ b/templates/commit-msg @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Conventional Commits 提交信息校验闸门(本机 hook) +# 安装:cp templates/commit-msg ~/.githooks/commit-msg && chmod +x ~/.githooks/commit-msg +# git config --global core.hooksPath ~/.githooks +# 绕过:git commit --no-verify(不建议);CI 侧 .gitea/scripts/ci_gate.py 会再拦一次,绕不过。 +# 规则必须与 .gitea/scripts/ci_gate.py 完全一致 —— 两处不一致 = 本地绿、CI 红(或反之)。 +set -uo pipefail + +msg_file="${1:-}" +[ -n "$msg_file" ] || { echo "commit-msg: 缺少提交信息文件参数" >&2; exit 1; } +[ -f "$msg_file" ] || exit 0 + +# 取第一条非注释行作为标题 +subject_line="" +while IFS= read -r line; do + case "$line" in ''|'#'*) continue ;; esac + subject_line="$line"; break +done < "$msg_file" +[ -n "$subject_line" ] || exit 0 + +# 放行自动生成的提交 +case "$subject_line" in + Merge\ *|Revert\ \"*|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;; +esac + +TYPES="feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert" +RE='^('"$TYPES"')(\([a-zA-Z0-9._/-]+\))?!?: .{3,}$' + +fail() { + cat >&2 <(<范围>): <简短描述> + 类型 : feat 新功能 | fix 修缺陷 | docs 文档 | style 格式(不改逻辑) + refactor 重构 | perf 性能 | test 测试 | build 构建/依赖 + ci CI 配置 | chore 杂务 | revert 回滚 + 范围 : 可选,小写,如 (card) (weknora) (deploy) + + 正确示例 + feat(asset): 新增按歌曲生成应援序列 + fix(parser): 修复表格解析越界导致的 500 + ci: 接入 Gitea Actions 自动编译 + + 要点 + · 类型小写,冒号后必须有一个空格,描述 ≥3 个字符 + · 标题 ≤72 字符,结尾不要加句号 + · 说明「为什么」写正文(空一行后),一行一件事 + · 破坏性变更:类型后加 !,并在正文写 BREAKING CHANGE: 说明 + · ⚠️ 正文不要直接在 -m 里写反引号 / \$()(会被 shell 先执行): + git commit -F - <<'EOF' + + 临时绕过(不推荐,CI 仍会拦截): git commit --no-verify +EOF + exit 1 +} + +[ ${#subject_line} -le 72 ] || fail "标题 ${#subject_line} 字符 > 72" +case "$subject_line" in + *. | *。) fail "标题结尾不要加句号" ;; +esac +printf '%s' "$subject_line" | grep -Eq "$RE" || fail "不符合 <类型>(<范围>): <描述>" \ No newline at end of file diff --git a/templates/gitea-build.yml b/templates/gitea-build.yml new file mode 100644 index 0000000..5ffa116 --- /dev/null +++ b/templates/gitea-build.yml @@ -0,0 +1,192 @@ +# —— 提交触发的 CI(Gitea Actions) +# 来自 zheromiku/eng-standards 的 templates/gitea-build.yml;正本见 01-规范-Git与CI-CD(Gitea版).md §4 +# +# ⚠️ 四处形状不许动(动了会被规范判为不合规): +# · 触发只有三类入口:推任意分支(校验+编译,不部署)、推 main(+自动部署测试环境)、workflow_dispatch +# · 提交信息闸门(gate)必须在编译之前 +# · 生产部署**不在这里** —— 它是独立的 prod-deploy.yml,只由 workflow_dispatch 触发(§5.2) +# · 不用第三方 action(`uses:`)—— 本 runner 取不到就随机红;全部逻辑写在 run: 里 +# +# ⚠️ 本 runner 实测特性(act 语义,见正本 §4.4): +# · act_runner 不会自动检出代码 ⇒ 必须自己 git init + fetch + checkout +# · 每步以 `bash -e -o pipefail` 执行 ⇒ 禁止 `cmd | head`(SIGPIPE 假红) +# · 工具链来自主机只读挂载:容器内 /opt/citools;可写缓存 /opt/citools-cache + +name: build + +on: + push: + branches: + - "**" + workflow_dispatch: + +env: + BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }} + BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }} + GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + +# 同一 ref 的新推送取消旧的**校验/编译**运行(注意:生产部署 workflow 不许这么设,见 §5.4) +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: 检出代码 + run: | + set -uo pipefail + SERVER="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}" + HOSTPORT="${SERVER#*://}" + BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}" + cd "${GITHUB_WORKSPACE:-$(pwd)}" + rm -rf ./* .git 2>/dev/null || true + git init -q . + git remote add origin "http://x-access-token:${GIT_TOKEN}@${HOSTPORT}/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 50 origin "$BRANCH" + git checkout -q -B "$BRANCH" FETCH_HEAD + echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BRANCH}" + git log -1 --format=' %h %an %s' + + - name: 提交信息规范校验(权威闸门,失败不编译) + run: python3 .gitea/scripts/ci_gate.py + + build: + needs: [gate] + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: 检出代码 + run: | + set -uo pipefail + SERVER="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}" + HOSTPORT="${SERVER#*://}" + BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}" + cd "${GITHUB_WORKSPACE:-$(pwd)}" + rm -rf ./* .git 2>/dev/null || true + git init -q . + git remote add origin "http://x-access-token:${GIT_TOKEN}@${HOSTPORT}/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 50 origin "$BRANCH" + git checkout -q -B "$BRANCH" FETCH_HEAD + git rev-parse HEAD > /tmp/ci-sha + git rev-parse --short=12 HEAD > /tmp/ci-sha12 + date +%s > /tmp/ci-start + : > /tmp/ci-preview-url + echo skipped > /tmp/ci-build-status + echo skipped > /tmp/ci-gate-status + git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count + + - name: 编译与自检 + run: | + set +e; set +o pipefail + LOG=/tmp/ci-build.log; : > "$LOG" + run() { echo "-- $* --"; "$@" 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; } + runin() { local d="$1"; shift; echo "-- ($d) $* --"; ( cd "$d" && "$@" ) 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; } + + export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH + export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod + export UV_CACHE_DIR=/opt/citools-cache/uv GOPATH=/opt/citools-cache/gopath + export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod + mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null + + echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)" + fail=0; ran="" + + if [ -f go.mod ]; then + ran="$ran go" + run go build ./... || fail=1 + run go vet ./... || fail=1 + run go test ./... || fail=1 + fi + if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then + ran="$ran python" + if grep -qE '^check:' Makefile 2>/dev/null; then + run make UV=uv check || fail=1 + else + run uv sync || fail=1 + run uv run pytest -q || fail=1 + fi + fi + WEBPKG="" + for p in package.json web/package.json frontend/package.json ui/package.json; do + if [ -f "$p" ]; then WEBPKG="$p"; break; fi + done + if [ -n "$WEBPKG" ]; then + ran="$ran node"; D="$(dirname "$WEBPKG")" + if [ -f "$D/package-lock.json" ]; then runin "$D" npm ci || fail=1; else runin "$D" npm install || fail=1; fi + if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then runin "$D" npm run build || fail=1; fi + fi + if [ -z "$ran" ] && [ -f Makefile ]; then ran="$ran make"; run make || fail=1; fi + if [ -z "$ran" ]; then echo "未识别到构建入口(go.mod / pyproject.toml / package.json / Makefile 都没有),跳过编译"; fi + + if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi + tail -c 4000 "$LOG" > /tmp/ci-build-tail + echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)" + [ "$fail" -eq 0 ] || exit 1 + + - name: 发布网页预览 + if: always() + run: | + set +e; set +o pipefail + : > /tmp/ci-preview-url + if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then + echo "构建未成功,跳过预览发布"; exit 0 + fi + WEB="" + for d in web/dist dist build out public web/build frontend/dist; do + if [ -f "$d/index.html" ]; then WEB="$d"; break; fi + done + if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi + # ⚠️ 不要在打包时产生 `.` 根成员:先 cd 再只打目录内容(正本 §4.7) + tar -czf /tmp/ci-web.tgz -C "$WEB" . || { echo "打包失败"; exit 0; } + B="${BRIDGE_URL:-}" + for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do + if [ -n "$cand" ]; then curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }; fi + done + SHA="$(cat /tmp/ci-sha)" + echo "发布预览到 $B(产物目录 $WEB)" + curl -sS -m 90 -X POST "$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" \ + -H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' \ + --data-binary @/tmp/ci-web.tgz -o /tmp/ci-pub.json + cat /tmp/ci-pub.json; echo + URL="$(python3 -c "import json;print(json.load(open('/tmp/ci-pub.json')).get('url',''))" 2>/dev/null)" + if [ -n "$URL" ]; then echo "$URL" > /tmp/ci-preview-url; echo "网页预览: $URL"; fi + + - name: 上报 ci-bridge(通知 + 报告) + if: always() + run: | + set +e; set +o pipefail + B="${BRIDGE_URL:-}" + for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do + if [ -n "$cand" ]; then curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }; fi + done + echo "使用 ci-bridge: $B" + S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S + echo $((S - T)) > /tmp/ci-duration + CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py + + # ───────────────────────────────────────────────────────────────────────── + # 测试环境部署:只在推 main 时自动跑(正本 §4.6) + # TODO:本作业只是骨架。填 与下面的读回断言后再启用。 + # 发布**必须**是"构建一次、两个环境部署同一份产物"(正本 §5.1)—— 见 prod-deploy.yml 的注释。 + # ───────────────────────────────────────────────────────────────────────── + deploy:test: + needs: [gate, build] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: deploy-test + cancel-in-progress: false + steps: + - name: 占位(未启用) + run: | + echo "TODO: ① 取本次构建产物(按 sha12 寻址)② 校验 tar_sha256 ③ scp 到测试机" + echo " ④ docker load → 断言 image_id == release-.json" + echo " ⑤ 切镜像 → 读回 healthz / version / env" + echo " ⑥ /version.commit_sha 必须 == $(cat /tmp/ci-sha12 2>/dev/null || echo '')" + echo " 见正本 §5.1 证据链表与 02-流程说明与Runbook.md §C" \ No newline at end of file diff --git a/templates/gitea-prod-deploy.yml b/templates/gitea-prod-deploy.yml new file mode 100644 index 0000000..13d3432 --- /dev/null +++ b/templates/gitea-prod-deploy.yml @@ -0,0 +1,96 @@ +# —— 生产发布(Gitea Actions,**只由人工发起**) +# 正本见 01-规范-Git与CI-CD(Gitea版).md §5 +# +# ⚠️ 这个文件是"生产发布"的**唯一入口**。四条不许动的形状: +# 1. 触发**只有** workflow_dispatch —— 不存在任何自动触发路径(§4.1) +# 2. 必填 sha + confirm,第一步就校验,不合法直接红(§5.2 第二层) +# 3. concurrency.cancel-in-progress **必须** false —— 取消不是回滚,会留下换了一半的镜像(§5.4) +# 4. **不构建**。只搬运"测试验收过的那一份产物"(同 tar_sha256 / 同 image_id,§5.1) +# +# 人工确认三层(§5.2): +# 第一层 平台:本 workflow 只能手动发起 +# 第二层 参数:confirm 必须等于字面量 deploy-production +# 第三层 人:最后一步"切镜像"由本人在生产机执行(或本人确认脚本输出) + +name: deploy-production + +on: + workflow_dispatch: + inputs: + sha: + description: '要发布的短 sha(12 位,必须是已在测试环境验收过的那一份)' + required: true + confirm: + description: '确认执行生产发布,请输入字面量 deploy-production' + required: true + note: + description: '发布说明(对应 release issue / 验收记录链接)' + required: false + +# ⚠️ 不要 cancel-in-progress +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + +jobs: + guard: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: 人工确认 + 参数校验 + run: | + set -eu + SHA="${{ github.event.inputs.sha }}" + CONFIRM="${{ github.event.inputs.confirm }}" + echo "请求发布 sha=$SHA" + [ "$CONFIRM" = "deploy-production" ] || { echo "❌ confirm 不匹配,拒绝执行"; exit 1; } + printf '%s' "$SHA" | grep -Eq '^[0-9a-f]{12}$' || { echo "❌ sha 必须是 12 位小写十六进制"; exit 1; } + echo "$SHA" > /tmp/ci-sha12 + echo "✅ 参数合法" + + publish: + needs: [guard] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: 取产物并校验(不构建!) + run: | + set -eu + echo "TODO: 从产物托管点取 release-<${{ github.event.inputs.sha }}>.json 与 -.tar" + echo " ① sha256sum 必须 == 清单 tar_sha256" + echo " ② 清单 sha12 必须 == 输入 sha" + echo " 见 02-流程说明与Runbook.md §D" + + - name: 交付到生产机(只传文件,不切镜像) + run: | + set -eu + echo "TODO: scp 到 120.76.218.137:/opt/dobotai/container/incoming/" + echo " ⚠️ 凭据只走 stdin / 文件,绝不进命令行参数(会进 ps)" + echo " ⚠️ ssh 是非交互 shell,不读 /etc/profile:远端命令要显式补 PATH" + + - name: 输出给本人的"下一步"(第三层确认在这里) + run: | + cat <<'EOF' + + ✅ 产物已送达生产机。**最后一步由本人在生产机执行**: + + exec 9>/var/lock/dobotai-deploy.lock + flock -n 9 || { echo "另一个部署正在进行"; exit 1; } + sha256sum /opt/dobotai/container/incoming/-.tar # == 清单 tar_sha256 + docker tag :rollback--before- # 回滚用 + docker load -i /opt/dobotai/container/incoming/-.tar + docker image inspect --format '{{.Id}}' :sha- # == 清单 image_id + # 迁移(若有)必须在切镜像**之前**完成并读回(正本 §5.5) + cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml \ + up -d --no-deps --force-recreate + curl -fsS http://127.0.0.1:8090/healthz # {"status":"ok"} + curl -fsS http://127.0.0.1:8090/version # commit_sha == + + 最后的门:**本人在生产机器人上发一条真实消息**,再回读 turns.agent_id。 + 回滚:bash /opt/dobotai/container/ops/rollback_dobotai_prod.sh + ⚠️「取消流水线」不是回滚。 + EOF \ No newline at end of file diff --git a/templates/gitignore b/templates/gitignore new file mode 100644 index 0000000..cce1acb --- /dev/null +++ b/templates/gitignore @@ -0,0 +1,34 @@ +# 依赖与构建产物 +node_modules/ +dist/ +bin/ +build/ + +# 运行期数据(唯一不可再生的东西,绝不入库) +data/ +secrets/ +*.db +*.db-wal +*.db-shm +backups/ + +# ⚠️ 无斜杠前缀的规则匹配任意层级:`secrets/` 会连 `src/secrets/` 一起忽略。 +# 处理办法**不是**加 `!` 例外(例外越加越多,规则本身就没人看得懂了), +# 而是**别在源码路径里用 `dist`/`data`/`secrets` 这些名字**。 +# (附件原稿的模板在这里给了 !src/secrets/,与它自己的正本条款矛盾,已删。) + +# 发布产物(CI 生成,绝不能入库;产物名带 sha,见正本 §1.4) +runtime/ +out/ +*.tar.gz +*.tar +version.txt +release-*.json + +# 本机与密钥 +.DS_Store +*.log +.tmp/ +.env +.env.* +!.env.example \ No newline at end of file