Files
eng-standards-main/02-流程说明与Runbook.md
hermes 1b0cce283a feat(templates): 同步通知链接基址(CI_PUBLIC_BASE_URL)与可信来源规则
templates/ci_report.py 同步项目版(链接基址/提交链接/回退来源 + 通知字段);templates/gitea-build.yml 上报步骤加非秘密 CI_PUBLIC_BASE_URL;templates/README.md 加'上报脚本的非秘密配置'一节;01 正本 §4.7 增链接基址与'禁止全局 URL 替换';02 Runbook 增排障条目;04 加 T-9(通知链接指向内网地址)。
2026-10-13 10:27:43 +08:00

295 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 流程说明与 Runbook
> 配套 `01-规范-Git与CI-CD(Gitea版).md`。这里只写**怎么做**:逐步命令 + 每一步的通过判据。
> 所有涉及凭据的命令都**从文件读**,不出现在命令行参数里。
## A. 日常开发循环
### A1. 开工前
```bash
cd <repo>
git fetch origin --prune
git status --short --branch # 必须干净
git log --oneline origin/main..HEAD # 本地领先的提交 = 本次要发的范围
```
**判据**:工作区干净;`origin/main..HEAD` 的每一条都能说清属于本次需求(不能把"顺手带上"的历史提交混进 PR 范围)。
### A2. 改完、提交前
```bash
make verify # 本仓唯一门禁入口(本地与 CI 同一条),串行、失败即停
# 名字不同也行(npm run verify / scripts/verify.sh),但 AGENTS / Runbook / CI 必须指同一个
```
**判据**:末尾汇总表全绿;有跳过项会显式警告"不算全绿"且非 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'
<type>(<scope>): 一句话(中文,≤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/<ns>/<repo>/pulls?state=open"
# 创建用 POST /api/v1/repos/<ns>/<repo>/pulls body: {title, head, base:"main", body}
```
**PR 描述必须含**:已实现 / 安全与数据边界 / 真跑过的测试 / 尚未验证 / 明确写"未部署"。
**判据(读回,不看 POST 的返回就当成功)**:
```bash
python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/pulls/<n>"
# state=open, merged=false, head.ref/base.ref 正确, head.sha == git rev-parse HEAD
```
### A5. 看 CI(失败先读日志,再改代码)
```bash
giteactl runs <ns>/<repo>
giteactl logs <ns>/<repo> <runId> # 或 giteacli action job view --repo <ns>/<repo> --index <jobId>
```
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/<ns>/<repo>/pulls/<n>"
# 判据:state=closed, merged=true, merge_commit_sha 非空
git fetch origin main
git merge-base --is-ancestor <reviewed_sha> origin/main # 必须成功
```
⚠️ 合并 POST 可能返回**空 body** → 客户端解析 JSON 会误报失败。**永不重试**,改读回。
---
## B. 接入一个新仓库(照 `01-规范` §8)
```bash
bash /srv/aiball/runner/attach_repo.sh <owner/repo> 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/<ns>/<repo>/actions/tasks?limit=3"
docker ps --format '{{.Names}}' | grep runner
python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/secrets" # CI_BRIDGE_TOKEN 在
```
⚠️ 若 run 一直 `queued`,先查**该仓库是否真的拥有一个 Runner**(标签是否匹配、Runner 是不是**这个仓库**的),再怀疑工作流或提交。
---
## C. 构建与测试环境部署(**项目自己的 CD**;推送流水线不含部署)
### C1. 自动(推 main)
```
git push origin <分支>
→ gate(提交信息闸门)→ build(依赖/静态/格式/单测/构建/安全/契约)
```
⚠️ **推送只做 CI,不做 CD**:上面这条流水线里**没有**部署作业(正本 §4.1 / §8)。
部署(测试环境/生产)由各项目自己设计,见 §D。本模板里不再有 `deploy:test` 占位作业 ——
占位作业会打印 TODO 然后返回成功,那是假报成功。
### 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 <ctr> --format '{{.Config.Image}}'
docker inspect <ctr> --format '{{.Id}}'
```
**判据**:`/version.commit_sha` == 本次 main 的短 sha;容器镜像 ID == 本次构建产物的镜像 ID。
### C3. 人工验收
在测试环境跑**一轮真实业务**(DobotAI:在测试钉钉机器人上真实发一条消息,再回读 `turns.agent_id`)。
通过后在 release issue / PR 里写下:验收人、时间、观察到的现象、结论。
**判据**:有这条记录。没有这条记录就不算验收过 —— 后面的生产发布不允许开始。
---
## D. 生产发布(人工确认 + 同一产物)
> 前提:§C3 的人工验收已完成、release 清单 `release-<sha12>.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-<sha12>.tar
# 判据:== release-<sha12>.json 的 tar_sha256
# 3) 备份当前版本(回滚用)
docker tag <current> dobotai-platform:rollback-<old12>-before-<sha12>
# 4) 加载并断言镜像 ID
docker load -i /opt/dobotai/container/incoming/dobotai-platform-<sha12>.tar
docker image inspect --format '{{.Id}}' dobotai-platform:sha-<sha12>
# 判据:== release-<sha12>.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 == <sha12>
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 回到 `<old12>` + `/version.commit_sha` == `<old12>` + 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
```
- 通知里的链接基址:看上报里的 `link_base_source`(`CI_PUBLIC_BASE_URL` 还是回退到 `GITHUB_SERVER_URL`);bridge 只渲染允许来源的链接,非允许来源会被丢掉(消息里就没有该链接)。
- 通知发不出去时:先看 `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) |
| 通知里的链接指向内网地址/打不开 | 没配 `CI_PUBLIC_BASE_URL`(或配成了非法值 ⇒ 已明确回退),或 bridge 的可信来源没登记公网域名 | 在 workflow 的上报步骤配 `CI_PUBLIC_BASE_URL`;bridge `ci_base_url` 里把公网域名登记上(可多个);核对上报里的 `link_base_source` |
| 构建随机报原生模块加载失败 | 缓存里混了平台相关依赖 | 不缓存 `node_modules` |
| "登录成功却又要重新登录" | 两个环境共用「父域 + Cookie 名」 | 改其中一个 DOMAIN 或换 COOKIE_NAME |
| 境外请求全 000 | mihomo 节点被钉死 | `PUT /proxies/三毛机场 {"name":"<活节点>"}` |
---
## 附:第二轮修正后的入口与自检命令(都可直接跑)
| 想确认什么 | 命令 | 期望 |
|---|---|---|
| 门禁清单与识别到的语言 | `python3 scripts/ci_checks.py --list` | 打印必需清单;识别不到语言 ⇒ 明说"会判红" |
| 汇总/前端判据/pytest 退出码语义 | `python3 scripts/ci_checks.py --self-test` | 全过;含「进程非零必红」「不写死 vitest」 |
| 报告能不能采信(发布前这一道) | `python3 .gitea/scripts/ci_report.py --verify-only` | 0=可采信 / 1=不可采信 / 2=核不了 |
| 上报核对逻辑 | `python3 .gitea/scripts/ci_report.py --self-test` | 11 分支全拦下 |
| **预览在坏报告下会不会乱发** | `python3 scripts/ci_preview_negative_test.py` | 10 分支:坏报告 publish 0 次、好报告 1 次 |
| 提交范围取法 | `python3 .gitea/scripts/ci_gate.py --self-test` | 11 分支(截断/空范围/新分支/手工运行…) |
| 检出协议保留 | `bash .gitea/scripts/ci_checkout.sh --print-remote` | https 保持 https、未知协议拒绝 |
| 合并调用链 | `python3 ~/eng-standards/scripts/adopt-existing.py --self-test-chain` | 13 分支只有全条件成立才发 merge |
| 合并判定纯函数 | `python3 ~/eng-standards/scripts/adopt-existing.py --self-test` | 11 分支全拒绝 |
⚠️ 这些都是**本地**证据:本地绿不等于远端 CI 已跑通。远端证据只有一条 —— 真实 run 的链接 + 完整 SHA。
| 完整调用链(空工作区→检出→闸门) | `python3 scripts/ci_chain_fixture.py`(项目仓里) | 12 项断言:空工作区能启动、协议保留、新分支范围非空、违规提交红 |
| 模板 workflow 的启动顺序 | `grep -n "name: 检出代码" -A3 templates/gitea-build.yml` | 第一步是最小 bootstrap,不是仓内脚本 |
| 通用门禁的 Python 锁定 | `python3 templates/ci-gate.py --self-test` | 缺 `uv.lock` 必须失败、从不用 `--system` |