# 流程说明与 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 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' (): 一句话(中文,≤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. 构建与测试环境部署(**项目自己的 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 --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 ``` - 通知里的链接基址:看上报里的 `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` |