# CI/CD 架构

> **定位**：本文件描述 `.gitea/workflows/` 的实施现状——pipeline 编排层。
>
> 跟其他文档的边界：
> - **设计原则 / 四层模型 / 测试金字塔** → [docs/standards/05-development-workflow.md](../standards/05-development-workflow.md)
> - **服务器基础设施 / 端口 / 容器 / Nginx** → [docs/ops/01-server-infrastructure.md](./01-server-infrastructure.md)
> - **运维策略 / 备份 / 监控** → [docs/ops/03-ops-policy.md](./03-ops-policy.md)
> - **env / 凭据管理** → [docs/ops/07-env-and-secrets.md](./07-env-and-secrets.md)
>
> **冲突时以本文件 + yml 为准**（standards/05 是目标设计，本文件是实施现状）。

---

## 1. 范围与定位

**适用读者**：动 `.gitea/workflows/**` 之前必读。

**事实源**：`.gitea/workflows/*.yml` 是单一事实源，本文档是它的解读。yml 改动后必须同步更新本文件。

**不在范围**：本地开发流程（用 `dev.sh` / agent pool）、应用层架构、服务器物理资源。

---

## 2. 环境矩阵

| 环境 | 触发分支 | 主机 | 用户 | 路径 | 域名 | 部署脚本 | Schema 同步 |
|---|---|---|---|---|---|---|---|
| **L2 test** | push `develop` | 170.106.161.71（独立 test 机，PR #272 后从 UAT 拆出） | `ubuntu` | `/srv/apps/ffworkspace-test` | `https://ffworkspace.test.faradayfuturecn.com` | `deploy.sh test deploy --skip-migrate` | `db push`（test 不走 migration history） |
| **L3 UAT FFAI** | push `staging` | 43.153.69.73 | `ubuntu` | `/srv/apps/ffworkspace-test` | UAT 域名 | `deploy.sh uat deploy` | `migrate deploy` |
| **L3 UAT AIxC** | push `staging` | 52.234.29.56 | `itadminaixc` | `/srv/apps/aixcworkspace` | AIxC UAT 域名 | `sg docker -c 'deploy.sh uat deploy'` | `migrate deploy` |
| **L4 Prod FFAI** | push `production` | 43.130.6.44 | `srvadmin` | `/srv/apps/ffworkspace` | Prod 域名 | `deploy.sh production deploy` | `migrate deploy` |
| **L4 Prod AIxC** | push `production` | 23.101.202.65 | `itadminaixc` | `/srv/apps/aixcworkspace` | AIxC Prod 域名 | `sg docker -c 'deploy.sh production deploy'` | `migrate deploy` |

**注意**：UAT 和 Prod 各自有 **FFAI + AIxC 两个目标**，由同一个 deploy job 在**单 job 内并发后台 SSH** 部署（详见 §7「双目标并发部署」）。

---

## 3. 触发 → workflow 映射

| 触发 | workflow | 备注 |
|---|---|---|
| `push develop` | [deploy-test.yml](../../.gitea/workflows/deploy-test.yml) + [quality-gates.yml](../../.gitea/workflows/quality-gates.yml)::verify-agent-assets | quality-gates 重型 job 不在 push 上跑（PR 已过，避免重复） |
| `push staging` | [deploy-uat.yml](../../.gitea/workflows/deploy-uat.yml) | FF-only 升级后每次 push 都触发部署（无回灌路径） |
| `push production` | [deploy-production.yml](../../.gitea/workflows/deploy-production.yml) | concurrency=no-cancel（保护已开始的 deploy） |
| `pull_request` → develop | [quality-gates.yml](../../.gitea/workflows/quality-gates.yml)（5 个重型 job + verify-agent-assets）+ [ai-review.yml](../../.gitea/workflows/ai-review.yml) | L2 门禁；ai-review 当前 dry-run 不阻断 |
| `pull_request` → staging | [quality-gates.yml](../../.gitea/workflows/quality-gates.yml)::verify-agent-assets + [ai-review.yml](../../.gitea/workflows/ai-review.yml)（mode=batch-summary） | 重型 job 用 `if: base_ref == 'develop'` 自动 skip（L2 已过的不重跑） |
| `pull_request` → production | [quality-gates.yml](../../.gitea/workflows/quality-gates.yml)::verify-agent-assets + [ai-review.yml](../../.gitea/workflows/ai-review.yml)（mode=release-risk） | 同上 |
| `schedule` Sat 01:03 UTC（= 09:03 CST） | [weekly-retro.yml](../../.gitea/workflows/weekly-retro.yml) | 在本 Gitea 实例 schedule 尚未生产验证 |
| `workflow_dispatch` | deploy-test / ai-review / weekly-retro / nightly-snapshot-check | 手动触发，调试 / 首次验证用 |
| `schedule` cron（注释中） | [nightly-snapshot-check.yml](../../.gitea/workflows/nightly-snapshot-check.yml) | **未启用**（脱敏快照链路待打通） |

---

## 4. Workflow 拓扑

```
┌─────────────────────────────────────────────────────────────────┐
│ PR 阶段（develop / staging / production 三层）                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  PR → develop      ─┬─→ quality-gates.yml                       │
│                     │     ├─ verify-agent-assets (always)       │
│                     │     ├─ build-check                        │
│                     │     ├─ migration-file-count               │
│                     │     ├─ contract-check                     │
│                     │     ├─ env-coverage-check                 │
│                     │     └─ backend-integration ─┐             │
│                     │            (L1 + L0c 同 step) │             │
│                     │            on uat-with-docker│             │
│                     │            via /tmp/ffoa-…lock│            │
│                     └─→ ai-review.yml (mode=hard-rules-block)   │
│                                                                  │
│  PR → staging      ─┬─→ quality-gates.yml::verify-agent-assets  │
│                     └─→ ai-review.yml (mode=batch-summary)      │
│                                                                  │
│  PR → production   ─┬─→ quality-gates.yml::verify-agent-assets  │
│                     └─→ ai-review.yml (mode=release-risk)       │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                               │ merge
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│ 部署阶段                                                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  push develop      ─→ deploy-test.yml                           │
│                         ├─ deploy (170.106.161.71)              │
│                         └─ post-deploy-health (公网+on-server)   │
│                                                                  │
│  push staging      ─→ deploy-uat.yml                            │
│                         ├─ deploy (FFAI 43.153.69.73 ║          │
│                         │          AIxC 52.234.29.56) 并发       │
│                         └─ post-deploy-regression ──┐           │
│                              L1 全量 on uat-with-docker│         │
│                              via /tmp/ffoa-…lock      │         │
│                                                       ▼         │
│                              ↑↓ 共享测试容器 flock 互斥           │
│                                                                  │
│  push production   ─→ deploy-production.yml                     │
│                         ├─ pre-deploy-checks (env-check.sh)     │
│                         ├─ deploy (FFAI 43.130.6.44 ║           │
│                         │          AIxC 23.101.202.65) 并发      │
│                         └─ post-deploy-smoke (smoke-test.sh)    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│ 独立周期任务                                                       │
├─────────────────────────────────────────────────────────────────┤
│  schedule Sat 09:03 CST  ─→ weekly-retro.yml                    │
│  workflow_dispatch only  ─→ nightly-snapshot-check.yml (未启用)  │
└─────────────────────────────────────────────────────────────────┘
```

---

## 5. Runner 分工

| Runner label | 类型 | 用于 | 选 label 的理由 |
|---|---|---|---|
| `ubuntu-latest` | self-hosted（act_runner） | 大多数 jobs：build / contract / migration / 部署 SSH / weekly-retro / ai-review | 标准 ubuntu，啥都跑得动 |
| `uat-with-docker` | self-hosted，带 docker | `quality-gates::backend-integration` / `deploy-uat::post-deploy-regression` / `nightly-snapshot-check` | 要本地拉测试容器 (ffoa-test-postgres / -redis) |

**容量约束**：当前 self-hosted host runner 共 **2 台**（`dedicated-runner-1` `43.166.205.48` + `dedicated-runner-2` `43.166.182.155`，2026-05-18 起），每台 `capacity: 3`——理论 6 并发，但不能假设 10 个并发 job 同时跑，concurrency 配置和 flock 互斥就是为了应对这点。详见 [02-gitea-config.md § 3](./02-gitea-config.md#3-actions-runner-当前状态)。

**升级配套规则**：act_runner 必须跟 Gitea 大版本配套——v1.0.x 配 Gitea 1.26+；v0.6.x 配 Gitea 1.25-。**升级 Gitea 必须同步升级 act_runner**，否则 silent hang（详见 [.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md](../../.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md) 教训 2/3）。

---

## 6. Secret 模型

| Secret 名 | 用途 | 命名风格 | 共用情况 |
|---|---|---|---|
| `FFWORKSPACE_TEST_CI_KEY_170_106_161_71` | test 部署 SSH | 业务+用途+IP 编码 | 专用 |
| `DEPLOY_SSH_KEY` | UAT + Prod 4 个目标共用 | 通用 alias | **共用** |
| `AI_REVIEW_GITEA_TOKEN` | ai-review 评论 PR / 写 status | 用途专名 | 专用 |
| `WEEKLY_RETRO_TOKEN` | weekly-retro 创建/更新 issue | 用途专名 | 专用 |
| `SNAPSHOT_S3_URL` / `SNAPSHOT_DECRYPT_KEY` | nightly-snapshot 拉脱敏快照 | 用途专名 | 专用（**未启用**） |

**当前张力**：
- `DEPLOY_SSH_KEY` 同一把 key 给 4 台不同机不同 user 共用（FFAI UAT / AIxC UAT / FFAI Prod / AIxC Prod）
- 命名 `DEPLOY_SSH_KEY` 看 yml 不知对应哪个目标，故障排查多绕一层

> ⚠️ **Rotation 风险（具体后果）**：任一台机的 key 泄露 / 怀疑泄露 → 要在所有 4 台机同时换：
> 1. 生成新 key pair
> 2. ssh 到 4 台机分别在 `authorized_keys` 加新 pubkey、删旧 pubkey
> 3. Gitea 改 `DEPLOY_SSH_KEY` secret 为新 private key
> 4. 触发一次 deploy-uat / deploy-production 各跑一遍验证连通
> 5. 撤旧 key：4 台机 `authorized_keys` 删旧 pubkey
>
> 攻击窗口期 = 泄露发现到第 3 步完成（实测约 30min）。**拆 4 个 secret 后**，单台泄露只需操作 1 台机 + 改 1 个 secret，攻击窗口缩短 + 爆炸半径限定单环境。

**未来方向（拆 4 个 secret）**：

替代命名跟 `FFWORKSPACE_TEST_CI_KEY_170_106_161_71`（test 已用）一致：
- `FFWORKSPACE_UAT_CI_KEY_43_153_69_73`
- `AIXCWORKSPACE_UAT_CI_KEY_52_234_29_56`
- `FFWORKSPACE_PROD_CI_KEY_43_130_6_44`
- `AIXCWORKSPACE_PROD_CI_KEY_23_101_202_65`

**操作顺序**（**不能 PR-first**——chicken-and-egg：yml 改了 secret 名但 Gitea 没配新 secret 会立即挂 deploy）：

1. 4 台机各生成一对新 key（runner 机或本地）
2. 对应每台机：目标机的 `authorized_keys` 加对应新 pubkey（**保留旧 pubkey 暂不删，确保 fallback**）
3. Gitea Web UI 加 4 个新 secret（用对应 private key）
4. 改 yml：每个 deploy job 把 `DEPLOY_SSH_KEY` 改成对应专名 secret，PR 进
5. PR merge 后 push 触发 deploy-uat / deploy-production 各跑一次验证
6. 验证 OK 后撤旧 key：4 台机 `authorized_keys` 删旧 pubkey + Gitea 删 `DEPLOY_SSH_KEY`

> 涉及生产 / UAT 服务器 `authorized_keys` 改动（CLAUDE.md「生产环境只读铁律」），AI 不能擅自做 step 1/2/6——需要人工 ssh 操作。AI 能做 step 4（yml 改动）和操作手册撰写。

**Gitea 命名约束**：secret 名不能以 `GITEA_` 开头（保留前缀）——历史上 `GITEA_API_TOKEN_FOR_AI` 因此改名为 `AI_REVIEW_GITEA_TOKEN`。

---

## 7. 部署不变量

### 7.1 concurrency 策略

3 个 deploy yml 都配置了 concurrency group（PR #280 落地，Gitea 1.26.1 升级解锁）：

| Workflow | cancel-in-progress | 理由 |
|---|---|---|
| deploy-test.yml | `true` | AI 高频合并 develop（10+ 次/天），老的 cancel 掉 = 节流 |
| deploy-uat.yml | `true` | UAT 短时间内连续 push 撞 PM2/docker，新的取代老的 |
| deploy-production.yml | **`false`** | deploy 中途被 kill 可能留 inconsistent state（PM2 reload 一半 / docker compose 改了一半），让老的安全跑完更稳 |

**关键约束**：concurrency syntax 在 Gitea 1.25.x 是 silent no-op，**1.26.0+ 才真正生效**。

### 7.2 共享测试容器 flock 互斥

`quality-gates::backend-integration` 和 `deploy-uat::post-deploy-regression` 共用 self-hosted runner 上的全局测试容器 `ffoa-test-postgres` / `ffoa-test-redis`。

历史上用 `concurrency: shared-test-containers` block 在 yaml 层互斥，但 Gitea 1.25.x 的 concurrency 不可靠（silent fail）→ **改用 flock**：

```yaml
exec 200>/tmp/ffoa-test-containers.lock
if ! flock -x -w 1800 200; then
  echo "❌ 1800s 内未取到锁，疑似前序 job 卡死"
  exit 1
fi
# ... 整段 jest run（含内部 reset_test_db_schema）必须在锁内
# 锁随 step 退出由 fd 200 关闭自动释放
```

**为什么必须互斥**：另一个 job 一上来 `docker rm -f ffoa-test-postgres ffoa-test-redis` 会把正在跑的容器干掉。

**手动恢复**（runner 上）：`rm -f /tmp/ffoa-test-containers.lock && docker rm -f ffoa-test-postgres ffoa-test-redis`

详见 [.learnings/ERRORS/ERR-20260506-001.md](../../.learnings/ERRORS/ERR-20260506-001.md) / 工单 #264。

### 7.3 双目标并发部署（UAT + Prod）

UAT / Prod 都同时部署 FFAI Workspace + AIxC Workspace 到各自的目标机。**单 job 内后台执行两个 SSH**：

```bash
# 两个 ssh 后台跑，logs 加前缀
( ssh ... ubuntu@<FFAI> ... ) 2>&1 | sed 's/^/[FFAI] /' &
FFAI_PID=$!
( ssh ... itadminaixc@<AIXC> ... ) 2>&1 | sed 's/^/[AIXC] /' &
AIXC_PID=$!
wait $FFAI_PID; FFAI_RC=$?
wait $AIXC_PID; AIXC_RC=$?
[ $FFAI_RC -ne 0 ] || [ $AIXC_RC -ne 0 ] && exit 1
```

**收益**：~6min vs 之前两个 workflow 串行 ~12min（合并前 self-hosted runner 只有 1 台 → 串行排队；2026-05-18 起 host runner 增至 2 台 / cap=3，但单 workflow 内的 deploy step 还是顺序结构，合并优化仍有意义）。

**前历史**：之前是 `deploy-uat.yml` + `deploy-aixc-uat.yml` 两份独立 workflow → 排队，详见 .learnings/ERRORS.md ERR-20260427-018。

### 7.4 silent fail-open 防御（红线）

CI 检查必须 fail-fast，禁止把错误吞成 0：

```bash
# ❌ 错误：错误被吞成空字符串后下游"看起来 0 个问题"
CHANGED=$(git diff --name-only "origin/${BASE_REF}...HEAD" 2>/dev/null || true)

# ✅ 显式可达性校验，找不到就 exit 1
if ! git rev-parse --verify "origin/${BASE_REF}" >/dev/null 2>&1; then
  echo "❌ origin/${BASE_REF} not found; checkout step needs fetch-depth: 0"
  exit 1
fi
CHANGED=$(git diff --name-only "origin/${BASE_REF}...HEAD")  # 不吞错误
```

**新增/修改任意 PR 门禁 step 后必须构造反例 PR 验证应该红的真红**——只看正向通过不算验证。

详见 [.gitea/workflows/README.md](../../.gitea/workflows/README.md) 红线段。

### 7.5 不在 CI 里 `git fetch ... --depth=1`

`actions/checkout@v4` 默认浅克隆，需要历史时显式 `fetch-depth: 0`。再叠加 `--depth=1` 会把已有历史砍掉，三点 diff（依赖 merge-base）失败。

详见 [.learnings/2026-05-01-ci-shallow-fetch-fail-open.md](../../.learnings/2026-05-01-ci-shallow-fetch-fail-open.md)。

---

## 8. 跟测试金字塔的接合

测试金字塔的设计原则见 [docs/standards/05-development-workflow.md §492](../standards/05-development-workflow.md)；本节只列「在 CI 哪里跑」的实施细节。

| 测试层 | CI 实施位置 | 触发时机 | 备注 |
|---|---|---|---|
| L0 页面清点 | （不在 CI 跑，是范围规划） | L1 本地 | AI 在 L1 阶段输出范围清单 |
| L0a/L0b 契约校验 | quality-gates.yml::contract-check | PR → develop | 静态对比前端字段 ↔ 后端 DTO |
| **L0c 响应快照** | quality-gates.yml::backend-integration **Phase 3**（同 step 内） | PR → develop | **复用 backend container** 零增量成本——needs 不共享 runner，所以必须同 step |
| L1 集成（受影响模块） | quality-gates.yml::backend-integration Phase 2 | PR → develop | runs-on uat-with-docker，flock 互斥 |
| L1 集成（全量） | deploy-uat.yml::post-deploy-regression | merge → staging | runs-on uat-with-docker，flock 互斥 |
| L1c 数据质量 | nightly-snapshot-check.yml | **schedule 注释中（未启用）** + workflow_dispatch | 之前在 deploy-uat 跑，已迁出（数据健康度不应阻断 UAT 部署 + 之前从未真正连上 UAT 库） |
| L2 E2E | （**当前不在 CI 跑**） | L1 本地（改动页 MCP）+ L3 人工阶段（全量） | post-deploy-regression 注释里 TODO：Playwright MCP 服务可用后接入 |
| L3 人工验收 | （不在 CI 跑） | UAT 阶段 | 用户/PM 执行 |
| **环境层（L4）** | deploy-production.yml::pre-deploy-checks（env-check.sh）+ post-deploy-smoke（smoke-test.sh） | merge → production | 部署前 env 校验 + 部署后 smoke |

**当前缺口**（vs standards/05 设计意图）：
- L2 部署后无 Discord 通知 / 自动回滚（standards/05 §116 写了，未实施）
- L3 部署后 L1c 已迁到 nightly-snapshot-check（standards/05 §138 还按老位置写）
- L4 灰度部署 / auto-rollback 未实施（standards/05 §163-164/169 写了，未实施）

---

## 9. 已知约束

### 9.1 Gitea / act_runner 跨版本配套

- act_runner v1.0.x 配 Gitea **1.26+**；v0.6.x 配 Gitea **1.25-**
- 升级 Gitea 必须同步升级 act_runner，否则 deploy job silent hang（CI log 黑洞 + 远端从未真发 ssh）
- 升级前 mandatory check：`free -h | head -2`（≥ 4 GB）/ `df -h`（≥ 5 GB free）/ `uptime`（load < 2）
- Gitea + act_runner **不要同机**（任一升级或负载就 OOM）

详见 [.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md](../../.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md)。

### 9.2 SSH HostKey 兼容性（runner 机本地绕行中）

runner 机的 ssh client 默认偏好 `sk-*`（FIDO 安全密钥）host key 类型，部分目标 sshd 不支持 → negotiate 失败 → ssh `CLOSE_WAIT` 不退 → CI step 卡死 + log 黑洞。

**当前临时绕行**：在 runner 机本地 `~/.ssh/config` 写：
```
Host <target-ip>
    HostKeyAlgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256,ssh-rsa
```

**已知风险**：跨 runner 不可移植——换/加 runner 就要再配（2026-05-18 加 `dedicated-runner-2` 就因此手工镜像了 `~/.ssh/config` + `deploy_key` + `known_hosts`，见 [02-gitea-config.md § 7](./02-gitea-config.md#7-后续维护规则) 第 6 条）。**长期方案**：在 yml 里 ssh 调用显式 `-o HostKeyAlgorithms=...`（待开 PR #283）。

详见 [.learnings/2026-05-10-...saga.md](../../.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md) 教训 6。

### 9.3 Self-hosted runner 跨 PR 共享 docker daemon

L1 集成测试用的全局测试容器（ffoa-test-postgres / -redis）跨 PR 复用 docker daemon → 上轮 PR 留下的孤儿连接会让 `reset_test_db_schema` 的 `DROP DATABASE` 卡死 → 强制重建容器（`docker rm -f` 在 backend-integration 的 Phase 1）。

详见 [.learnings/2026-05-01-test-db-silent-state-corruption.md](../../.learnings/2026-05-01-test-db-silent-state-corruption.md) / issue #211。

### 9.4 Secret 命名前缀约束

Gitea secret 不能以 `GITEA_` 开头（保留前缀），历史上 `GITEA_API_TOKEN_FOR_AI` → `AI_REVIEW_GITEA_TOKEN`。

---

## 10. 历史决策与 PR 引用

| 时间 | PR / Learning | 决定 |
|---|---|---|
| 2026-04 | PR #272 | dev → test 重命名 + 拆独立机器（170.106.161.71）；test secret 改名 `FFWORKSPACE_TEST_CI_KEY_170_106_161_71` |
| 2026-04 | PR #275 | weekly-retro 机制：Gitea Actions cron + Issue-driven 闭环 |
| 2026-05 | PR #277 | deploy.sh CI 模式跳过 stdout redirect（修 deploy log 黑洞表层） |
| 2026-05 | [.learnings/2026-05-10-...saga.md](../../.learnings/2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga.md) | Gitea 1.25→1.26 升级 + act_runner 跨版本兼容 + ssh HostKey 共 6 条教训 |
| 2026-05 | PR #280 | 3 个 deploy*.yml 加 concurrency（Gitea 1.26.1 升级解锁） |
| 待开 | PR #283 | 3 个 deploy*.yml 加 HostKeyAlgorithms（防御性，跨 runner 可移植）—— 当前优先级低（runner 本地绕行已生效） |

待办 / 未启用：
- `nightly-snapshot-check.yml` 启用（脱敏快照链路待打通）
- `weekly-retro.yml` schedule 在本 Gitea 实例首次生产验证
- ai-review.yml 退出 dry-run 观察期 → 加进 required checks
- L2 E2E 接入（Playwright MCP 在 self-hosted runner 上方案待定）

参考 audit 报告：[docs/proposals/ci-workflow-audit.md](../proposals/ci-workflow-audit.md)。
