Qwen Code 发布工作流 Shell 提取重构:从巨型 YAML 到仓库自有脚本的实战拆解
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
发布流水线在持续演进的过程中,往往会被"运营历史"和"超长 Shell 程序"慢慢撑大,最终逼近 GitHub 对工作流文件 500 KB 的硬性启动上限。qwen-code 仓库通过一次"Release workflow shell extraction"(发布工作流 Shell 提取)重构,把可执行准备、校验、发布与失败通知逻辑整体迁移到仓库自有的脚本中,让release.yml只保留编排、权限、条件与 action 调用。本篇以 设计文档 为骨架,结合 release.yml、run-release-step.sh、run-release-workspace-tests.sh、run-release-docker-integration.sh 与 release-workflow.test.js 等源码证据,带你理解这一重构的动机、安全边界、脚本职责划分与回归测试设计,并可直接迁移到自己的 CI 体系。
一、问题背景:编排、历史与长脚本的三种混杂
release.yml在重构前面临三重混杂:
- 编排与操作历史混杂:工作流里不仅写着"做什么",还积累了大量"曾经怎么处理过什么问题"的注释与补丁式逻辑;
- 超长 Shell 程序内嵌:版本解析、打包、发布、失败通知等几十行乃至上百行的
run:块直接写在 YAML 里; - 尺寸棘轮被反复消耗:仓库通过 check-workflow-size.sh 与 .size-baseline 对每个工作流文件实施"基线 + 增长额度"双重约束,日常维护每增加一段 Shell,都会消耗
release.yml仅 4096 字节的增长额度;一旦越过 470 KB 的内部闸门或 512 KB 的 GitHub 硬上限,工作流会"半死"——schedule 静默消失、dispatch 永远排队(该仓库的qwen-autofix.yml就曾在 2026-08-19 越线后让自动修复循环停摆一天,见 check-workflow-size.sh 头部注释)。
重构后的治理原则是:
把任务编排、权限、条件、action 调用与输入保留在 YAML 中;把可执行准备、校验、发布与失败通知逻辑迁移到仓库自有的脚本中。仅当相邻的发布命令已经共享同一个 job、同一组权限与同一个失败边界时,才允许合并。
当前 release.yml 全文 827 行(wc -l实测),其尺寸基线记录在 .size-baseline 中(release.yml 34861字节)。这个数字正是"提取完成后记录的新基线"——后续 Shell 维护改脚本、不改工作流基线。
二、核心设计:脚本从"被选中的 ref"中剥离
2.1 问题:操作者选择的 ref 不能提供"接收凭据的脚本"
发布流程由workflow_dispatch或schedule触发,操作者可以在 dispatch 时指定ref(分支或完整 commit SHA)来发布旧分支或旧提交。如果把提取出来的脚本放在普通 checkout 里,那么:
- 操作者选中的旧 ref 会提供
.github/scripts/下的提取脚本; - 这些脚本会在持有发布凭据的步骤中被执行(job token、bot PAT、npm OIDC id-token);
- 攻击面随之出现:ref 里的代码可以操纵接收凭据的脚本本体。
因此设计规定:每个调用提取脚本的 job,都必须先从github.workflow_sha(触发本次运行的那个工作流 commit)把.github/scripts检出到一个隔离路径,再执行脚本。实际实现见 release.yml 中的Checkout release workflow scripts步骤:
- name: 'Checkout release workflow scripts' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.workflow_sha }}' fetch-depth: 1 persist-credentials: false sparse-checkout: |- /.github/scripts /scripts/assert-release-version.mjs sparse-checkout-cone-mode: false path: '.release-workflow'要点拆解:
| 配置项 | 值 | 作用 |
|---|---|---|
ref | ${{ github.workflow_sha }} | 固定到"触发本次运行的工作流 commit",而不是操作者选择的发布 ref,保证脚本内容可信 |
fetch-depth | 1 | 只取一个 commit,最小化拉取成本 |
persist-credentials | false | 该 checkout 不写入凭据,脚本路径本身不带任何 token |
sparse-checkout | /.github/scripts+/scripts/assert-release-version.mjs | 只物化脚本目录与发布前版本守卫脚本,隔离路径最小化 |
sparse-checkout-cone-mode | false | 非 cone 模式,允许精确列出两个不连续的路径 |
path | .release-workflow | 隔离目录名,与主 checkout 完全分开 |
测试 release-workflow.test.js 的'loads extracted runners from the workflow commit for old release refs'用例逐 job 断言了这段with结构,并保证:在prepare、quality_build、workspace_tests、integration_docker、publish、notify_failure六个 job 中,脚本 checkout 必须存在,且除notify_failure外都必须排在主 checkout(Checkout步骤)之后——主 checkout 先以操作者 ref 落盘,脚本 checkout 再覆盖其.release-workflow子目录。
2.2 安全边界:无法阻止 ref 代码运行,但能切断"凭据步骤里的脚本注入"
文档明确承认并划定了边界:提取并不能阻止 ref 提供的代码在持有凭据的步骤里运行——两条既有路径保持不变:
- 版本解析仍执行所选 ref 的
scripts/get-release-version.js,且携带 job token; - release commit 仍会在持有 bot PAT 的步骤里运行该 ref 的
.huskyhooks。
真正被改变的是:接收发布凭据的"提取辅助脚本"不再可能由操作者选择的 ref 提供。github.workflow_sha指向工作流本身所在的 commit,而工作流文件与脚本的更新必须通过 PR 评审,相当于把脚本的可信度提升到了与工作流文件同等的级别。
2.3 运行后重置:只重置.release-workflow,绝不触碰.husky与package.json
在运行了所选 ref 的代码之后,持有凭据的步骤会先删除再重新检出那个隔离路径——只重置.release-workflow这一个目录,从不重置.husky或package.json。对应的 YAML 模式是每个受保护步骤前成对出现的:
- name: 'Reset release workflow scripts' run: rm -rf .release-workflow # 仅删除隔离目录 - name: 'Checkout release workflow scripts' uses: 'actions/checkout@...' with: ref: '${{ github.workflow_sha }}' ...测试're-pins trusted runners before every step that runs one'用"扫描而非手写清单"的方式守护这一模式:对release.yml中每一个包含.release-workflow/的步骤,断言其前面存在脚本 checkout,并且从该 checkout 到受保护步骤之间没有任何npm命令——因为 ref 提供的 npm 生命周期脚本可能就地改写 runner。同时断言每个非首次的脚本 checkout 前一步必须是Reset release workflow scripts。这条测试还要求扫描到的受保护步骤数大于 10(expect(swept).toBeGreaterThan(10)),防止清单悄悄缩水。
另一条测试'runs the step script only from the trusted checkout'则把前缀本身钉死:任何 step 调用run-release-*.sh都必须写成.release-workflow/.github/scripts/run-release-*.sh,禁止出现裸的.github/scripts/run-release-(那会命中主 checkout 里 ref 提供的副本)。
2.4 版本化与 bot-PAT 步骤保持分离
文档强调"版本化仍与 bot-PAT 步骤分离,保留既有凭据边界"。从 run-release-step.sh 可以看到这一分层:
resolve-version步骤只调用node scripts/get-release-version.js(来自主 checkout 的 ref 代码),产出RELEASE_TAG/RELEASE_VERSION/NPM_TAG/PREVIOUS_RELEASE_TAG;push-release-branch步骤才导出GH_TOKEN="${CI_BOT_PAT}"并gh auth setup-git,且推送前用隔离路径中的node .release-workflow/scripts/assert-release-version.mjs --assert-unreleased=...做发布前版本守卫(探测失败 exit 2 时重试三次,间隔递增)。
这样一来,任何旧 ref 的版本解析逻辑即使被攻破,也够不到 bot PAT;而接触 PAT 的 push 步骤只执行来自github.workflow_sha的可信脚本。
三、刻意保持内联的部分:checkout 前的工作区清理
文档明确说明:pre-checkout 工作区清理必须保持内联,不能迁移到仓库脚本。原因在于安全顺序:它必须在任何 checkout 能安全读取持久化自托管工作区(ECS 共享池)之前执行。若把它移入仓库脚本,就会依赖"先检出脚本再清理",从而反转其安全边界——脏工作区可能先于清理被脚本读到。
该步骤在 release.yml 中名为Restore workspace ownership,if: ${{ runner.environment == 'self-hosted' }},并且是每个 job 的第 0 步。其历史注释被压缩为"仅由测试强制的不显而易见不变量"。测试用整体相等(toBe(canonicalWipe),而非子串匹配)钉住九处副本,理由是:注释掉一行find、插入一个提前退出、或从全部九份副本中统一删掉 chown/chmod 阶梯,都能让子串/跨副本相等性测试变绿,同时重新打开事故门。该常量还要求:
- 拒绝符号链接或非规范路径(
RUNNER_WORKSPACE、GITHUB_WORKSPACE均需通过realpath -m与词法路径的比对,防止把整条守护链重定向到攻击者选定的位置); - 拒绝 runner 工作区之外的目标、含
..的路径、以及/、/home、/usr*等可疑根; - 只删除
$GITHUB_WORKSPACE内部内容,保留同级的 tool cache(ECS 池里其他 lane 通过无门控的 setup-node 从其中解析 Node); - 用
mktemp出的隔离release-state目录重写GIT_CONFIG_*、NPM_CONFIG_USERCONFIG、DOCKER_CONFIG、GH_CONFIG_DIR,切断跨 job 继承的凭据状态; - 不出现
ps/kill/pkill(测试'keeps workspace cleanup from inspecting or signaling host processes'防止清理步骤窥探或信号宿主机进程)。
行为级测试'executes the workspace wipe against guard branches'用真实文件系统构建了符号链接、..段、尾随斜杠、中间组件符号链接等十余种变异场景,逐一验证拒绝与"诱饵文件必须存活"。
四、提取后的脚本体系与 step 分发
4.1 run-release-step.sh:单一分发入口
run-release-step.sh(约 15.7 KB,100755可执行)以step为第一参数分发,内部按case实现全部发布子步骤。其开头set -eo pipefail是"fail closed"的关键:notify-failure里gh issue list ... | jq -c ...位于命令替换中,若gh发生连接级失败,jq对空输入会以 0 退出,pipefail缺失时整个替换被读成"没有现存 issue",从而绕过全部三条复用守卫新建 issue(测试'aborts notify-failure instead of filing a duplicate when gh is unreachable'专门守护这一点)。注意脚本注释说明:每个||都挂在整条命令而非管道腿上,所以pipefail下仍按预期工作。
各 step 的职责与关键实现:
| step | 职责 | 关键行为 |
|---|---|---|
set-flags | 解析 schedule cron / dispatch 布尔输入 | 精确匹配0 21 * * *(nightly)与0 17 * * 2(preview),写入is_nightly/is_preview/is_dry_run到GITHUB_OUTPUT |
resolve-commit | 固定发布 SHA | git rev-parse HEAD,输出release_sha |
resolve-version | 版本类型与覆盖值解析 | --type=nightly/preview/stable;preview 手动版本强制X.Y.Z或X.Y.Z-preview.N,否则 exit 1;调用 ref 的scripts/get-release-version.js后以jq提取四个输出 |
pack-build | 打包构建产物 | 硬编码dist+packages/web-templates/src/generated,find遍历时-prune掉node_modules/dist,且失败即关:路径数不大于 2 时报错退出 |
verify-package/build-package | 构建 + 准备包 | 强制校验dist/review-sources.sha256存在(review source stamp),缺失即失败 |
prepare-release-branch | 创建 release 分支 | 设置 git 身份与core.hooksPath .husky(这是所选 ref 的 hooks 在 PAT 步骤运行的路径),npm run release:version |
push-release-branch | 推送发布分支 | 此步骤才注入CI_BOT_PAT;推送前以.release-workflow/scripts/assert-release-version.mjs做 push-time 守卫(exit 2 重试 3 次、间隔 15s×attempt;exit 3 为已发布,置version_refusal=true后 exit 1);dry-run 跳过推送 |
build-archives/verify-archives | 独立归档构建与安装验证 | 支持OPENTUI_PREVIEW_RELEASE_ENABLED追加--include-opentui-preview |
publish-packages | 十二个包顺序发布 | 显式通道白名单dingtalk dws feishu github qqbot telegram wecom weixin;每个包--access public --tag=${NPM_TAG};dry-run 传--dry-run;已发布则 notice 跳过;用 marker 文件记录实际发布,全已发布时输出警告 |
label-release-prs | 自动为 PR 打 changelog 标签 | 通过gh api枚举PREVIOUS_RELEASE_TAG..HEAD的合并 PR,交给隔离路径中的classify-release-notes.mjs |
create-github-release | 生成并创建 GitHub Release | 先gh api .../releases/generate-notes生成草稿(可带previous_tag_name锚点),经cap-release-notes.mjs裁剪,再gh release create(nightly/preview 加--prerelease) |
dispatch-update | 通知 ECS 更新 | 向npm-publishedrepository_dispatch 投递版本,失败则提示手动运行Update ECS Runner Qwen |
notify-failure | 失败告警 + 自动 autofix | 见下节 |
测试对publish-packages的白名单做了"双源钉死":'keeps the publish allowlist and the guard package set in step'从脚本的for channel in ...循环解析通道集合,与 assert-release-version.mjs 导出的PUBLISHED_PACKAGES中@qwen-code/channel-*(排除base)逐一比对——新增发布通道却不同步守卫集合,会在 push-time 重试时被误判"未发布"并强推覆盖已发布锚点。
4.2 notify-failure:失败通知与 autofix 分发的三重复用守卫
notify-failure把五个 job(prepare/quality/integration_none/integration_docker/publish)的结果汇总成失败清单写入 issue body,然后走复用守卫逻辑:
gh issue list --search "Release Failed for ${RELEASE_TAG}" in:title后,jq用startswith("Release Failed for " + $tag + " on ")做精确标题复核(模糊搜索下v0.18.1可能命中v0.18.10的 issue);- 只复用 workflow 自有的 issue(
author.login == "github-actions[bot]"),人类维护者同题 issue 一律新建,避免把 autofix 挂到他人报告上; - 复用前复查
no:assignee -linked:pr -label:status/need-information -label:status/need-retesting,任一迹象都说明维护者已接管,停止 dispatch; - 带
autofix/skip或autofix/in-progress标签的 issue 直接 exit 0; - 否则打上
type/bug、status/ready-for-agent、autofix/approved标签(issue 内容完全由 CI 生成,无用户可控文本,可安全自动批准),再gh workflow run qwen-autofix.yml分发;分发失败时告警并让 schedule 兜底。
对应 release.yml 中notify_failurejob 还有内联的最后兜底步骤File a fallback failure issue,其if条件为${{ always() && steps.notify.outcome != 'success' && !steps.notify.outputs.issue_url }}——这正是文档所说"notify_failure 不依赖可信 checkout 或提取 runner(它是报告它们失败的 job),其最后手段的 issue 归档刻意内联"。
4.3 run-release-workspace-tests.sh:三 shard 分片与"传输超时放行"守卫
run-release-workspace-tests.sh 把 workspace 测试拆成 3 个完整 shard 执行:
npm run test:release:workspaces -- --shard="${shard}/3" --passWithNoTests "${retry_arg[@]}" 2>&1 | tee "${log}"重试:
VITEST_RETRY(release.yml 中默认${{ vars.QWEN_RELEASE_VITEST_RETRY || '2' }})非空且非off时追加--retry=N;测试'passes --retry unless the operator switched it off'特别验证off必须省略标志而不是传--retry=0(--retry=0会压过packages/sdk-typescript自身配置级重试)。--passWithNoTests的补充闸门:'discovers at least one test file in every test:ci workspace'在发布门禁 lane 中扫描每个test:ciworkspace 是否至少有一个测试文件,防止 shard 因零文件而静默全绿。Vitest 传输超时放行:这是脚本最精巧的部分。
[vitest-worker]: Timeout calling ...是 worker RPC 超时,--retry无法覆盖(重试只重跑失败的测试,未处理错误会直接让整个 run 失败),且与真实损坏日志无法从头部区分。放行条件是四重证据同时成立:- 退出码
< 128(不是被信号杀死); - 日志出现
Tests N passed总计; - 没有
Tests/Test Files N failed; awk求和的Errors N errors计数 ==[vitest-worker]: Timeout calling行数。
测试
'names which failure this is, and never changes the exit code'用 19 组 stub 日志枚举了"真实测试失败不注解""超时+完成放行""信号死亡""超时旁边还有真实错误""无 tally""失败 tally""测试自己打印的 Error: 行不干扰计数"等全部形状——计数不依赖头部模式,因为仓库 293 个 Error 子类里有 26 个不带 Error/Exception 后缀、4 个把裸类名赋给err.name,任何头部匹配模式在构造上都不完备。- 退出码
超时预算与上限均由 operator 变量(
vars)而非代码控制:QWEN_RELEASE_WORKSPACE_TIMEOUT_MINUTES || '45'、QWEN_RELEASE_STATIC_TIMEOUT_MINUTES || '60'、QWEN_RELEASE_BUILD_TIMEOUT_MINUTES || '45',且 timeout 表达式必须是fromJSON(...)(timeout-minutes需要数字,未设置变量要回退而非渲染空串)。每个可调 lane 还通过Report timeout budget步骤用与 timeout 完全相同的表达式上报解析到的预算,防止拼错变量名后"日志里看不出与默认值的区别"。
4.4 run-release-docker-integration.sh:Docker 沙箱的构建复用与容器回收
run-release-docker-integration.sh 处理integration_dockerlane:
- 以
git rev-parse HEAD派生沙箱镜像 tag(<sandboxImageUri>-release-<revision>),命中则复用,未命中才npm run build:sandbox构建; - 自托管环境用三个
flock锁协调共享 daemon:共享读锁防构建饥饿、构建协调锁、host build 互斥锁(锁描述符在子进程中显式关闭,避免存活更久的后代持锁); QWEN_SANDBOX=docker npx vitest run --root ./integration-tests cli|interactive直接复用已构建镜像(package.json 的 docker 测试脚本各自重建镜像,这里绕开以复用);trap cleanup_release_containers EXIT按org.qwen-code.ci.ownerlabel 回收本 job 的容器,cleanup子命令用于显式清场并失败即关。
五、YAML 与脚本的职责边界全景
把整个 release.yml 的 job 拓扑与脚本调用点对应起来,可以得到如下职责全景:
| Job | 职责 | 脚本调用(均走.release-workflow/) |
|---|---|---|
prepare | 解析元数据:commit、版本、标志 | set-flags、resolve-commit、resolve-version |
quality_static | lint 门禁 | (步骤较短,内联,但超时预算上报) |
quality_build | 构建 + 打包产物 | build-package、pack-build、verify-package |
quality_typecheck/workspace_tests/quality_scripts | 消费构建产物做类型检查、三 shard 测试、脚本自测 | run-release-workspace-tests.sh、build-package |
integration_none/integration_docker | 沙箱集成测试 | run-release-docker-integration.sh |
quality | fail-closed 聚合门 | 内联循环校验五个 result 环境变量 |
publish | 版本化、推送、打包、发布、Release 创建、通知 | prepare-release-branch、push-release-branch、build-archives、verify-archives、publish-packages、create-github-release、label-release-prs、dispatch-update |
notify_failure | 失败归档与 autofix 分发 | notify-failure(+ 内联 fallback issue 步骤) |
几个值得注意的编排细节(均有测试钉住):
- 构建一次、多处消费:
quality_build上传release-quality-buildartifact(overwrite: true、retention-days: 3),三个消费 job 都必须声明对quality_build的needs边,下载后经tar -xzf解包;'keeps the dist producer ahead of the pack step'钉死Check Serve Fast Path Bundle(物化 repo 根dist)必须先于Pack Build Outputs。 - 所有 job 的 checkout 都钉在 prepare 输出的
release_sha(${{ needs.prepare.outputs.release_sha }}),且必须声明needs: prepare——否则 ref 表达式解析为空串,checkout 静默回退到事件 ref 的移动分支尖。 - 发布质量聚合 fail-closed:
quality的if为${{ !cancelled() && needs.prepare.result == 'success' && github.event.inputs.force_skip_tests != 'true' }}——被操作者主动取消的运行保持 skipped,使notify_failure的needs.quality.result == 'failure'门保持关闭;force_skip_tests应急开关必须同时跳过五个组件 lane,否则红 lane 仍会运行并阻塞它本要解封的发布。 - 发布串行化:scheduled 发布共享
release-scheduled-validationconcurrency group,手动运行相互独立,避免同一 tag 并发发布。
六、回归测试的"钉死"哲学:为什么用整体相等与行为执行
release-workflow.test.js 共 2592 行,是这次重构能否长期成立的防线。其测试策略有三层递进:
- 文本结构钉死:解析 YAML 后断言步骤顺序、
with对象整体相等(如脚本 checkout 的ref/fetch-depth/sparse-checkout/path五元组)、以及受保护步骤之间无npm。设计文档说的"历史注释缩减为不显而易见不变量"正是落到这里。 - 整体相等防变异:工作区清理脚本用
toBe(canonicalWipe)常量整体相等,专门对抗"注释掉一行、插入提前退出、统一删掉阶梯"这类能骗过子串匹配的变异。 - 行为级执行:大量
spawnSync('bash', [...])直接以 stub 的node/npm/gh在临时目录执行真实脚本,验证set-flags对 cron 字符串的分类、publish-packages的通道循环与--tag=latest/--access public参数、resolve-version的--type=preview与--preview_version_override=1.2.3-preview.0推导、非法 preview 版本拒绝、notify-failure的 gh 不可达防重复归档、workspace 测试的重试标志传递与超时放行判定等。
测试还钉住了脚本的记录模式:git ls-tree HEAD .github/scripts/断言三个 runner 脚本的 Git 记录模式为100755(100644会在 bare-path 调用时以 exit 126 死于任何校验之前,而 PR lane 从不触发 schedule/dispatch 型工作流,模式无人兜底)。此外,测试还覆盖了npm依赖层面的发布安全:dist-tag必须显式传--tag=latest(否则夜间发布会在 21:00 UTC 抢占每个终端用户的默认 tag),--access public对全部十二个发布包一致生效。
七、可迁移的实践经验
- 工作流尺寸用"闸门 + 棘轮"双重治理:硬闸门(如 470 KB)防止撞上 GitHub 512 KB 的启动墙;基线文件(.size-baseline)把"增长额度 + 同 PR 更新基线"变成评审可见的一行,同时用"显著瘦身后回收基线"防止余量被悄悄攒起来。
- 凭据步骤的脚本必须来自可信源:凡是会接触 PAT/token/id-token 的步骤,其脚本一律从
github.workflow_sha隔离检出,操作者选择的 ref 只能提供"不持有凭据的代码"。 - 信任链上每次重置只作用于隔离路径:运行完 ref 代码后
rm -rf .release-workflow再重检出,绝不扩大范围到.husky或package.json。 - 安全关键的清理逻辑保持内联在正确时机:需要在任何 checkout 之前执行、且"依赖仓库脚本就等于先检出后清理"的步骤,宁可留在 YAML 里接受尺寸代价,也不反转其安全顺序。
- 长逻辑收口到单入口脚本:以
case "${step}"分发(run-release-step.sh),YAML 侧只保留一行调用与参数,配合set -eo pipefail让每个命令替换都 fail closed。 - 为"无法从头部识别的故障"设计计数型放行:当错误类型与真实故障在日志头部无法区分时,用"完成证据(tally)+ 计数相等"来放行,而不是模式匹配头部。
- 白名单与循环双源同步:发布通道列表同时存在于脚本循环与发布守卫集合中,用测试从两个源各自解析后比对,杜绝"加通道忘守卫"的静默漂移。
本次重构把"发布如何做"沉淀为仓库自有的可执行脚本与约 2592 行行为级测试,而 release.yml 回归为一份 827 行、34861 字节、职责单一的编排清单。对于同样在 GitHub Actions 上维护发布流水线的项目,这套"编排留 YAML、逻辑进脚本、凭据步骤隔离化、不变量用测试钉死"的组合,是一条可复制的治理路径。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考