---
date: 2026-05-19
status: 工程化保险已加（02-gitea-config §7 第 6 条扩展 + scripts/ops/mirror-runner.sh 一键镜像）
tags: [gitea-actions, runner, claude-cli, runner-mirror, ai-review]
related-errs: []
related-learnings: [2026-05-18-gitea-org-scoped-runner-no-admin.md]
---

# ERR-20260519-002 — 加新 runner 后 ai-review job 12 秒挂在 `Verify claude CLI`（claude CLI 没装 + 登录态没拷）

## 现象

PR #440 触发 ai-review workflow → run 4418 被 Gitea 调度到 **dedicated-runner-2**（`43.166.182.155`，2026-05-18 新加），跑了 12 秒 conclusion=failure。失败步骤：

```
Verify claude CLI is available
  command -v claude && claude --version
  → bash: line 1: claude: command not found
```

老主力 dedicated-runner-1（`43.166.205.48`）上 ai-review 一直跑得通；只要 run 落到 `-1` 就 OK，落到 `-2` 就挂——明显的"新 runner 缺组件"。

## 直接原因

`-2` 上没装 claude CLI，也没 `~/.claude/.credentials.json` 登录态。

2026-05-18 加 `-2` 时只**人工镜像了 SSH 资料**（`~/.ssh/config` + `deploy_key` + `known_hosts`），把"加 runner = 拷 SSH"当成了完整清单，没意识到 ai-review 还依赖 runner host 上预装的 claude CLI + OAuth credentials。

## 元根因

**runner 横向扩容没有"运行时镜像清单"——每加一类 workflow 依赖就再踩一次跨 runner 漂移。**

`docs/ops/02-ci-cd-architecture.md` 早就写过这条已知风险（"跨 runner 不可移植——换/加 runner 就要再配"），上次为 SSH 加了 § 7 第 6 条作为工程化保险，但**保险只列了 SSH 一类**，没把它泛化成"镜像清单"。这次踩的是同一类风险的不同侧面：

| 时间 | runner | 漏的依赖 | 后果 |
|---|---|---|---|
| 2026-05-18 加 `-2` 当天 | dedicated-runner-2 | SSH key | deploy job silent hang |
| 2026-05-18 加 `-2` 当天 | dedicated-runner-2 | claude CLI + credentials | ai-review 挂 |

每加一类 workflow 依赖（docker / 业务凭据 / 特殊 cron / 自定义 systemd unit / npm 全局工具…），如果不让"镜像清单"作为一等公民，第三类、第四类只会重蹈覆辙。

## 解法

### 应用层

1. 在 `-2` 上装同版本 claude CLI：
   ```bash
   ssh ubuntu@43.166.182.155 'sudo npm install -g @anthropic-ai/claude-code@2.1.123'
   ```
   （pin 到 `-1` 同版本避免无谓 drift；CLI 一般向后兼容，但锁版便于排障）

2. 从 `-1` 复制登录态到 `-2`：
   ```bash
   ssh ubuntu@43.166.182.155 'mkdir -p ~/.claude && chmod 700 ~/.claude'
   ssh ubuntu@43.166.205.48 'cat ~/.claude/.credentials.json' \
     | ssh ubuntu@43.166.182.155 'cat > ~/.claude/.credentials.json && chmod 600 ~/.claude/.credentials.json'
   ssh ubuntu@43.166.205.48 'cat ~/.claude/settings.json' \
     | ssh ubuntu@43.166.182.155 'cat > ~/.claude/settings.json && chmod 644 ~/.claude/settings.json'
   ```

3. 验证 token 真的能用（光 `--version` 不够，要触发 API 调用）：
   ```bash
   ssh ubuntu@43.166.182.155 'claude -p "reply with exactly: PONG"'
   # 期望：PONG
   ```

### 工程化保险

1. **`docs/ops/02-gitea-config.md` § 7 第 6 条扩展**：从"SSH 镜像"泛化成"运行时依赖镜像清单"（表格列出 SSH / claude CLI / claude 登录态 + 漏镜像后果 + 验证方式）。
2. **`scripts/ops/mirror-runner.sh` 一键镜像脚本**：`bash scripts/ops/mirror-runner.sh <OLD_HOST> <NEW_HOST>`，把当前已知清单一次性拷过去 + 自检，新增依赖时往脚本里加一行即可成为团队共识。

## 监控点

- ai-review run 整体 <30 秒 conclusion=failure + 失败 step 名 `Verify claude CLI is available` → 99% 是 runner 缺 CLI / credentials
- 失败的 run 看 `runner_name` 字段：跟"哪台新加 runner"对应即坐实

## 复现

```bash
# 在任意没装 claude CLI 的 act_runner host 上跑 ai-review.yml
# Verify claude CLI is available step 必挂；整 job 退出码 1，~10s 内 fail
```

## 关联

- 上一次"加新 runner 漏镜像"踩坑：SSH key 漏镜像 → 已在 `docs/ops/02-gitea-config.md` § 7 第 6 条；这次把第 6 条**泛化为清单**而不是再加第 7 条
- `docs/ops/02-ci-cd-architecture.md` L305 早写过的"跨 runner 不可移植"风险——这是第二次实证
- 下次该想到的可能漂移类：docker / npm 全局工具（typescript / prisma） / 业务凭据 env / 特殊 systemd unit / cron
- **副发现 + 同 PR 修复**：本 PR merge develop 时被 `.githooks/pre-commit` 的 `check-api-doc-drift.sh --mode block` 卡住——hook 把 develop 上几十个 commit 的 controller 改动当作本次本地引入的 drift（104 个 endpoint）。同根原因：跟 `check-migration-count.sh` 一样把 staged 当"本次本地引入"算，但只有 migration-count 加了 merge-skip。本 PR 提取 `IS_MERGE_COMMIT` 复用判定，两个 check 共用
