13 KiB
流程说明与 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与 Tailscale100.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。