# ERR-20260515-003 — bash 嵌入 Python 解析中文反引号/标点炸，意外清空 PR body

**Date**: 2026-05-15
**Tags**: bash, python-heredoc, shell-escape, gitea-api
**Severity**: Medium（生产副作用：PR #384 body 被清空，已恢复）

## 症状

更新 PR #384 body 时用了 inline `python3 -c "..."` 在 bash heredoc 里嵌入 Python：

```bash
NEW=$(echo "$CURRENT" | python3 -c "
import sys
body = sys.stdin.read()
addendum = '''
... develop→staging ...
... `gitea promote uat|prod` ...
'''
print(body + addendum)
")
```

报错：

```
/bin/bash: line 90: develop→staging: command not found
/bin/bash: line 90: gitea: command not found
SyntaxError: unterminated triple-quoted string literal
```

最后 `len: 0` —— Python 失败但 curl 仍然跑了 PATCH，**实际把 PR body 清空了**。

## 根因

bash 的双引号字符串里的 **反引号** ``` ` ``` 被当作 command substitution，把 `` `gitea promote uat|prod` `` 解析成"执行命令 gitea promote uat|prod"。命令本身又触发中文标点（中文逗号 / `→` 不是 ASCII）被 shell 解析成单词分隔。

Python 那边因为 bash 抢走了反引号包裹的内容，传入 Python 的 source 变成 `addendum = '''... '''` —— 三引号没正常闭合（其中 `'''` 之间的内容被 bash 改了），SyntaxError。

最致命的是：**Python 失败 → `$NEW` 是空串 → 后续 curl PATCH `{"body": ""}` 实际执行了**，把 PR body 清空。

## 解法

**绝对不要在 bash 双引号里嵌入含反引号或复杂字符的 Python**。改写：

```python
# /tmp/update_pr.py
#!/usr/bin/env python3
import json, os, urllib.request
TOKEN = os.environ["GITEA_API_TOKEN"]
body = """... 反引号自由 ... `gitea` ... → 也自由 ..."""
# call API
```

然后 `python3 /tmp/update_pr.py && rm /tmp/update_pr.py`。

shell 只负责调度，Python 文件里随便写。

## 防御深度（应该但没做的）

curl 之前**校验 body 非空**：

```bash
if [ -z "$NEW" ] || [ ${#NEW} -lt 100 ]; then
  echo "❌ NEW body 异常短，拒绝 PATCH"; exit 2
fi
```

如果 PR body 总是几 KB，"突然变 0 字节"就是明显异常，应该早拦下来。

## 元教训

### 1. shell 是糟糕的字符串拼接器

任何把"复杂含特殊字符的 payload"通过 shell 字符串传递的场景都是定时炸弹。
特别是要保留：反引号、中文标点（`→` / `「」` / `——`）、`$`、`!`、`'`、`"`、heredoc 分隔符。
规则：**payload 长度 > 200 字符 或 含 ≥2 种 shell 元字符 → 改 Python 文件**。

### 2. 失败要 fail-loud，不要 fail-empty

Python 在 subshell 里挂了 stderr 写 SyntaxError，但 stdout 是空——上层 `NEW=$(...)` 拿到空串当成"成功但结果为空"。
**所有"输出非空"是 contract 的步骤都要显式校验**，不要让"输出为空"被默认当成合法状态。

### 3. 副作用前的 sanity check

PATCH PR body 是有副作用的操作，body 长度跨数量级变化（3.8K → 0）就是异常信号。**所有有副作用的 API 调用前应有 sanity gate**：长度、关键字段存在性、与"上一次状态"的 diff size。这跟 #380 的 `merge_commit_sha == head.sha` post-merge 校验是同一类思想。

## 验证（恢复后）

```bash
curl -s -H "Authorization: token $GITEA_API_TOKEN" \
  http://43.130.59.228/api/v1/repos/FFAIWorkspace/workspace/pulls/384 \
  | python3 -c "import json,sys; print(len(json.load(sys.stdin)['body']))"
# → 3877  ✓
```
