问题描述

GitHub Actions workflow 中,git push 修改 .github/workflows/*.yml 时报错,提示 “refusing to allow a GitHub App to create or update workflow”。即使 Fine-grained PAT 显式授予了 workflows: write 权限,仍然被拒。

! [remote rejected] main -> main (refusing to allow a GitHub App to create or update
  workflow `.github/workflows/sync.yml` without `workflows` permission)

本文由 AI 助手 opencode 生成

根因分析

逐一排除可能原因:

  1. ❌ 权限不足?— PAT 有 workflows: write,排除。
  2. ❌ URL 前缀(x-access-token: vs oauth2:)?— extraheader 优先级高于 URL 凭证,单纯改 URL 无效,排除。
  3. ❌ PAT 类型(classic vs fine-grained)?— 清除 extraheader 后两种 PAT 均可用,类型不是根源,排除。
  4. ✅ 真凶:extraheader 中的 x-access-token: 包装。

查看 actions/checkout 源码 (src/git-auth-helper.ts):

const basicCredential = Buffer.from(
  `x-access-token:${this.settings.authToken}`,  // ← 任何 token 都包
  'utf8'
).toString('base64')
this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`

actions/checkout 将所有 token 包装为 x-access-token: 存入 git config 的 https.extraheader

[http "https://github.com/"]
  extraheader = AUTHORIZATION: basic <base64("x-access-token:<TOKEN>")>

git push 发送该 header 时,服务器看到 x-access-token: 前缀,判定为 GitHub App 安装 token,转而读取 workflow YAML 的 permissions: 块,忽略 PAT 自身的 scope。因此即使 PAT 有 workflows: write 权限,仍然被拒。

注意: GITHUB_TOKEN 不支持 workflows: write 权限,解析 workflow YAML 时即报错,无法用于更新 workflow 文件。必须使用 PAT(classic 或 fine-grained 均可)。

解决方案

根源是 x-access-token: 包装。以下方案按推荐优先级排列:

方案 C(推荐):persist=false + 手动写入裸 PAT extraheader

- uses: actions/checkout@v4
  with:
    token: $
    persist-credentials: false

# ... 中间步骤 ...

- name: Update cron (schedule only)
  if: github.event_name == 'schedule'
  env:
    NEW_N: $
    WORKOUTS_WORKFLOW_PAT: $
  run: |
    # persist-credentials: false → no extraheader written.
    # Write bare PAT extraheader (no x-access-token: prefix)
    # so server routes as PAT, not GitHub App.
    git config --add http.https://github.com/.extraheader \
      "AUTHORIZATION: basic $(printf '%s:' "$WORKOUTS_WORKFLOW_PAT" | base64 -w0)"
    git add .github/workflows/sync.yml
    git diff --cached --quiet || \
      git commit -m "chore: sync interval N=$NEW_N"
    git pull --rebase
    git push

原理: persist-credentials: false 阻止 checkout 写入 extraheader,然后手动写入一条不含 x-access-token: 前缀的 Authorization header。服务器看到裸 PAT 值,正确识别为用户 PAT。

优点: PAT 不出现在进程参数(ps aux)、不修改 remote URL、一行 bash 搞定。

方案 D(备选):persist=true + 清除 extraheader + URL 内嵌 PAT

git config --unset-all http.https://github.com/.extraheader || true
git remote set-url origin https://$WORKOUTS_WORKFLOW_PAT@github.com/Lax/workouts.git

缺点: URL 中的 PAT 在 ps aux 中可见(单租户 VM 风险极低,但可能过安全审查)。

四种方案对比

# 方案 ps 安全 代码行数 已验证
C persist=false + 手动裸 PAT extraheader 2 行
A persist=false + URL 嵌入 PAT ⚠️ 1 行
D persist=true + 清除 extraheader + URL PAT ⚠️ 2 行
B persist=true + mutate extraheader 5 行

简要说明:

  • 方案 Apersist=false + URL 嵌入 PAT)代码最简但 PAT 会暴露在 ps aux
  • 方案 Bpersist=true + 解码 extraheader 替换 x-access-token: 前缀)ps 安全但步骤繁琐,未验证

关键点:

  1. 根源是 x-access-token: 包装,不是 PAT 类型 — classic 和 fine-grained PAT 均可用
  2. persist-credentials: false 从源头阻止 — 最优雅
  3. extraheader 优先级高于 URL 凭证 — 仅改 URL 不够

复盘

这个问题的排查过程本身值得记录:AI 在”token 类型 → permissions → 版本 → scope”等 参数上反复尝试,每次只改一个变量、运行、看结果,消耗了大量时间。真正突破来自人工介入 ——缩小验证范围,设计对比实验(几种 token × 是否有 extraheader 的交叉测试), 最终定位到 extraheader 而非 PAT 类型。

AI 擅长执行已知路径的试验,但在搜索空间过大时容易原地打转。 人类的贡献在于精确定义”什么值得测”,缩小搜索空间。 这是当前 AI 辅助调试的一个典型局限。

参考