---
name: deploy
owner: team-lead
version: 3
last-updated: 2026-04-23
---

# SOP: 发布代码到 alpha / epsilon

两台机器：
- **alpha** 测试机（1C1G，issue #001 建的）—— 合 main 自动同步（`deploy-staging.yml`）
- **epsilon** 生产机（`112.124.27.213`）—— **打 tag 自动部署**（`release.yml` 的 deploy-production job）

整体链路见 [release-flow.md](../release-flow.md)。

## alpha（自动）

### 原理

合 main 后 GHA `deploy-staging.yml` 自动执行：
1. checkout 最新 main
2. ssh alpha：rsync 代码（排除 `.venv / data / .git / *.db*`）
3. ssh alpha：`uv sync`
4. ssh alpha：`systemctl restart xiangqin`
5. curl alpha `/health` smoke test

### 人肉验收

GHA 跑完后本地：

```bash
# 环境变量临时指向 alpha
xq --endpoint http://<alpha-IP> health
xq --endpoint http://<alpha-IP> register 13800001111   # 真跑注册流程（alpha 用测试号段）
```

完整 alpha 验收清单在 [../release-flow.md](../release-flow.md)。

### 失败处理

| 症状 | 看哪 |
|---|---|
| GHA deploy-staging.yml 红 | GHA 日志 → 常见：ssh key 过期 / 磁盘满 |
| curl alpha/health 不通 | `ssh alpha 'journalctl -u xiangqin -n 100'` |
| rsync 同步了但服务未重启 | `ssh alpha 'systemctl status xiangqin'` |

alpha 挂不阻塞生产（epsilon 独立），修 alpha 可以慢慢来。

## epsilon（tag 触发自动部署）

### 前置条件

- [ ] alpha 已跑通当前 main（上一节）
- [ ] 主仓 `pyproject.toml` 版本号 + `src/xiangqin/__init__.py.__version__` + `CHANGELOG.md` 三处对齐
- [ ] 如果涉及 schema 变更：备份已做（OSS `xiangqin.db` 最近 24h 内的快照存在）

### 触发：打 tag

```bash
cd /Users/yarnb/xiangqin
git tag v0.X.Y
git push --tags
```

push tag 自动触发 `release.yml`：
1. `test` job（gate）—— pytest + ruff 必须绿
2. `publish-pypi` `sync-skills-sh` `publish-clawhub` 并行发三端
3. `deploy-production` job（needs `publish-pypi`）—— rsync → epsilon → systemd restart → smoke test

全程无人工点按钮。GHA `environment: production` 标记了这是真生产部署。

### 本地兜底（GHA 挂时 / 紧急）

```bash
cd /Users/yarnb/xiangqin
bash scripts/deploy.sh epsilon
```

`deploy.sh` 做 4 步（参数化支持 `epsilon` / `alpha`）：
1. rsync 代码到 `root@epsilon:/opt/xiangqin/`（排除 `.venv / data / .git / *.db*`）
2. ssh root 跑 `uv sync`（用 `UV_PYTHON_INSTALL_DIR=/opt/uv-python` 共享路径）+ chown xiangqin
3. 复制 `systemd/xiangqin.service` → `/etc/systemd/system/` + `daemon-reload` + `systemctl restart`
4. 本机 `curl http://112.124.27.213/health` smoke test（内网 via ssh）

### 验收

- [ ] 最后一行 `curl http://112.124.27.213/health` 输出 `{"status":"ok","version":"<新版本>"}`
- [ ] 本地 `xq health`（装好的 PyPI 新版）过
- [ ] 如果是版本 bump：`xq --version` 显示新版

### 失败回滚

不要原地继续 debug 生产，直接按 [../emergency/rollback.md](../emergency/rollback.md) 回到上个 tag。

紧急停服（确认不影响用户再做）：

```bash
ssh root@112.124.27.213 'systemctl stop xiangqin'
```

数据损坏：按 [restore.md](./restore.md)。

## 配置变更（环境变量）

- `XIANGQIN_SMS_PROVIDER=mock|aliyun` —— 短信 provider
- `XIANGQIN_PAYMENT_PROVIDER=mock|wechat|alipay` —— 支付 provider（0.1 期都 mock）
- `XIANGQIN_PHONE_SALT=<随机盐>` —— 手机号 hash 盐（⚠️ 不要改，改了等于让所有老手机号匹配失败）
- `XIANGQIN_CERT_ID_SALT=<独立随机盐>`（v0.6.0+）—— 身份证号 hash 盐（⚠️ 同理不要改；和 PHONE_SALT 独立，互不复用）

修 systemd unit 的 `Environment=` 行后：

```bash
scp systemd/xiangqin.service root@112.124.27.213:/etc/systemd/system/
ssh root@112.124.27.213 'systemctl daemon-reload && systemctl restart xiangqin'
```

### SALT 类 env 的存活约定（关键）

**live 位置**：每台机器的 `/etc/systemd/system/xiangqin.service.d/override.conf` 里 `Environment=` 行；主仓 `systemd/xiangqin.service` **不**写 salt（salt 是密码级，不进 git）。

**灾备镜像**：vault 里两条 credential 存着每台机的 salt，供机器消亡时恢复：

```bash
vault credential show xiangqin-epsilon-salts --unmask  # epsilon 生产
vault credential show xiangqin-alpha-salts   --unmask  # alpha staging
```

**为什么每台独立 salt**：alpha ≠ epsilon，salt 不复用 → 一台被打不影响另一台。

**恢复流程**（host 消亡新机接管时）：

```bash
# 1. 从 vault 拉回原 salt（不能重新 openssl rand，否则老数据 hash 全失配）
PHONE=$(vault credential show xiangqin-epsilon-salts --field phone_salt --unmask)
CERT=$(vault credential show xiangqin-epsilon-salts --field cert_id_salt --unmask)

# 2. 新机写 override.conf
ssh root@<new-host> "mkdir -p /etc/systemd/system/xiangqin.service.d && cat > /etc/systemd/system/xiangqin.service.d/override.conf <<EOF
[Service]
Environment=XIANGQIN_PHONE_SALT=$PHONE
Environment=XIANGQIN_CERT_ID_SALT=$CERT
EOF"

# 3. daemon-reload + restart + DB restore（从 OSS backup，见 restore.md）
```

**新增 salt 时的动作清单**（防 #003 / #031 类漏洞再发生）：

1. 代码加 `os.environ.get("XIANGQIN_<NAME>_SALT", "dev-...-not-for-prod")` 这种 env 读取
2. **同一 PR 里**更新本 SOP 的 SALT 列表 + 恢复流程
3. **同 PR 里**给两台机器写 override.conf + restart
4. **同 PR 里**把新 salt 加到 vault 对应的 `xiangqin-<env>-salts` credential 的 values 里

第 2-4 步不能拖到"以后"，否则又会像 CERT_ID_SALT 那样漏配在 dev 值。

## 历史事故

- （初版，待事故充填）
