GitHub Actions SSH 脚本里嵌套 Heredoc 会让 ${{ secrets }} 炸掉
在一次自动化部署改造中,我需要在 GitHub Actions 里通过 SSH 把若干密钥写入远程服务器的 .env.local。思路很直接:用 heredoc 批量写入,顺手加个 if ! grep -q 做幂等保护。写完本地测试没问题,推到仓库后 CI 直接报 syntax error: unexpected end of file,排查了近半小时才发现是一个隐蔽的展开顺序问题。
现象
CI runner 在执行 SSH action 的 script 字段时抛出以下错误(节选):
bash: line 12: syntax error: unexpected end of file
# 或者
OSSEOF: command not found
# 或者
unterminated quoted string
但在本地 shell 里手动跑同一段脚本完全正常,换任何人的机器也都正常,只有 GitHub Actions runner 上会报错。
根因
问题出在两件事的执行顺序上:
- GitHub Actions 先展开
${{ }}表达式,再把结果字符串通过 SSH 发送给远端 shell 执行。 - Shell 再解析 heredoc,此时它收到的脚本已经是展开后的内容。
下面是触发问题的原始写法:
- name: Sync env
uses: appleboy/ssh-action@v1.2.2
with:
script: |
cat >> /app/.env.local << 'ENVEOF'
if ! grep -q 'OSS_ACCESS_KEY_ID' .env.local 2>/dev/null; then
cat >> .env.local << 'OSSEOF'
OSS_ACCESS_KEY_ID=${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET=${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_BUCKET=my-bucket
OSSEOF
fi
ENVEOFActions 展开 ${{ secrets.OSS_ACCESS_KEY_ID }} 时,如果 secret 的值包含换行、空格、或任何看起来像 heredoc 终止符的字符串,就会破坏外层 ENVEOF 或内层 OSSEOF 的定界符匹配。即便使用了带引号的 << 'OSSEOF'(本意是阻止 shell 变量展开),也无法阻止 Actions 在更早的阶段就完成了 ${{ }} 展开——引号对 Actions 模板引擎没有任何约束力。
<< 'EOF' 的单引号只能告诉 shell 不要展开 $var。但 ${{ secrets.X }} 是 Actions 模板引擎在构建脚本字符串时处理的,此时 shell 还没有介入。结果是:shell 拿到的脚本已经是含有 secret 明文的字符串,若该字符串里恰好出现了 OSSEOF 或 ENVEOF,外层 heredoc 就会提前关闭,后续内容变成孤立命令,触发 syntax error。
嵌套 heredoc 本身在 bash 里也是边缘场景,稍有不慎就会让 shell 混淆两层定界符的归属。
正解
彻底放弃嵌套 heredoc,改用逐行 echo 追加:
- name: Sync env
uses: appleboy/ssh-action@v1.2.2
with:
script: |
cd /app
if ! grep -q 'OSS_ACCESS_KEY_ID' .env.local 2>/dev/null; then
echo "OSS_ACCESS_KEY_ID=${{ secrets.OSS_ACCESS_KEY_ID }}" >> .env.local
echo "OSS_ACCESS_KEY_SECRET=${{ secrets.OSS_ACCESS_KEY_SECRET }}" >> .env.local
echo "OSS_BUCKET=my-bucket" >> .env.local
echo "OSS_REGION=oss-cn-hangzhou" >> .env.local
fi每条 echo 是一条独立的 shell 语句。Actions 展开 ${{ secrets.X }} 后得到一个单行字符串,嵌入 echo "K=v" 里,shell 收到的是若干条普通的重定向命令,没有任何 heredoc 参与,定界符匹配问题彻底消失。
- 每个
${{ secrets.X }}只在单行echo里展开,最坏情况只破坏那一行,不会引发结构性 parse error。 - 幂等保护(
if ! grep -q)照样有效。 - 如果 secret 可能含特殊 shell 字符(
!、$、\),改用printf '%s\n' 'KEY=${{ secrets.X }}'或在运行时从 base64 解码,进一步隔离风险。
一句话外卖
在 GitHub Actions SSH 脚本里,
${{ }}的展开早于 shell 的 heredoc 解析——凡需要把 secret 写进远程文件,一律用逐行echo,永远不要嵌套 heredoc。