get-shit-done(GSD)hotfix 自动发布管线:3621 如何修复 cherry-pick 丢失测试夹具导致的 CI 红灯
GSD(get-shit-done)的 release-sdk.yml 工作流提供了一条全自动 hotfix 路径:从最近的稳定 tag 切出 hotfix/X.YY.Z 分支,自动从 main cherry-pick 修复提交并发布到 npm。本文围绕 changeset #3621 展开,讲清这次修复解决的核心问题——为什么"生产修复被选中、配套测试夹具没被选中"会让 hotfix 分支 CI 直接变红——以及两半修复(commit 前缀过滤器加入 test:、shipped-paths 分类器新增 CI-gating 规则)如何在源码层面协同生效。读完本文,你能理解 GSD hotfix 管线中"三级筛选漏斗"的完整决策链,并能自行解读运行摘要中 Included / NON_SHIPPED_SKIPPED / CONFLICT_SKIPPED / POLICY_SKIPPED 四个分桶的含义。
背景:release-sdk hotfix 管线与"三级筛选漏斗"
在 release-sdk.yml 中,prepare job 的 "Prepare hotfix branch" 步骤实现了 hotfix 自动化。它的工作方式是:
- 从目标版本
X.YY.Z反推 base tag(用sort -V做语义化版本比较,避免v1.27.10与v1.27.9这类多位补丁号的字典序错排); - 用
git cherry HEAD origin/main找出尚未进入 base tag 的main提交,作为候选集; - 对每个候选提交依次过三道筛子,只有通过者才会真正
git cherry-pick。
这三道筛子就是理解 #3621 的关键:
| 筛子 | 位置 | 规则 | 落桶 |
|---|---|---|---|
| 前缀过滤器 | 工作流内联正则 | 提交标题匹配 ^(fix|chore|test)(\([^)]+\))?!?: |
不匹配 → POLICY_SKIPPED |
| shipped-paths 分类器 | scripts/diff-touches-shipped-paths.cjs | diff 路径与 package.json 的 files 白名单(含 package.json 本身)取交集 |
无交集 → NON_SHIPPED_SKIPPED |
| cherry-pick 冲突处理 | 工作流内 | 冲突则跳过并分类原因(context absent / merge conflict) | CONFLICT_SKIPPED |
其中 shipped-paths 分类器被独立成脚本而不是内联在 YAML 里,源码注释给出了明确理由:"rules … are unit-testable"(bug #2980)。它从 stdin 读入 git diff-tree --no-commit-id --name-only -r <SHA> 的路径列表,用退出码表达三种结果:
0— 至少一个路径是 shipped(对 npm 包行为有意义),应 cherry-pick;1— 没有任何 shipped 路径,hotfix 循环跳过该提交;2— 分类器自身出错(package.json 缺失、I/O 失败、未捕获异常),工作流必须 fail-fast,而不能当作跳过处理。
退出码 2 的区分是 bug #2983 的产物:Node 未捕获异常的默认退出码是 1,若不显式区分,工具故障会被误当成"无 shipped 路径"而静默跳过提交。
事故复盘:v1.42.3 hotfix 为什么 CI 变红
#3621 的直接起因是一次真实的生产事故。回归测试 tests/bug-3621-cherry-pick-test-fixtures.test.cjs 的文件头注释完整记录了故障形态(v1.42.3 hotfix,run 25949422676):
- 提交
36534059(fix(3562)前缀)被选中。它修改了安装器,使其在 Codex skills 目录下物化gsd-命名的SKILL.md文件——这是 docs/RELEASE-v1.42.3.md 中"routable skills"修复的核心代码; - 提交
08848df8(docs(3562)前缀)被前缀过滤器判为POLICY_SKIPPED。问题在于这个提交捆绑了配套的测试夹具修正——删除了tests/install-minimal-all-runtimes.test.cjs中一段if (runtime === 'codex') return new Set()的短路逻辑; - 结果:hotfix 分支同时具备"新的生产行为 + 过期的测试断言",3 个测试失败,CI 红灯。
这就是"捆绑失败模式"(bundling failure mode):生产修复与其测试对齐被拆在两个提交里,而 hotfix 过滤器只能看到提交标题,看不到语义上的依赖关系。测试文件头明确指出:只改其中一半,这个失败模式依然存在——必须两半修复同时落地。
修复上半:前缀过滤器放行 test: 提交
第一处改动在 release-sdk.yml 的候选循环中。修改后的正则(工作流内 "Prepare hotfix branch" 步骤):
if echo "$SUBJECT" | grep -qE '^(fix|chore|test)(\([^)]+\))?!?: '; then
变化点是把 test 加入前缀集合,使 test(3562): 这类夹具对齐提交能与 fix(3562): 生产修复一起进入候选集。正则保留了对可选 scope(如 (3562))和 breaking ! 标记的容忍,与原有 fix: / chore: 行为一致。
同时,这条正则刻意没有加入 feat: 或 docs:。回归测试对这两点做了负向断言:
fix|chore不能被加test:时"顺手"丢掉(must keep fix and chore as before);- 不能悄悄加入
feat:——特性提交不属于 hotfix 范畴; - 不能悄悄加入
docs:——纯文档提交仍应保持POLICY_SKIPPED。
值得注意的是,v1.42.3 事故中那条夹具提交的前缀是 docs(3562),而不是 test:。这意味着本次修复解决的是同类问题的未来形态:只要团队约定夹具对齐用 test: 前缀提交,管线就能把它和生产修复成对 pick 进来。changeset 原文也确认了这一点——"当生产修复捆绑在一个 fix: 提交里、而对应的测试夹具对齐落在另一个 test: 提交时,两者现在会被一起 pick"。
修复下半:shipped-paths 分类器新增 CI-gating 判定
仅放行 test: 提交还不够。一个只碰测试文件的 test: 提交,其 diff 路径全部落在 tests/ 下,而 tests/ 并不在 package.json 的 files 白名单里(当前白名单为 bin、commands、get-shit-done、agents、hooks、scripts、sdk/src、sdk/shared、sdk/prompts、sdk/dist 等)。如果分类器仍只认"shipped"语义,test: 提交会在 shipped-paths 这道筛子被二次拦截,问题依旧。
因此 scripts/diff-touches-shipped-paths.cjs 新增了 isCiGating 判定:
// #3621: paths that gate hotfix-branch CI even though they don't appear
// in the npm tarball.
function isCiGating(diffPath) {
if (diffPath.startsWith('tests/')) return true;
// SDK vitest specs live next to source. Production source ships via
// sdk/dist/ (already in package.json `files`); the test files are what's
// missing from that surface.
if (diffPath.startsWith('sdk/src/') && /\.(test|spec)\.(ts|cjs|mjs|js)$/.test(diffPath)) return true;
return false;
}
规则覆盖两类路径:
tests/<任意路径>— 仓库根部的 node:test 测试目录(含tests/helpers.cjs、tests/fixtures/**等夹具);sdk/src/<任意层级>/<名字>.test.<ts|cjs|mjs|js>及.spec.变体 — SDK 的 vitest 规格与源码同目录存放,生产代码经sdk/dist/发版,而测试文件不在 npm tarball 里,正是需要"补票"的那部分。
分类逻辑的判定顺序(main() 的 stdin end 回调)是刻意的:
// #2980 still wins over #3621: any commit touching .github/workflows/*
// is unpickable regardless of other content because the push step
// fails on workflow scope rejection. Check this first.
if (paths.some(isPushBlocking)) {
process.exit(EXIT_NOT_SHIPPED);
}
if (paths.some((p) => isShipped(p, shipPrefixes))) {
process.exit(EXIT_SHIPPED);
}
// #3621: a commit whose only relevant paths are CI-gating tests is
// still pickable — it can change whether the hotfix CI passes even
// though it doesn't change what the npm tarball ships.
if (paths.some(isCiGating)) {
process.exit(EXIT_SHIPPED);
}
process.exit(EXIT_NOT_SHIPPED);
语义上,tests/ 与 sdk/src/** 的测试规格现在被视为 CI-gating-equivalent(等价于 shipped):它们不进入 npm tarball,但决定 hotfix 分支测试 job 的生死。一个与已 cherry-pick 生产修复配套的夹具对齐提交必须可被 pick,否则 hotfix 运行必然失败——这正是 #3621 的头注释所总结的"根因"。
与 #2980 的优先级关系:workflow 文件仍是"一票否决"
#3621 扩展了可 pick 集合,但它不能也不得侵蚀 #2980 的 push-blocking 护栏。#2980 的背景是:默认 GITHUB_TOKEN 没有 workflow 权限,任何触碰 .github/workflows/* 的提交一旦被 pick 进 hotfix 分支,git push 步骤会被 GitHub 拒绝(v1.39.1 曾因此炸掉,run 25232010071)。
在分类器里,这条规则由 isPushBlocking 表达,且先于 isShipped 与 isCiGating 判定:
function isPushBlocking(diffPath) {
return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/');
}
也就是说,一个 fix(release-sdk): 提交即使同时修改了 workflow 文件和配套的回归测试(例如 tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs),在 #3621 之后,测试路径虽然单独满足 CI-gating 检查,整个提交仍会被跳过。回归测试专门锁定了这一不变式:
test('push-blocking guard wins: workflow + test bundle classifies as NOT_SHIPPED (preserves #2980)', () => {
// ... stdin: .github/workflows/release-sdk.yml + tests/bug-2980-*.test.cjs + CHANGELOG.md
assert.equal(result.status, 1,
'#2980 preservation: bundle with .github/workflows/* must skip regardless of test paths in the same commit');
});
这与 changeset 最后一句的声明完全对应:".github/workflows/<file> 的 push-blocking 护栏被保留(#2980):触碰 workflow 文件的 bundle 仍然整体跳过,不管其中还含有什么。"
回归测试:行为矩阵如何锁定修复
tests/bug-3621-cherry-pick-test-fixtures.test.cjs 从两个层面锁定这次修复:
层面一:工作流文本契约。 测试直接读取 release-sdk.yml 并提取含 grep -qE '^( 的正则行,断言其匹配 ^\(fix\|chore\|test\),同时负向断言不包含 feat| 与 docs|。测试文件里有一条自证注释值得留意:
// allow-test-rule: source-text-is-the-product
// release-sdk.yml IS the product for hotfix automation; this test reads
// the workflow's prefix-filter regex line directly because the regex IS
// the behavior contract — there is no runtime that consumes it.
对于 CI 自动化工作流而言,YAML 本身就是"产品",前缀正则就是行为契约——没有运行时消费它,因此对源码文本做断言是合理且必要的。
层面二:分类器行为矩阵。 通过 spawnSync 在临时目录(写入最小 package.json,files: ["bin", "sdk/dist"])中运行分类器,覆盖了完整的退出码矩阵:
| 输入 diff 路径 | 期望退出码 | 依据 |
|---|---|---|
tests/bug-foo.test.cjs、tests/helpers.cjs、tests/fixtures/... |
0 | tests/ 前缀即 CI-gating |
sdk/src/query/init.test.ts、init.spec.ts、golden.integration.test.ts |
0 | SDK vitest 规格即 CI-gating |
sdk/src/query/init.ts、sdk/src/config.ts |
1(isCiGating 层面为 false) |
非测试的 sdk/src 路径走 shipped 规则,本身不在最小 files 中 |
docs/test-strategy.md、bin/install-test-helper.js、docs/install.test.md |
false | 防止"路径名里含 test"的误报;.test.md 不是被识别的规格扩展名 |
纯 tests/install-minimal-all-runtimes.test.cjs(v1.42.3 情形) |
0 | 核心回归点 |
README.md + docs/CONFIGURATION.md + 两个 tests 文件(v1.42.3 提交形态) |
0 | 混合 diff 只要含一条 CI-gating 路径即通过 |
纯文档 diff(README.md + docs/CONFIGURATION.md) |
1 | 文档不是 CI-gating,保持 NOT_SHIPPED |
bin/install.js(正常 shipped 路径) |
0 | 修复前的行为不得回归 |
.github/workflows/release-sdk.yml |
1 | 工作流文件既非 shipped 也非 CI-gating |
| workflow + test + CHANGELOG 混合 | 1 | #2980 护栏优先 |
对操作者意味着什么
对触发 hotfix 发布的人而言,#3621 之后运行摘要(GITHUB_STEP_SUMMARY)中的分桶语义有一处实质性变化:"Skipped — touches no shipped paths (informational)" 一节现在明确写着:被跳过的 fix/chore/test 提交既未触碰 npm tarball 的 files 白名单(或 package.json),也未触碰 tests/ 或 sdk/src/** 下的 CI-gating 测试(#3621)。CI / 文档 / 规划类变更属于 main,不属于 hotfix——无需操作。
实践上的直接结论:
- 夹具对齐提交请使用
test:前缀(可带 scope,如test(3562):)。这是它能进入 hotfix 候选集的唯一入口;docs:/feat:前缀的夹具修正仍会被前缀过滤掉。 - 测试路径的落点有讲究:仓库根
tests/下任何路径,以及sdk/src/下符合.test./.spec.+ts|cjs|mjs|js后缀的文件都会被识别;其他位置(如sdk/prompts/、自定义目录)不享受 CI-gating 待遇。 - 不要把 workflow 文件改动混进修复提交。即便同提交里带了合法的生产路径或测试路径,
isPushBlocking的一票否决仍会让整个提交被跳过——这是 #2980 保留的护栏,#3621 明确不推翻它。
小结
#3621 是一次典型的"自动化管线语义对齐"修复:hotfix 自动 cherry-pick 的目标不只是"把能改 npm 包内容的提交 pick 进来",还包括"把能让 hotfix 分支 CI 通过的提交 pick 进来"。为此它改了两处且必须成对生效——release-sdk.yml 的前缀正则从 fix|chore 扩为 fix|chore|test,scripts/diff-touches-shipped-paths.cjs 的分类逻辑新增 isCiGating 判定(tests/** 与 sdk/src/** vitest 规格视为 CI-gating 等价路径);同时 isPushBlocking 对 .github/workflows/* 的一票否决保持最高优先级。两半修复的必要性由 tests/bug-3621-cherry-pick-test-fixtures.test.cjs 的完整行为矩阵锁定,与 tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs 共同构成 hotfix 管线的回归防线。这套"前缀过滤 → shipped/CI-gating 分类 → 冲突分桶"的三段式设计,对任何需要维护自动 hotfix 管线的 npm 项目都有直接参考价值。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00