1b0cce283a
templates/ci_report.py 同步项目版(链接基址/提交链接/回退来源 + 通知字段);templates/gitea-build.yml 上报步骤加非秘密 CI_PUBLIC_BASE_URL;templates/README.md 加'上报脚本的非秘密配置'一节;01 正本 §4.7 增链接基址与'禁止全局 URL 替换';02 Runbook 增排障条目;04 加 T-9(通知链接指向内网地址)。
295 lines
14 KiB
Markdown
295 lines
14 KiB
Markdown
# 流程说明与 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` |
|