GitHub Actions:从 PR 验证到受控交付流水线
一次“全绿”流水线为什么发错了包
周五晚上的 Workflow 全部显示绿色,生产环境却运行着前一次提交。追查后发现,构建 Job 上传了一个名为 web-dist 的压缩包,部署 Job 又从可变分支重新构建;缓存键只包含分支名,旧依赖被恢复;两个生产部署并发运行,较早启动的任务最后完成,把新版本覆盖了。团队拥有测试、审批和回滚按钮,却没有回答三个问题:究竟交付了哪个字节序列,谁授权它进入生产,失败时怎样证明恢复的是上一份可信制品?
GitHub Actions 的核心对象并不复杂:事件创建 Workflow Run,Run 包含一个或多个 Job,Job 被路由到 Runner,Step 在同一 Job 的工作目录中依次执行。复杂性来自这些对象跨越了不同信任域:PR 作者能控制源码和构建脚本,Action 作者能控制第三方执行代码,Runner owner 能控制执行环境,仓库管理员能控制令牌权限,发布审批人决定制品能否进入环境。
一条可靠链路应保持以下不变量:
验证任务可以执行不可信代码,但拿不到发布凭证,也到不了生产网络。构建只发生一次,后续环境提升同一份制品及其 SHA-256,而不是重新构建。Cache 丢失只影响速度,不能改变构建是否正确;Artifact 是证据,不能被当作可信输入直接执行。
生产部署互斥且可审计,取消旧任务不会中断不可逆操作。Runner 用完即弃或完成可证明的清理,凭证、工作区和容器层不跨信任域残留。
先跑通一个不接触密钥的验证闭环
在测试仓库创建一个最小 Node.js 工程:
{
"name": "actions-lab",
"private": true,
"scripts": {
"test": "node test.mjs",
"build": "node build.mjs"
}
}// test.mjs
if (process.env.FORCE_FAILURE === "1") {
console.error("EXPECTED_FAILURE: forced by test fixture");
process.exit(23);
}
console.log("TEST_OK");// build.mjs
import { mkdirSync, writeFileSync } from "node:fs";
mkdirSync("dist", { recursive: true });
writeFileSync("dist/build.txt", `${process.env.GITHUB_SHA ?? "local"}\n`);生成锁文件并先在本机确认脚本语义:
npm install --package-lock-only
npm test
npm run build
cat dist/build.txt预期先看到 TEST_OK,随后 dist/build.txt 包含本地占位值。再运行反向实验:
FORCE_FAILURE=1 npm test
echo $?Bash 下退出码应为 23;PowerShell 使用 $env:FORCE_FAILURE='1'; npm test; $LASTEXITCODE,验证后执行 Remove-Item Env:FORCE_FAILURE。这个固定退出码让“测试失败”与 Runner 丢失、依赖下载失败等基础设施故障可以区分。
创建 .github/workflows/verify.yml:
name: verify
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: verify-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-and-build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci --ignore-scripts
- run: npm test
- run: npm run build
- name: Record digest
run: cd dist && sha256sum build.txt | tee SHA256SUMS
- name: Upload build evidence
id: upload
uses: actions/upload-artifact@v7
with:
name: web-dist-${{ github.sha }}
path: dist/
if-no-files-found: error
retention-days: 14
- name: Write run summary
run: |
{
echo "### Build evidence"
echo "- commit: $GITHUB_SHA"
echo "- artifact digest: ${{ steps.upload.outputs.artifact-digest }}"
} >> "$GITHUB_STEP_SUMMARY"提交到分支并创建 Pull Request。预期 Run 中只有 contents: read,测试输出 TEST_OK,Artifact 名称包含完整 Commit SHA,摘要中同时出现源码身份和上传后摘要。示例使用当前主版本便于首次运行;生产仓库应由依赖更新机器人把每个 uses: 固定为经过审核的完整提交 SHA。完整 SHA 是不可变引用,主版本标签不是。
把测试步骤临时改成 FORCE_FAILURE=1 npm test。预期 Job 以 23 退出,构建和上传步骤不再执行。恢复后重跑,状态转绿。若失败仍显示绿色,通常是脚本使用了 || true、错误地读取了管道最后一条命令的退出码,或把关键步骤设置成了 continue-on-error。
YAML 里每个字段都在改变执行模型
on 决定谁能请求计算资源,permissions 决定 Job 拿到怎样的 GITHUB_TOKEN,runs-on 决定代码进入哪个网络和文件系统,timeout-minutes 限制失控任务占用资源的时间,concurrency 决定重复运行如何相互影响。它们不是装饰性元数据。
needs 构成 Job 依赖图。不同 Job 默认运行在不同 Runner 上,不能假设工作目录会保留;需要跨 Job 传递的字节必须进入 Artifact 或外部制品仓库。if: always() 适合上传测试报告,不适合让部署绕过构建失败。矩阵任务会放大 Runner 分钟数、缓存条目和 Artifact 数量,应把操作系统、运行时版本和架构视为容量维度,而不是越多越好。
npm ci --ignore-scripts 在这个实验里阻止依赖安装脚本执行,并不适合所有项目。确实依赖原生编译或生命周期脚本时,应审查 lockfile 变化、隔离网络和凭证,再有意识地移除该参数。可复现构建还需要固定运行时、包管理器、锁文件、时区与 locale,消除构建时间戳;同一 Commit 在两个干净 Runner 上构建后,SHA-256 应一致。若不一致,先比较归档顺序、文件 mtime、依赖镜像和环境变量,不要急着把差异归咎于 Runner。
Hosted 与 Self-hosted Runner 的选择
GitHub-hosted Runner 由平台供应和回收,适合普通开源构建、无内网依赖的业务和波动负载。Self-hosted Runner 适合专用硬件、私网依赖、定制镜像或合规边界,但团队同时接管操作系统补丁、Runner 版本、镜像预热、网络出口、日志外送、容量与残留清理。
在仓库、组织或企业的 Settings -> Actions -> Runners 创建 Self-hosted Runner 后,界面会给出与平台、架构匹配的下载和注册命令。注册令牌短期有效,不应写入镜像、脚本仓库或工单。Linux 上的操作顺序是:
建立无登录权限的专用系统账号和独立工作目录。从设置页下载 Runner 包,按页面提供的校验值验证后解压。用页面生成的短期令牌执行 ./config.sh --url <repository-or-organization-url> --token <registration-token>。
为专用池设置 Runner Group 与标签,例如 self-hosted,linux,x64,build-untrusted。安装并启动服务,提交一个只输出 RUNNER_NAME、RUNNER_OS、RUNNER_ARCH 的探针 Job。删除 Runner 时先停止接单,等待在途 Job 完成,用设置页新生成的删除令牌执行 ./config.sh remove --token <removal-token>,再销毁主机或磁盘。
生产弹性池应使用临时 Runner。注册时的 --ephemeral 使 Runner 完成一个 Job 后自动注销,但自动注销不等于主机已擦除;编排器还要销毁 VM、Pod 或磁盘,并把 Runner 应用日志提前发送到外部存储。Kubernetes 团队通常使用 Actions Runner Controller 管理规模集,非 Kubernetes 场景可以使用 Runner Scale Set Client 或自建基于 workflow_job 事件的供应器。
若镜像内注册时启用 --disableupdate,平台团队必须在 Runner 镜像流水线中主动升级。版本落后会导致新 Action runtime 无法执行,关键安全更新还可能让平台停止向旧 Runner 派发任务。升级顺序应是:构建新镜像、启动 canary Runner、跑 checkout/容器/Artifact/OIDC 探针、逐步扩大池、排空旧池、保留上一镜像的快速恢复入口。
长期在线 Runner 不应同时服务公开 Fork、内部项目和生产发布。Shell、宿主 Docker Socket、云实例元数据和内网访问会把一次普通 PR 变成宿主机权限。合理的池划分至少区分“不可信验证”“可信构建”“受控发布”,并用 Runner Group 限制可使用仓库;发布池不承担 PR 任务,验证池拿不到生产路由。
Fork PR:把源码当作攻击者输入
pull_request 适合执行 Fork PR 的测试。来自 Fork 的运行通常拿不到普通 Secret,GITHUB_TOKEN 权限受限,但这不意味着代码安全:恶意测试仍能消耗计算资源、探测 Runner 网络、污染可写缓存或读取同机残留。
pull_request_target 运行默认分支上的 Workflow,并处在基础仓库的高信任上下文。危险组合不是“检出代码”四个字,而是在持有基础仓库权限和 Secret 的 Job 中执行 PR 控制的任何内容,包括 Makefile、包安装钩子、测试配置和下载来的 Artifact。标签、评论等元数据自动化可以使用该事件,但不要检出并执行 PR Head。需要对 PR 结果做特权处理时,拆成两段:低权限 Workflow 只生成被视为不可信数据的结果;高权限 Workflow 校验格式、来源 Run、Commit 和内容边界后,只把它作为数据读取,绝不执行。
表达式也可能成为命令注入入口。不要把 ${{ github.event.pull_request.title }} 直接拼进 Shell;先放入环境变量,再把它作为带引号的数据处理。审批 Fork 首次运行只控制“何时执行”,不能替代 Runner 隔离和最小权限。
权限和 OIDC:让长期密钥退出流水线
顶层先设 permissions: {} 或 contents: read,再在具体 Job 增权。构建 Job 通常不需要写仓库;发包 Job 可能需要 packages: write;创建证明可能需要 attestations: write 和 id-token: write。不要给整个 Workflow 一个宽权限,再指望没有用到它的步骤保持克制。
云部署应使用 OIDC 换取短期凭证:
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- name: Exchange OIDC token
uses: <cloud-provider-login-action>@<reviewed-full-commit-sha>
with:
role: <deployment-role>
- run: ./scripts/deploy-existing-artifact.sh "$ARTIFACT_DIGEST"id-token: write 只允许 Job 请求 OIDC JWT,不直接授予云权限。真正的边界在云端信任策略:校验 issuer、audience、仓库、Ref 或 Environment 等 Claim,并把角色权限限制到目标环境。只校验组织名会让同组织内任意仓库尝试换取身份;只校验分支名会混淆同名 Fork。调试时可以解码不含 Secret 的 JWT Header/Payload,不能把完整 Token 写进日志或 Artifact。
Environment Secret 要等保护规则通过后才提供给 Job。审批人、禁止自批、允许部署分支和管理员绕过策略应一起评审;相关能力受仓库可见性与套餐影响,不能用一份模板假设所有仓库都具备相同审批能力。
Cache、Artifact 与制品身份
Cache 保存可重新生成的数据,例如包管理器下载目录;Artifact 保存本次 Run 的输出和证据。Cache Key 至少纳入操作系统、架构、锁文件摘要和会改变输出的工具链版本。宽泛 restore-keys 会恢复“相似但不相同”的内容,只适合下载缓存,不适合可执行二进制和最终制品。
缓存边界实验可以安全地在测试仓库完成:让功能分支或 Fork PR 尝试写入带随机 nonce 的缓存,再让默认分支使用相同 Key 恢复。GitHub 内建 Cache 的正确结果应是默认分支 Cache Miss,因为普通分支不能向父分支写缓存,pull_request 产生的缓存又被限制在 PR Merge Ref;pull_request_target、issue_comment、workflow_run 等低信任触发器对默认分支作用域也只有读取权限。若默认分支真的读到 nonce,说明团队使用的外部缓存、共享目录或自建代理绕过了平台作用域,必须立即停止把恢复内容交给编译器执行。可信 push 负责写缓存,低信任事件显式使用 Restore-only,Key 再纳入锁文件摘要和信任域;不要把真实令牌放入标记文件。
上传 Artifact 后记录 artifact-id、artifact-url、artifact-digest、Run ID、Commit SHA 和构建参数。artifact-digest 描述上传归档,download-artifact 会自动比对它,但不一致时平台只记录警告;它不能替代发布门禁。构建 Job 还应在归档内生成文件级 SHA256SUMS,部署 Job 下载后执行 sha256sum --check,以非零退出码阻断篡改或缺失文件。保留天数应按用途区分:失败诊断通常短,候选发布覆盖发布窗口与回滚窗口,审计证据进入受控长期存储。把整个工作区、.git、.env、测试数据或 Home 目录上传,会同时增加泄漏面与存储成本。
高价值制品应补充 provenance attestation,并在消费端验证仓库身份、Workflow、Ref 和 subject digest。证明“谁在何处构建”不能证明代码没有漏洞;它解决的是来源与完整性,不替代测试和签名策略。
从同一制品提升到受控环境
构建 Job 输出不可变 Artifact,发布 Job 只消费它:
deploy-production:
needs: test-and-build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v5
with:
name: web-dist-${{ github.sha }}
path: release/
- run: cd release && sha256sum --check SHA256SUMS
- run: ./scripts/deploy-existing-artifact.sh "release/build.txt"验证类 Workflow 可以 cancel-in-progress: true,因为旧 Commit 的测试失去价值;生产发布通常应设为 false,让部署串行完成。并发组只是字符串,Environment 不会自动获得互斥;所有会修改同一目标的 Workflow 必须使用同一并发键。若部署不是幂等操作,强制取消可能留下半迁移状态,需要由发布系统提供事务边界或补偿步骤。
回滚不应在旧 Tag 下重新构建。发布记录保存制品摘要、部署参数和审批记录;回滚选择上一份已验证摘要重新提升。数据库和流量回滚由对应生产 SOP 决定,Actions 负责提供准确制品身份、受控凭证和可审计入口。
用证据定位失败点
Workflow 未出现时,先检查事件过滤、默认分支上的配置、路径过滤和仓库/组织 Actions 策略。Run 已创建但 Job 长时间排队,检查 runs-on 标签、Runner Group 授权、Runner 在线状态和池容量;脚本尚未开始时,不应排查 npm 命令。
Resource not accessible by integration 通常表示 GITHUB_TOKEN 缺少对应权限,或 Fork 事件本来就不允许该写操作。先定位失败 API,再给单个 Job 增加最小权限,不要直接改成 write-all。OIDC 交换失败时比对 iss、aud、sub、Ref 和 Environment Claim;时间漂移、代理拦截 Discovery 或云端条件过窄都会留下不同证据。
缓存命中后构建异常,立即用禁用缓存的 Run 复测。无缓存成功说明应比较 Key、恢复前缀、缓存写入事件和目录内容;无缓存仍失败则回到源码、锁文件和工具链。Artifact 缺失时检查上游 Step 是否实际生成文件、if-no-files-found、路径、上传动作退出状态和保留策略。Job 成功不等于文件已被纳入归档。
Self-hosted Runner 上出现偶发脏文件、陌生进程或跨仓库内容时,停止派发并隔离整个 Runner,不要只 rm -rf 工作目录后继续接单。保留 Runner ID、Run/Job ID、镜像版本、网络日志和磁盘快照;轮换可能暴露的凭证,删除相关 Cache,追踪下游制品,再从可信镜像重建池。
容量、成本与长期治理
平台团队应观察排队时间、运行时长、失败类型、缓存命中与下载字节、Artifact 增长、Runner 利用率和取消数量。扩容依据应是排队年龄和 SLO,而不是只看 CPU;矩阵爆炸、无限 Retry 和大 Artifact 往往比单机规格更先推高成本。Hosted Runner 的规格、分钟、存储和保留上限会变化,预算要由组织当前策略与基线测量决定。
每个共享 Workflow、Runner Group、Environment 和云角色都要有 owner。Action 升级先看来源仓库、Release Diff、runtime 要求和权限变化,再更新完整 SHA;Runner 镜像升级跑 canary;审批人和 Secret 定期复核;临时豁免记录原因、责任人和到期时间。删除 Workflow 时一并清理 Environment、OIDC 信任、组织 Secret、Runner 授权、Cache 和 Artifact 策略,避免“代码已删,身份仍活着”。
交付链进入稳定状态时,应能持续证明:无缓存构建仍成功;同一输入在干净环境产生相同摘要;Fork PR 接触不到发布身份;生产同一时刻只有一个变更者;每次部署都能追到 Commit、Workflow、Runner、Artifact 摘要和审批;上一份可信制品可在演练环境重新提升。绿色图标只是结果,能回答这些问题才是工程能力。
