Files
eng-standards-main/02-流程说明与Runbook.md
T

13 KiB
Raw Blame History

流程说明与 Runbook

配套 01-规范-Git与CI-CD(Gitea版).md。这里只写怎么做:逐步命令 + 每一步的通过判据。 所有涉及凭据的命令都从文件读,不出现在命令行参数里。

A. 日常开发循环

A1. 开工前

cd <repo>
git fetch origin --prune
git status --short --branch          # 必须干净
git log --oneline origin/main..HEAD  # 本地领先的提交 = 本次要发的范围

判据:工作区干净;origin/main..HEAD 的每一条都能说清属于本次需求(不能把"顺手带上"的历史提交混进 PR 范围)。

A2. 改完、提交前

make verify       # 本仓唯一门禁入口(本地与 CI 同一条),串行、失败即停
# 名字不同也行(npm run verify / scripts/verify.sh),但 AGENTS / Runbook / CI 必须指同一个

判据:末尾汇总表全绿;有跳过项会显式警告"不算全绿"且非 0 退出 —— 那就不是绿。

git status --short --ignored | grep '^!!' | head
git ls-files | grep -E '(^|/)(dist|node_modules|data|runtime|bin)/|\.tar\.gz$|^version\.txt$'   # 必须为空

判据:第二条命令输出为空。

A3. 提交

git add -A
git commit -F - <<'EOF'
<type>(<scope>): 一句话(中文,≤72 字符,结尾不加句号)

现象 / 根因 / 取舍 / 证据(写"为什么",不写"是什么")。
EOF

判据:本机 hook ~/.githooks/commit-msg 未报错。报错就照它的提示改,不要 --no-verify(CI 侧会再拦一次)。

A4. 推分支、开 PR

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 的返回就当成功):

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(失败先读日志,再改代码)

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 状态都满足后合并。合并后立即读回:

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 /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} → 提交推送 → 读回第一次运行。

判据:

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. 测试环境读回(每次部署都做全)

# 在测试机 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. 生产机上的动作(人工确认第三层:最后一步由本人执行)

# 在生产机 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. 健康检查(缺一即不合格)

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. 回滚

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. 数据库迁移(与镜像分开发布)

# 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 / 通知排障

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":"<活节点>"}

附:第二轮修正后的入口与自检命令(都可直接跑)

想确认什么 命令 期望
门禁清单与识别到的语言 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。