平时 4 分钟跑完的 GitHub Actions workflow 卡在部署步骤上 6 小时,最后被 The job running on runner X has exceeded the maximum execution time of 360 minutes. The job is canceled. 强杀。同样的 vercel deploy --prod 或 firebase deploy 在本地手动跑又一切正常。取消重跑有时成功,有时还卡。
原因几乎都是部署 CLI 在静默地等一个永远不会出现的东西:一个没有 stdin 可以回答的确认提示、一个因为 deployment ID 从没被设上而卡死的 wait-for-deployment 轮询、一个等首次 host key 确认的 SSH 部署,或一次停住的云上传。360 分钟是 job 上限,不是你的部署上限。
最快的修法:用 shell 的 timeout 工具把部署命令包起来,再加上 CLI 的非交互参数。这样卡住会在几分钟内带着明确报错失败,而不是白烧 6 小时 CI 分钟:
- name: Deploy to production
timeout-minutes: 15 # job 级安全网
run: timeout 600 vercel deploy --prod --yes
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
然后照下面的诊断修掉真正的卡点。
你属于哪一类?
对照症状,跳到对应修复。
| 卡住前日志最后一行 | 可能原因 | 跳到 |
|---|---|---|
Continue? (Y/n),或停在 Starting deploy... 之后 | CLI 在等提示,没有 stdin | 步骤 2 |
反复的 [poll N] / state=BUILDING,没有终止 | wait-for-deployment 永不收敛 | 步骤 4 |
停在一行 ssh/scp/rsync 之后 | SSH 首连 host key 提示 | 步骤 3 |
uploaded 47/120 assets 后归于沉寂 | 出站限速 / 代理把传输卡住 | 步骤 5 |
Downloading cache... 后没有完成行 | actions/cache 版本太旧、后端已变更 | 步骤 7 |
| hook 步骤是绿的,但目标面板没有构建 | 部署 hook 收下了却没真正开始 | 步骤 6 |
没有 timeout-minutes,步骤直接跑到 360 分钟 | 没有步骤级上限,job 默认值吃掉了卡住 | 步骤 1 |
开始排查前
- 取出失败 job 的 workflow YAML(完整的部署步骤,含
with:、env:和命令)。 - 顺着 job 日志确定卡在哪个步骤。
gh run view <run-id> --log比 Web UI 抓日志更干净。 - 是必现还是偶发?
- 打开目标部署平台自己的面板(Vercel deployments、Firebase function logs、Render/Railway 的 activity),对照他们端到底发生了什么。
- 确认 runner 类型(
ubuntu-latest、GitHub-hosted larger runner、还是 self-hosted):出站和 DNS 行为不一样,而 360 分钟上限只对 GitHub-hosted runner 生效;self-hosted runner 用的是 72 小时的 workflow 上限。
分步修复
顺序:先止血,再修根因。
步骤 1:给步骤设上限,让卡住几分钟就失败
每个部署和等待步骤都设 timeout-minutes。job 级默认是 360 分钟(截至 2026 年 6 月),而步骤级 timeout 永远不能超过 job timeout,只能往下压。
- name: Deploy to production
timeout-minutes: 15
run: vercel deploy --prod --yes
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
一个重要注意点:步骤级 timeout-minutes 会取消步骤,但对 uses: 类 action、以及某些 Docker / 子进程卡死的情况,runner 不一定能干净地中断那个卡住的子进程。凡是顽固的,就在命令外面再套一层 GNU timeout,在操作系统层面直接杀掉进程:
- name: Deploy to production
timeout-minutes: 15 # 外层安全网
run: timeout --signal=SIGTERM 600 ./deploy.sh
timeout 600 ... 在命令跑超过 600 秒时以退出码 124 退出,给你一个确定、快速的失败 —— 这是单靠步骤 timeout 不一定能保证的。
步骤 2:让部署 CLI 进非交互模式
这是最常见的原因:CLI 检测到变更,正在等一个永远不会来的 Y/n。
Vercel(用 env 传 token,不要用 --token:--token 参数可能进日志,而且在 Vercel CLI 50.16.0–52.0.0 上会进入一个结构化 JSON 错误负载 —— CVE-2026-44479,2026 年 5 月;升级到 52.0.1+):
- run: vercel deploy --prod --yes
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
Firebase:
- run: firebase deploy --non-interactive --force --project=prod
已知缺口(截至 2026 年 6 月):--non-interactive --force 在 firebase-tools 里仍不能自动回答每一个提示。Firestore index 字段覆盖删除、以及 firebase deploy --only firestore 或 --only functions 上某些 min-instances / min-bill 确认仍可能卡住。如果加了两个参数 Firebase 部署还卡,就把部署拆细(--only hosting、--only functions:myFn),让那个会卡的资源不在同一条命令里,并到 firebase-tools issue 区查你这条提示。
npm publish:
- run: npm publish --provenance --access public
env:
NPM_CONFIG_YES: "true"
步骤 3:稳妥跳过 SSH host key 提示
全新的 runner 上跑 ssh user@host 会问 Are you sure you want to continue connecting (yes/no/[fingerprint])?,因为没有 known_hosts 条目。提前把它填好:
- name: Add SSH known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Deploy via SSH
timeout-minutes: 10
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: deploy
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /var/www && git pull && pnpm install --prod && systemctl reload app
不要用 StrictHostKeyChecking=no 来”修”这个 —— 那是把中间人防护关掉了。ssh-keyscan 会把真实的 host key 钉上。
步骤 4:让 wait-for-deployment 轮询快失败
bobheadxi/deployments 或自己写的 gh api 轮询会等状态更新。如果部署根本没回报(部署者用了别的 SHA、状态 webhook 静默失败),轮询会无限继续。加上硬上限、心跳日志,和一个 ID 缺失的守卫:
- name: Wait for Vercel deployment
timeout-minutes: 10
run: |
DEPLOY_ID="${{ steps.deploy.outputs.id }}"
if [ -z "$DEPLOY_ID" ]; then
echo "ERROR: deployment id missing — trigger step never set an output" >&2
exit 1
fi
for i in $(seq 1 60); do
STATE=$(vercel inspect "$DEPLOY_ID" | grep -E "^\s+status" | awk '{print $2}')
echo "[poll $i] status=$STATE"
[ "$STATE" = "Ready" ] && exit 0
[ "$STATE" = "Error" ] && exit 1
sleep 10
done
echo "ERROR: deploy did not reach Ready in 10m" >&2
exit 1
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
deployment ID 缺失现在立即失败;真正卡住的轮询 10 分钟超时,而不是 6 小时。
步骤 5:给上传加进度看门狗
云上传(S3、GCS、Cloudflare R2)可能在传输中途撞上出站限速或公司代理而停住。TCP 连接不报错,只是不再前进。日志一安静就把它杀掉:
- name: Upload artifacts
timeout-minutes: 20
run: |
aws s3 sync ./dist s3://my-bucket --delete --no-progress | tee upload.log &
UP_PID=$!
touch /tmp/_lastsize
while kill -0 $UP_PID 2>/dev/null; do
sleep 30
if [ -z "$(find upload.log -newer /tmp/_lastsize 2>/dev/null)" ]; then
echo "no upload progress in 30s — killing transfer" >&2
kill $UP_PID
exit 1
fi
touch /tmp/_lastsize
done
wait $UP_PID
上传停住约 30 秒就死,不再挂到 job 超时。
步骤 6:交叉核对目标平台自己的日志
有些卡住其实不是卡住 —— workflow 在正确地等,而目标服务静默拒绝了、或根本没开始部署。webhook 风格的部署(Render、Railway、带 hook URL 的 Fly)可能回 runner 一个 200 OK 却从不开始构建。
- name: Trigger deploy
run: |
RESPONSE=$(curl -fsSL -X POST "$DEPLOY_HOOK_URL")
echo "deploy hook response: $RESPONSE"
- name: Sanity-check the deploy exists
run: vercel ls | head -5
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
如果目标面板上看不到这次运行时间戳对应的部署,那触发步骤就是静默失败了 —— 把它的完整响应和退出码打到日志。相关静默部署模式见 firebase deploy permission denied。
步骤 7:升级 actions/cache —— 旧的钉版本现在会直接失败,不只是卡住
这一节有实质变化。GitHub 在 2025 年 2 月 1 日关停了旧的缓存服务后端。任何还钉在 actions/cache@v1、@v2,或 3.4.0 / 4.2.0 之前版本(很多仓库到处复制的那个老 v3.3.2 SHA)的 workflow,现在已经连不上一个能用的后端 —— 它们现在会直接失败或卡在 Downloading cache...,而不只是偶尔挂一下。
截至 2026 年 6 月,当前线是 actions/cache@v5(Node.js 24 运行时,需要 runner 2.327.1+)。升到 v5,或者如果不方便升运行时就钉 v4.2.0+ / v3.4.0+:
- uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
如果出于供应链安全要钉 commit SHA,用 v5、v4.2.0+ 或 v3.4.0+ 版本的 SHA —— 绝不要再用老的 v3.3.x SHA。浮动的主版本 tag 也可能带进回归,升级前先过一遍 release notes。
怎么确认修好了
- 重新跑 workflow,总耗时回到上次绿色构建的约 1.5 倍以内。
- 每个部署和等待步骤都设了
timeout-minutes(顽固的uses:/脚本步骤还套了timeout N ...)。 - 故意制造的卡住现在 10–15 分钟带明确报错失败,而不是 6 小时被强杀。快速测试:临时把某个部署指向一个不存在的 host,确认它快速失败。
- 每条部署 CLI 调用都带了非交互参数(
--yes、--non-interactive --force、NPM_CONFIG_YES)。 - 等待步骤在 deployment 没出现时非零退出。
actions/cache在 v5 / v4.2.0+ / v3.4.0+ 上;没有Downloading cache...这一行后面没有完成行的情况。
长期预防
- 每个 CI workflow 强制步骤级
timeout-minutes,PR 里 lint 检测缺失。 - 始终给部署 CLI 显式传非交互参数,哪怕默认看似 OK —— 默认值会随 CLI 版本变。
- token 通过
env:(VERCEL_TOKEN、FIREBASE_TOKEN/GOOGLE_APPLICATION_CREDENTIALS)传,不要用会进日志的命令行--token参数。 - 部署成功要通过查目标服务的 API/CLI 确认,不只是看触发步骤的退出码。
- 写一份
.github/workflows/CHECKS.md,列出每步最坏 timeout 和卡住时的 runbook。 - self-hosted runner 上监控磁盘和内存;缓存恢复卡住常跟磁盘压力相关。
常见坑
- 设
timeout-minutes: 360以为修好了卡住 —— 这本来就是默认值。要的是低得多的步骤级上限。 - 对
uses:类 action 只靠步骤级timeout-minutes—— 顽固的子进程卡死要再用 shelltimeout工具包住命令。 - 用
StrictHostKeyChecking=no“解决 SSH 提示” —— 那是把 host 校验关了,是安全漏洞。用ssh-keyscan替代。 - 加
continue-on-error: true,workflow 绿了但部署根本没发生。相关静默部署模式见 vercel build failed。 - 用
if: always()的 Slack 步骤在部署超时了还报deploy succeeded。改成用steps.deploy.outcome == 'success'来 gate。 actions/cache还钉在 2025 年之前的 SHA 上 —— 那些版本指向一个已经不存在的后端,现在会直接失败。
常见问答
job timeout 能不能直接超过 360 分钟?
GitHub-hosted runner 上不行 —— 每个 job 上限就是 360 分钟硬顶;一次 workflow run 整体最多能撑 72 小时。self-hosted runner 上可以把 timeout-minutes 设到那个 workflow 上限,但真要超过 6 小时,该做的是拆部署而不是延长。处理卡住该用的工具是步骤级 timeout。
我的步骤设了 timeout-minutes,可步骤还是跑满了 360 分钟。为什么?
步骤级 timeout-minutes 对 uses: action 里或 Docker 步骤里卡死的子进程不一定能中断,于是 job 级上限把它吸收了。在 run: 步骤里用 timeout 600 ... 把真正的命令包住,让操作系统直接杀进程。
workflow 失败但部署其实成功了,怎么办?
最难处理 —— 回滚困难。先去目标面板确认;部署确实进去了的话,在那边手动 promote/approve,再去查 workflow 退出码为什么错了(多半是后面的烟雾测试步骤)。
部署和烟雾测试要不要拆成两个 job?
要。部署 job 把 URL 作为 output 输出、几分钟内结束保持干净状态;烟雾测试 job 消费那个 output,可以跑久一点也不堵部署日志。
ubuntu-22.04 上跑通,runner 镜像升级后 ubuntu-latest 上挂了?
可能 —— 镜像变更偶尔会改默认 ~/.ssh/config、装的 Node 版本,或某个预装 CLI 的版本。生产部署 workflow 把 runner 钉到具体镜像(ubuntu-22.04 或 ubuntu-24.04),别用 ubuntu-latest。相关 runner 环境调试见 vercel build failed。