fix(release): bump preview base past published stable - #7978
Conversation
When the nightly tag base (e.g. 0.21.0) is already published as stable, getPreviewVersion() now bumps the patch (→ 0.21.1-preview.0) instead of deriving a preview for the released version. This prevents the scheduled Tuesday preview release from hitting npm E403 on channel packages that were already published at that version. Fixes #7969
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
Review:
|
| input | value |
|---|---|
nightly dist-tag |
0.21.0-nightly.20260729.0c0ca5fed → base 0.21.0 |
latest dist-tag |
0.21.1 (published 2026-07-28 17:49Z) |
| result before this PR | 0.21.0-preview.0 → E403 on @qwen-code/channel-dingtalk |
| result with this PR | 0.21.2-preview.0 → free on all 10 published packages |
So the immediate bleeding stops. The semver.valid(baseVersion) guard, semver.inc, and the two new test cases all address the earlier bot review correctly, and the existing happy-path preview test (latest 0.6.1 < base 0.8.0) still pins the no-bump path. scripts/tests/get-release-version.test.js: 27/27 pass on the branch.
1. [Major] The collision check still only looks at one of the ten packages being published
The PR body says "channel packages don't go through that loop" — correct, and that is the actual bug, but this PR doesn't change it. doesVersionExist() (scripts/get-release-version.js:179) only queries @qwen-code/qwen-code, while release.yml publishes the same version to audio-capture, channel-base, and 7 more channel packages.
Current registry drift (versions that exist on a sibling package but not on @qwen-code/qwen-code):
audio-capture : —
channel-base : 0.21.1-preview.0
channel-dingtalk : 0.21.0-preview.1, 0.21.1-preview.0
channel-feishu : 0.21.1-preview.0
channel-github : 0.21.1-preview.0
channel-qqbot : 0.21.1-preview.0
channel-telegram : 0.21.1-preview.0
channel-wecom : 0.21.1-preview.0
channel-weixin : 0.21.1-preview.0
Two consequences worth noting:
(a) The worked example in the PR description is a version that is already taken. The body says 0.21.0 (stable published) → 0.21.1-preview.0. All eight channel packages already hold 0.21.1-preview.0 (published 2026-07-27 14:00Z). Had latest still been 0.21.0 on Wednesday, this patch would have produced exactly the same E403. It works today only because stable happened to advance to 0.21.1 first. That's a coincidence, not a guarantee.
(b) The fix is not idempotent under a partial publish. The publish job has no per-package skip and no rollback: if the loop lands N packages and then fails, re-running recomputes latestStable + 1 patch — the same version — because doesVersionExist can't see the packages that already got it. That is precisely how the current drift was created, and it will recur.
Also note the drift did not originate in release.yml: there is no release run between 2026-07-27 00:16Z and 2026-07-28 00:15Z, yet the channel packages were published at 13:32 / 13:48 / 14:00 on 07-27. Something outside the workflow (manual npm publish, most likely) put them ahead. A heuristic that derives the base from npm dist-tags cannot defend against that; only checking the versions that are actually about to be published can.
Suggested follow-up (either is sufficient, the second is cheaper):
// A) teach doesVersionExist about the full publish set
const PUBLISHED_PACKAGES = [
'@qwen-code/qwen-code', '@qwen-code/audio-capture', '@qwen-code/channel-base',
...['dingtalk','feishu','github','qqbot','telegram','wecom','weixin']
.map((c) => `@qwen-code/channel-${c}`),
];# B) make publish idempotent in release.yml — also makes a failed release re-runnable
PKG=$(node -p "require('./package.json').name")
if npm view "${PKG}@${VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PKG}@${VERSION} already published, skipping"
else
npm publish --access public --tag="${NPM_TAG}"
fi2. [Major] --type=patch --patch-from=preview is broken right now and untouched by this PR
getPatchVersion('preview') derives from the preview dist-tag, which is 0.21.0-preview.0; the highest preview on the main package is also 0.21.0-preview.0. So it computes 0.21.0-preview.1 → doesVersionExist says free (main lacks it) → publish → E403 on channel-dingtalk, which has held 0.21.0-preview.1 since 2026-07-27 13:48Z. Same root cause, different entry point — another reason to fix the check rather than the derivation.
3. [Minor] Uses the raw latest dist-tag, bypassing this file's own rollback handling
getVersionFromNPM('latest') at line 342 returns whatever the dist-tag points at. This file has detectRollbackAndGetBaseline() specifically because the dist-tag can sit below the highest published stable (rollback). In getStableVersion() the same raw call is only a defensive throw, so being conservative is fine there; here the value computes the release version. If latest were rolled back to 0.21.0 while 0.21.2 is published, this yields 0.21.1-preview.0 — retrograde, and the preview-number loop can never climb past 0.21.1.
const latestStable = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]')?.latestVersion;4. [Nit] Test placement and assertions
- The two new cases live in
describe('Happy Path - Version Calculation'), between the preview and nightly happy-path tests. They're recovery-path cases;describe('Advanced Scenarios')(which already hosts rollback/deprecation/E404 cases) is the better home. - Only
releaseVersionis asserted. The sibling tests in that block also pinnpmTagandpreviousReleaseTag; worth matching, especially since the--tag=latestoverride also changes whatgetLatestStableReleaseTag()returns. - No case pins the non-bump direction against a regression to
semver.lt— e.g.latestStable = '0.7.9', base0.8.0→0.8.0-preview.0. The existing happy-path test covers it incidentally with0.6.1, but an explicit boundary case next to the two bump cases would read better.
Minor observations
console.errorfor the diagnostic is right — stdout carries the JSON the workflow parses withjq.- The guard correctly no-ops on greenfield (
latestStable === ''from E404) and on thepreview_version_overridepath. - One knock-on to confirm intentional: since
package.jsononmainis bumped to the released version (0.21.1today),nightlybase ==latestStableon essentially every scheduled preview, so this guard will now fire nearly every week andstable + 1 patchbecomes the default preview base. That's fine while minor bumps land as manualchore/bump-version-*PRs (which is howpromoteNightlyVersion()'s minor bump is reached anyway) — just worth stating so nobody expects the automated path to advance the minor.
中文完整版
结论
改动本身正确,确实能修好 #7969 这一次的事故,但没有修掉这一类失败,而且 PR 描述里给出的机理并不是真正的成因。 不阻塞合入,但请先看第 1 条 —— 后续修复比这个补丁重要。
能修好 #7969 吗?—— 能,已验证
复原失败那次运行(2026-07-29 00:16Z)的输入:nightly dist-tag 为 0.21.0-nightly.20260729.0c0ca5fed(base 0.21.0),latest 为 0.21.1(07-28 17:49Z 发布)。改动前得到 0.21.0-preview.0 → channel-dingtalk E403;改动后得到 0.21.2-preview.0,在全部 10 个包上都未被占用。semver.valid(baseVersion) 守卫、semver.inc、两条新测试都正确回应了此前的 bot review;原有的 preview happy-path 测试(latest 0.6.1 < base 0.8.0)仍然钉住了「不 bump」分支。测试 27/27 通过。
1.【重要】冲突检测仍然只看 10 个包里的 1 个
PR 描述说「channel 包不走那个循环」——说得对,而这正是真正的 bug,但本 PR 并没有改它。doesVersionExist()(scripts/get-release-version.js:179)只查 @qwen-code/qwen-code,而 release.yml 会把同一版本发布到 audio-capture、channel-base 和另外 7 个 channel 包。
当前的漂移(存在于兄弟包、但主包没有的版本):channel-base/feishu/github/qqbot/telegram/wecom/weixin 各有 0.21.1-preview.0;channel-dingtalk 还多一个 0.21.0-preview.1。
两个后果:
(a) PR 描述里举的例子,恰好是一个已被占用的版本。 描述写的是 0.21.0(已发 stable)→ 0.21.1-preview.0,而这 8 个 channel 包在 2026-07-27 14:00Z 就已经有了 0.21.1-preview.0。如果周三时 latest 仍停在 0.21.0,这个补丁会产生完全相同的 E403。它今天能work,只是因为 stable 恰好先推进到了 0.21.1 —— 这是巧合,不是保证。
(b) 在部分发布失败的场景下不幂等。 publish job 既没有逐包跳过也没有回滚:如果循环发布了 N 个包后失败,重跑会重新算出 latestStable + 1 patch —— 同一个版本,因为 doesVersionExist 看不到已经拿到该版本的那些包。当前的漂移正是这样造成的,而且还会复发。
另外需要指出:漂移并非源自 release.yml —— 2026-07-27 00:16Z 到 07-28 00:15Z 之间没有任何 release 运行,但这些 channel 包却在 07-27 的 13:32 / 13:48 / 14:00 被发布。是工作流之外的东西(大概率是手动 npm publish)让它们跑到了前面。靠 npm dist-tag 推导 base 的启发式挡不住这种情况,只有检查「真正即将被发布的那些版本」才行。
建议的后续修复(二选一即可,第二个更省事):把 doesVersionExist 扩展到完整发布清单;或者在 release.yml 里让 publish 幂等(已存在则跳过),顺带让失败的 release 可以直接重跑。
2.【重要】--type=patch --patch-from=preview 现在就是坏的,本 PR 未触及
getPatchVersion('preview') 从 preview dist-tag 推导,当前是 0.21.0-preview.0;主包上最高的 preview 也是它。于是算出 0.21.0-preview.1 → doesVersionExist 认为可用(主包确实没有)→ 发布 → channel-dingtalk E403,该版本自 2026-07-27 13:48Z 起就存在了。同一个根因,不同入口 —— 这也是应该修检测而不是修推导的理由。
3.【次要】用了裸的 latest dist-tag,绕过了本文件自己的 rollback 处理
第 342 行的 getVersionFromNPM('latest') 拿到的就是 dist-tag 指向的值。本文件里存在 detectRollbackAndGetBaseline(),正是因为 dist-tag 可能落在最高已发布 stable 的下方(回滚)。在 getStableVersion() 里同样的裸调用只用于防御性 throw,保守是合适的;而这里这个值是用来计算发布版本的。如果 latest 被回滚到 0.21.0 而 0.21.2 已发布,就会得到 0.21.1-preview.0 —— 倒退,而且 preview 序号循环永远爬不过 0.21.1。建议改用 getAndVerifyTags('latest', ...)?.latestVersion。
4.【吹毛求疵】测试的位置与断言
- 两条新用例放在了
describe('Happy Path - Version Calculation')里,夹在 preview 和 nightly 的 happy-path 之间。它们属于恢复路径,放进已经承载 rollback / deprecation / E404 的describe('Advanced Scenarios')更合适。 - 只断言了
releaseVersion。同一块里的邻居测试还会钉npmTag和previousReleaseTag;建议对齐 —— 尤其因为--tag=latest的 mock 覆盖同时改变了getLatestStableReleaseTag()的返回。 - 没有用例钉住「不 bump」方向以防退化成
semver.lt,例如latestStable = '0.7.9'、base0.8.0→0.8.0-preview.0。现有 happy-path 用0.6.1顺带覆盖了,但在两条 bump 用例旁边显式加一条边界用例可读性更好。
其他小观察
- 诊断信息用
console.error是对的 —— stdout 承载着工作流用jq解析的 JSON。 - 该守卫在全新场景(E404 导致
latestStable === '')和preview_version_override路径上都正确地不生效。 - 有一个连带效果请确认是否是有意为之:由于
main上的package.json会被 bump 到已发布版本(今天是0.21.1),几乎每次定时 preview 都满足nightlybase ==latestStable,因此这个守卫今后基本每周都会触发,stable + 1 patch会成为 preview 的默认 base。只要 minor 的抬升仍以手工chore/bump-version-*PR 落地(promoteNightlyVersion()的 minor bump 本来也是这么被触达的),这没问题 —— 只是说清楚,免得有人期待自动路径会推进 minor。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.8-max-preview via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
— qwen3.8-max-preview via Qwen Code /review
|
Resolved the release collision issues raised in review:
Pushed in |
Runtime verification of
|
| real run 30410661540 | local replay on main |
|
|---|---|---|
Publish @qwen-code/audio-capture |
success | success |
Publish @qwen-code/qwen-code |
success | success |
Publish @qwen-code/channel-base |
success | success |
| Publish remaining channel packages | failure | failure — E403 … 0.21.0-preview.0 on channel-dingtalk |
| derived release tag | v0.21.0-preview.0 |
0.21.0-preview.0 |
Step-level conclusions and the derived tag match the real run exactly, so the A/B below is measuring the PR and not the harness.
1. The publish guard works, and it fixes the failure class — verified
Same registry, same version, only the branch differs:
main |
this PR | |
|---|---|---|
| packages published | 3 | 3 |
E403 |
1 (channel-dingtalk) |
0 |
| channels reached | 1 of 7 | 7 of 7 |
| job | RED | GREEN |
Things I specifically tried to break, all held:
exit 0inside the per-channel( … )subshell does not abort the loop. All seven channels are visited afterdingtalkis skipped. This is the load-bearing detail of the whole step and it is correct.- No false skips. With a genuinely free version (
0.21.2-preview.0), both arms publish all 10 packages. - Dry-run is untouched.
IS_DRY_RUNis checked before thenpm viewprobe, so--dry-runstill packs every package even when the version exists. Identical on both arms. - The
npm viewpremise is sound. Against the real registry,npm view <pkg>@<exact-version> versionexits0iff that exact version is published, and1for a missing version, a missing package, and a non-matching range. Any other failure (network, auth) also exits non-zero → the guard falls through tonpm publishand you get the oldE403, which is the safe direction. - Re-running a half-finished release now works. Against today's registry, replaying the failed run on
maindies immediately withE403onaudio-capture; on this branch every package is skipped and the job is green.
This is the part that actually addresses finding 1 from my earlier review, and it also de-fangs finding 2 — --type=patch --patch-from=preview still derives 0.21.0-preview.1 (taken on channel-dingtalk since 07-27), but with this guard that run publishes the other 9 packages and goes green instead of failing.
2. [Blocker] The divergent-baseline throw blocks the release it is meant to fix
Driving the real scripts/get-release-version.js against the pre-incident registry:
arm=before releaseVersion = 0.21.0-preview.0
arm=after Error: Nightly base 0.21.0 is lower than published latest 0.21.1.
Refusing divergent preview baseline.
On the exact inputs of #7969, this branch does not produce a bumped version — it exits 1 in the prepare job. The incident becomes a hard failure one job earlier. The PR description still says the outcome is 0.21.0 → 0.21.1-preview.0; with the code as it now stands, neither #7969's inputs nor the description's own example reach that path.
This is not a rare corner. getNightlyVersion() derives from package.json, so the nightly base only catches up to a freshly released stable at the next nightly run — and the preview cron (59 23 * * 2) fires one minute before the nightly cron (0 0 * * *). Any stable patch released on a Tuesday puts the Tuesday preview into exactly this state. That is what happened on 2026-07-28: 0.21.1 published 17:49Z, preview ran at 23:59Z.
I replayed every scheduled preview slot of the last 12 months, reconstructing the two dist-tags at each slot from real npmjs publish timestamps, and spot-checked four of them through the real script:
| outcome under the new guard | slots |
|---|---|
THROW — release blocked |
13 |
bump — guard fires, release proceeds |
24 |
| no-op — unchanged | 15 |
All 13 of the blocked slots actually produced a preview release, published to every package that existed at the time. Twelve completed cleanly; the thirteenth is #7969 itself. Five of the last seven weeks are in that column. So the guard would convert roughly a quarter of scheduled previews — including ones that have been working fine for months — into hard failures, with no human in the loop and no preview_version_override on a cron.
The eq branch already knows the right answer. Folding gt into it fixes the incident and enforces the same "preview must not sit below stable" policy the throw was reaching for:
- if (semver.gt(latestStable, baseVersion)) {
- throw new Error(
- `Nightly base ${baseVersion} is lower than published latest ${latestStable}. Refusing divergent preview baseline.`,
- );
- }
- if (semver.eq(latestStable, baseVersion)) {
+ if (semver.gte(latestStable, baseVersion)) {
const bumped = semver.inc(latestStable, 'patch');
console.error(
- `Nightly base ${baseVersion} already released as stable (${latestStable}); bumping preview base to ${bumped}.`,
+ `Nightly base ${baseVersion} is at or below published latest ${latestStable}; bumping preview base to ${bumped}.`,
);
baseVersion = bumped;
}Verified against the same slots:
| slot | as shipped | with gte |
|---|---|---|
| 2026-02-17 | THROW | 0.10.4-preview.1 |
| 2026-04-07 | THROW | 0.14.2-preview.0 |
| 2026-06-23 | THROW | 0.19.2-preview.1 |
| 2026-07-14 | THROW | 0.19.11-preview.0 |
| 2026-07-21 | THROW | 0.20.2-preview.0 |
| 2026-07-28 (#7969) | THROW | 0.21.2-preview.0 — free on 10/10 packages |
One test needs updating with it — should reject the preview when the latest stable is ahead becomes a bump assertion. With that change the suite is 28/28 green:
it('should bump past the latest stable when it is ahead of the nightly base', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.9.0';
return mockExecSync(command);
});
const result = getVersion({ type: 'preview' });
expect(result.releaseVersion).toBe('0.9.1-preview.0');
});3. Test teeth — script side solid, workflow side is text-only
I mutated the production files and re-ran the PR's own suites (43 tests):
| mutant | suite | verdict |
|---|---|---|
semver.gt → semver.gte |
1 failed | killed |
drop the eq bump |
1 failed | killed |
drop the gt throw |
1 failed | killed |
semver.inc 'patch' → 'minor' |
1 failed | killed |
release.yml: unwrap the per-channel subshell |
43 passed | survived |
The surviving mutant is a real regression. Remove the ( … ) and exit 0 terminates the whole step at the first already-published channel — the YAML still contains every token package-scripts.test.js asserts. Publishing 0.21.0-preview.1 (held by channel-dingtalk, free on the other six):
- as shipped: 9 packages published, job green
- subshell unwrapped: 3 packages published, job green, 43/43 still passing
Both green, both silent. Worth either asserting the wrapper explicitly, or adding one behavioural test that runs the loop body with a stubbed npm.
Minor
A fully-skipped publish job is indistinguishable from a successful one. Re-running against today's registry, all 10 packages skip, newly-published=0, job green — and the job then proceeds to Create GitHub Release and Tag, tagging a fresh commit for a version whose tarballs came from an earlier run. Counting skips and failing (or at least ::warning::) when every package was skipped would make that visible:
[[ "${PUBLISHED_COUNT}" -eq 0 ]] && echo "::warning::every package was already published; nothing shipped"Guard version vs. on-disk version. PACKAGE_NAME is read from ./package.json but the version comes from RELEASE_VERSION. They agree today because npm run release:version bumps every workspace, but reading both from package.json would keep the probe and the publish permanently in sync.
Still open from my earlier review (not blocking, and much less severe now that publishing is idempotent): doesVersionExist() still only queries @qwen-code/qwen-code while 10 packages are published, and getVersionFromNPM('latest') still reads the raw dist-tag rather than going through detectRollbackAndGetBaseline().
Also checked, no issues
scripts/tests/get-release-version.test.js+scripts/tests/package-scripts.test.js: 43/43 pass on the branch.node scripts/lint.js --actionlint --shellcheck --yamllint: exit 0, no findings onrelease.yml.- No collateral on the other release types —
--type=nightly,promote-nightly,stable,patch --patch-from=stable,patch --patch-from=previewand--preview_version_overrideare byte-identical between the two arms. Only--type=previewchanges. - The guard correctly no-ops on greenfield (
E404→ emptylatestStable) and on the override path.
中文完整版
PR 7978 本地真实环境验证报告
这是对我之前那次 review 的跟进。我在本地重建了发布链路,用真实的 npm 客户端对着一个按 npmjs.org 快照播种的本地 registry(10 个已发布包的全部版本与发布时间戳)跑了本 PR 的两个部分。release.yml 里那四个 publish step 是逐字从 YAML 中取出的 —— ${{ }} 用真实 needs 上下文求值,然后交给 bash --noprofile --norc -e -o pipefail,与 runner 的行为一致。没有任何改写或转述。
结论:release.yml 这一半是正确的,我认为可以直接合入;get-release-version.js 这一半存在一个阻塞性回归 —— 新增的 throw 分支会挡住它本要修复的那次发布。 一行改动即可解决,文末附已验证的补丁。
工装保真度校验
在信任任何 A/B 之前,我先把 registry 回卷到 run 30410661540 读取 dist-tag 的那一刻(2026-07-29T00:52Z,当晚 nightly 于 00:51Z 落地之后、preview 于 00:56Z 发布之前),然后在 main 上重放 publish job:
| 真实 run 30410661540 | 本地重放(main) |
|
|---|---|---|
Publish @qwen-code/audio-capture |
success | success |
Publish @qwen-code/qwen-code |
success | success |
Publish @qwen-code/channel-base |
success | success |
| Publish remaining channel packages | failure | failure —— channel-dingtalk 上 E403 … 0.21.0-preview.0 |
| 推导出的 release tag | v0.21.0-preview.0 |
0.21.0-preview.0 |
step 级别的结论和推导出的 tag 与真实运行完全一致,因此下面的 A/B 衡量的是 PR 本身,而不是工装。
1. publish 守卫有效,并且修掉了这一类失败 —— 已验证
同一个 registry、同一个版本,只有分支不同:
main |
本 PR | |
|---|---|---|
| 实际发布的包 | 3 | 3 |
E403 |
1(channel-dingtalk) |
0 |
| 走到的 channel | 7 个里的 1 个 | 7 个里的 7 个 |
| job | RED | GREEN |
我专门尝试攻破的几点,全部成立:
- 每个 channel 外面那层
( … )子 shell 里的exit 0不会中断循环。 跳过dingtalk之后,7 个 channel 全部被访问。这是整个 step 的关键细节,写对了。 - 不会误跳过。 用一个确实空闲的版本(
0.21.2-preview.0),两个 arm 都发布了全部 10 个包。 - dry-run 未受影响。
IS_DRY_RUN在npm view探测之前判断,所以即便版本已存在,--dry-run仍然会打包每一个包。两个 arm 表现一致。 npm view这个前提是成立的。 对真实 registry,npm view <包>@<精确版本> version当且仅当该精确版本已发布时退出0;版本不存在、包不存在、range 无匹配都退出1。其他失败(网络、鉴权)同样非零 → 守卫会落到npm publish,拿回原来的E403,这是安全的方向。- 重跑一次做了一半的发布现在可以了。 对着今天的 registry 重放那次失败的运行:
main上在audio-capture就立刻E403;本分支则每个包都被跳过,job 变绿。
这才是真正回应我上次 review 第 1 条的部分,同时也化解了第 2 条 —— --type=patch --patch-from=preview 仍会推导出 0.21.0-preview.1(自 07-27 起被 channel-dingtalk 占用),但有了这个守卫,那次运行会发布其余 9 个包并变绿,而不是失败。
2.【阻塞】divergent-baseline 的 throw 挡住了它本要修复的那次发布
用真实的 scripts/get-release-version.js 跑事故前的 registry:
arm=before releaseVersion = 0.21.0-preview.0
arm=after Error: Nightly base 0.21.0 is lower than published latest 0.21.1.
Refusing divergent preview baseline.
在 #7969 的确切输入下,本分支并不会产出一个抬升后的版本 —— 它在 prepare job 里以 1 退出。事故只是提前一个 job 变成硬失败。PR 描述里仍然写着结果是 0.21.0 → 0.21.1-preview.0;而按现在的代码,无论 #7969 的输入还是描述自己举的例子,都走不到那条路径。
这并不是罕见角落。getNightlyVersion() 从 package.json 推导,所以 nightly base 要到下一次 nightly 运行才会追上刚发布的 stable —— 而 preview 的 cron(59 23 * * 2)恰好比 nightly 的 cron(0 0 * * *)早一分钟触发。任何在周二发布的 stable patch,都会把当天的 preview 推进这个状态。2026-07-28 正是如此:0.21.1 在 17:49Z 发布,preview 在 23:59Z 运行。
我重放了过去 12 个月的每一个 preview 定时槽位,用真实的 npmjs 发布时间戳重建每个槽位上的两个 dist-tag,并抽取其中 4 个用真实脚本做了交叉验证:
| 新守卫下的结果 | 槽位数 |
|---|---|
THROW —— 发布被挡 |
13 |
bump —— 守卫生效,发布继续 |
24 |
| 无操作 —— 不变 | 15 |
这 13 个被挡的槽位,实际上全都产出了 preview 发布,并且发布到了当时存在的每一个包。其中 12 次干净完成,第 13 次就是 #7969 本身。最近 7 周里有 5 周落在这一列。也就是说,这个守卫会把大约四分之一的定时 preview —— 包括那些几个月来一直正常工作的 —— 变成硬失败,而且是无人值守的 cron,拿不到 preview_version_override。
eq 分支其实已经知道正确答案。把 gt 并进去,既修好事故,也贯彻了 throw 想要表达的那条「preview 不得低于 stable」策略:
- if (semver.gt(latestStable, baseVersion)) {
- throw new Error(
- `Nightly base ${baseVersion} is lower than published latest ${latestStable}. Refusing divergent preview baseline.`,
- );
- }
- if (semver.eq(latestStable, baseVersion)) {
+ if (semver.gte(latestStable, baseVersion)) {
const bumped = semver.inc(latestStable, 'patch');
console.error(
- `Nightly base ${baseVersion} already released as stable (${latestStable}); bumping preview base to ${bumped}.`,
+ `Nightly base ${baseVersion} is at or below published latest ${latestStable}; bumping preview base to ${bumped}.`,
);
baseVersion = bumped;
}对同样的槽位验证:
| 槽位 | 当前实现 | 改用 gte |
|---|---|---|
| 2026-02-17 | THROW | 0.10.4-preview.1 |
| 2026-04-07 | THROW | 0.14.2-preview.0 |
| 2026-06-23 | THROW | 0.19.2-preview.1 |
| 2026-07-14 | THROW | 0.19.11-preview.0 |
| 2026-07-21 | THROW | 0.20.2-preview.0 |
| 2026-07-28(#7969) | THROW | 0.21.2-preview.0 —— 在 10/10 个包上都空闲 |
需要同步改一条测试 —— should reject the preview when the latest stable is ahead 改成断言 bump。改完之后测试 28/28 通过:
it('should bump past the latest stable when it is ahead of the nightly base', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.9.0';
return mockExecSync(command);
});
const result = getVersion({ type: 'preview' });
expect(result.releaseVersion).toBe('0.9.1-preview.0');
});3. 测试的「牙齿」—— 脚本侧扎实,workflow 侧只是文本匹配
我对生产文件做了变异,然后重跑 PR 自己的测试套件(43 条):
| 变异 | 套件 | 结论 |
|---|---|---|
semver.gt → semver.gte |
1 failed | 被杀死 |
去掉 eq 的 bump |
1 failed | 被杀死 |
去掉 gt 的 throw |
1 failed | 被杀死 |
semver.inc 'patch' → 'minor' |
1 failed | 被杀死 |
release.yml:拆掉每个 channel 的子 shell |
43 passed | 存活 |
这个存活的变异是真实的回归。去掉 ( … ) 之后,exit 0 会在第一个「已发布」的 channel 处终止整个 step —— 而 YAML 里 package-scripts.test.js 断言的每一个 token 都还在。以发布 0.21.0-preview.1 为例(channel-dingtalk 已占用,其余 6 个空闲):
- 当前实现:发布 9 个包,job 绿
- 拆掉子 shell:发布 3 个包,job 绿,43/43 仍然通过
两者都绿、都无声。建议要么显式断言那层括号,要么补一条行为测试,用打桩的 npm 真正跑一遍循环体。
次要问题
整体被全部跳过的 publish job,和一次成功的发布无法区分。 对着今天的 registry 重跑,10 个包全部跳过,newly-published=0,job 绿 —— 随后 job 会继续执行 Create GitHub Release and Tag,为一个 tarball 来自更早那次运行的版本,给一个全新的 commit 打上 tag。统计跳过数量,并在所有包都被跳过时失败(或至少 ::warning::),可以让这件事可见:
[[ "${PUBLISHED_COUNT}" -eq 0 ]] && echo "::warning::every package was already published; nothing shipped"守卫用的版本 vs 磁盘上的版本。 PACKAGE_NAME 从 ./package.json 读,版本却来自 RELEASE_VERSION。今天两者一致,因为 npm run release:version 会 bump 每一个 workspace;但两者都从 package.json 读,可以让探测与发布永久同步。
上次 review 仍未处理的(不阻塞,而且在发布已幂等之后严重性大幅下降):doesVersionExist() 仍然只查 @qwen-code/qwen-code,而实际会发布 10 个包;getVersionFromNPM('latest') 仍然读裸的 dist-tag,没有走 detectRollbackAndGetBaseline()。
其他已检查、无问题的项
scripts/tests/get-release-version.test.js+scripts/tests/package-scripts.test.js:分支上 43/43 通过。node scripts/lint.js --actionlint --shellcheck --yamllint:退出码 0,release.yml上无任何发现。- 对其他发布类型没有连带影响 ——
--type=nightly、promote-nightly、stable、patch --patch-from=stable、patch --patch-from=preview以及--preview_version_override在两个 arm 上逐字一致。只有--type=preview发生了变化。 - 该守卫在全新场景(
E404→latestStable为空)和 override 路径上都正确地不生效。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
.github/workflows/release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
— qwen3.8-max-preview via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
— qwen3.8-max-preview via Qwen Code /review
|
Addressed the preview derivation blocker by bumping when the latest stable version is greater than or equal to the nightly base. Verified the release-version and package-script tests locally. I left full multi-package publish idempotency as follow-up scope. |
- Expand doesVersionExist to check all 10 published packages instead of only @qwen-code/qwen-code, so the auto-increment loop detects versions taken on sibling channel packages. - Use the rollback-aware getAndVerifyTags lookup for the latest stable instead of the raw dist-tag, preventing a retrograde preview base when the dist-tag has been rolled back. - Emit ::warning:: in the channel publish loop when every package was already published, making a fully-skipped release visible. - Move preview stable-guard tests to Advanced Scenarios, add npmTag and previousReleaseTag assertions, add a non-bump boundary case, and assert the subshell wrapper and all-skipped warning in the workflow test.
Review round 3 — verification of the follow-up commits (
|
| output | outcome | |
|---|---|---|
main |
0.21.0-preview.1 |
E403 — @qwen-code/channel-dingtalk@0.21.0-preview.1 already exists (published 2026-07-27T13:48) |
| PR HEAD | 0.21.2-preview.0 |
free on all 10 packages |
The actual root cause of #7969, from npm publish timestamps:
0.21.0-preview.0 published on 10/10 packages:
qwen-code, audio-capture, channel-base ....... 2026-07-29T00:56 (the release run)
channel-dingtalk ............................. 2026-07-27T13:32 ← out-of-band
channel-{feishu,github,qqbot,telegram,...} ... 2026-07-27T13:39-13:40
So the channel packages were published out-of-band two days early, and doesVersionExist — which consulted only @qwen-code/qwen-code — was blind to it. The PUBLISHED_PACKAGES expansion in b94b03b2f is the direct fix, not the base bump. Isolated A/B via --preview_version_override=0.21.0-preview.1:
main → 0.21.0-preview.1 (blind; would E403 on dingtalk)
PR HEAD → Version 0.21.0-preview.1 already exists on NPM (@qwen-code/channel-dingtalk).
0.21.0-preview.2
2. Historical replay — the previous blocking finding is fixed, with no regression
I reconstructed all 60 historical preview slots from real publish timestamps across all 10 packages (nightly base and stable baseline as of each slot; same-run publishes excluded via a 15-min window) and ran both derivations:
| metric | main logic |
PR HEAD |
|---|---|---|
| outputs colliding with an already-published version | 1 (2026-07-29 = #7969) | 0 |
| hard failures / throws | 0 | 0 |
| outputs below the published stable | 44 / 60 | 0 |
| slots where output differs | — | 44 |
The semver.gt(...) → throw that I flagged last round is gone; semver.gte + bump never fails, and 0/60 slots hard-fail. That was the blocker — it's resolved.
The 44 → 0 row is the systemic win: the preview dist-tag has historically pointed below latest on ~73% of slots. This PR ends that.
On patch-vs-minor: I was going to ask whether the bump should be minor to match promoteNightlyVersion's minor + 1. The release history answers it — the cadence is patch-dominant (0.19.0 → 0.19.1 → … → 0.19.12 → 0.20.0), so semver.inc(latestStable, 'patch') is the right choice. No change requested.
3. release.yml — all paths exercised, behaviour is correct
Steps run verbatim with a stubbed npm (view → hit/miss, publish → log/fail):
| scenario | result |
|---|---|
| A fresh version, nothing published | all 7 channels publish, no warning, rc=0 |
| B all 7 already published | all skip, ::warning::…nothing shipped fires, rc=0 |
| C dingtalk published, rest fresh (the #7969 shape) | dingtalk skipped, other 6 publish, rc=0 |
| D dry run, everything "published" | npm view never consulted, all 7 run --dry-run, rc=0 |
| E genuine publish failure mid-loop | loop aborts, rc=1 — real failures still fail |
| F single-package step, already published | skipped, rc=0 |
| G single-package step, fresh | publishes, rc=0 |
Scenario C is the one that matters, and the subshell makes it work. Scenario E confirms the guard doesn't swallow genuine errors.
4. My previous "test survives the mutation" finding is also fixed
Last round the suite stayed green when the channel subshell was removed. It no longer does — expect(channelStep).toContain('(\n') catches it:
FAIL scripts/tests/package-scripts.test.js:486
expect(channelStep).toContain('(\n')
And the mutation it guards is genuinely destructive — replacing ( … ) with { … } makes exit 0 kill the whole step:
::notice::@qwen-code/channel-dingtalk@0.21.0-preview.1 already published; skipping
step_rc=0 ← 6 channel packages never published, step green
Good assertion. (expect(channelStep).toContain(')') on the next line is vacuous — it matches $(node -p …). Harmless, but it isn't carrying weight.)
Finding — non-blocking: transient registry error now hard-crashes the preview release
getPreviewVersion calls getAndVerifyTags('latest', …) unguarded. detectRollbackAndGetBaseline re-throws any non-E404 error, so a transient registry hiccup on the latest lookup now aborts the whole release. getLatestStableReleaseTag() — which makes the identical call for previousReleaseTag — already wraps it in try/catch, so the two call sites disagree.
Reproduced by stubbing npm view --tag=latest / versions --json to exit non-zero with ECONNRESET:
main → {"releaseVersion": "0.21.0-preview.1", "previousReleaseTag": ""} rc=0 (degrades)
PR HEAD → Error: Command failed … npm error code ECONNRESET rc=1 (crashes)
This is a new failure surface — pre-PR getPreviewVersion never touched latest at all. It's unattended cron, so a crash means a missed Tuesday preview.
The one-line fix reuses the already-guarded helper:
- const latestTagResult = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]');
- const latestStable = latestTagResult?.latestVersion ?? '';
+ const latestStable = getLatestStableReleaseTag().replace(/^v/, '');It keeps the rollback-aware semantics (getLatestStableReleaseTag → getAndVerifyTags('latest') → same baseline). Verified:
- normal run:
0.21.2-preview.0— unchanged - ECONNRESET:
0.21.0-preview.2, rc=0 — degrades to "no bump", and thedoesVersionExistnet still routes around dingtalk 29/29tests still pass
Nits
- Duplicate
latestlookup.getAndVerifyTags('latest')now runs twice per preview run (here +getLatestStableReleaseTag). Measured npm round-trips: 8 → 19, wall clock 4.5s → 16.2s. Three of the extra calls are pure duplication (--tag=latest×2,versions --json×3,@0.21.1 deprecated×2). Not a problem for a release job, but memoizing thelatestbaseline removes it for free — and the fix above is a natural place to do it. - No drift guard on the two allowlists.
PUBLISHED_PACKAGES(JS) andfor channel in dingtalk feishu …(YAML) are independent sources of truth. They agree today — I cross-checked, 10 vs 10, no diff either way — but a new channel workspace silently breaks the version check. A ~10-line test parsing both and diffing the sets would lock it in. - Skip visibility is asymmetric. The channel loop emits
::warning::…nothing shipped, but the three single-package steps only emit::notice::. A skipped@qwen-code/qwen-codestill letsCreate GitHub Release and Tagrun, so a release could be tagged and announced having published nothing. In practice the version derivation makes this near-unreachable (the version is always free), and the reachable case — re-running only the failedpublishjob, whereprepareoutputs are reused — is exactly the intended idempotency. Still,::warning::would cost nothing on those three. PUBLISH_MARKER="$(mktemp)"is never removed. Ephemeral runner, so cosmetic.yiliang114's comment says multi-package publish idempotency was "left as follow-up scope", butb94b03b2fdoes implement it (all 10 packages). The comment is just stale.
Checks
eslint clean · prettier --check clean · scripts/tests/get-release-version.test.js + package-scripts.test.js 44/44 pass · not superseded (main hasn't touched these files since the merge-base).
Recommendation: merge. It fixes a breakage that is live on main right now, the historical replay shows 0 regressions over 60 slots, and both prior blockers are closed. I'd fold in the getLatestStableReleaseTag() one-liner before merging since it's ~30 seconds and removes a crash path from an unattended cron — but it isn't worth holding the release train for.
|
@ruoyu0214 please review this pr ! |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
— qwen3.8-max-preview via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.8-max-preview via Qwen Code /review
Round 4 — local verification at HEAD
|
| output | free on | |
|---|---|---|
main |
0.21.0-preview.1 |
9 of 10 — @qwen-code/channel-dingtalk has held it since 2026-07-27T13:48 → E403 |
| this PR | 0.21.2-preview.0 |
10 of 10 |
Note #7969 is already closed — by #7970, which fixed the release-notes symptom of that run. The E403 cause was never fixed; this PR is the fix.
2. release.yml publish job — full scenario A/B
Harness fidelity first. Scenario C on the main arm reproduces run 30410661540 step for step — audio-capture, qwen-code, channel-base succeed, Publish remaining channel packages fails — which I re-checked against the run's own job API rather than trusting my earlier round. So the A/B measures the PR, not the harness.
| scenario | main |
this PR |
|---|---|---|
| A nothing published (normal release) | 10/10, green | 10/10, green — no false skips |
| C dingtalk holds the version (the #7969 shape) | 3/10, red | 9/10, green |
| F re-run of a partial release (first 3 landed) | 0/10, red, 3 steps never reached | 7/10, green |
| B every package already published | 0/10, red | 0/10, green + ::warning::…nothing shipped |
| D dry run, everything already published | 10/10 packed | 10/10 packed — npm view consulted 0× |
| E genuine publish failure mid-loop | red | red — the guard does not swallow real errors |
The exit 0 inside the per-channel ( … ) subshell is the load-bearing detail and it is correct: scenario C visits all seven channels after skipping dingtalk.
npm view premise re-confirmed against the live registry: npm view <pkg>@<exact-version> version exits 0 iff that exact version is published, and 1 for a missing version, a missing package, and an unpublished package. Any other failure (network, auth) is also non-zero → the guard falls through to npm publish and you get the old E403. Safe direction.
3. Test teeth — 11 of 13 mutants killed, and the new commit is what kills the round-2 survivor
Every mutant is proved to have landed (git diff --numstat, right column of the shot).
The mutant that survived in round 2 — reverting doesVersionExist to the main-package-only check — is now killed, and it is 210cb098 specifically that kills it:
with 210cb098 present × should auto-increment when the version exists only on a channel package
Tests 1 failed | 29 passed (30) → KILLED
with that one it() removed Tests 29 passed (29) → SURVIVED
Those 16 lines are load-bearing, not decoration. That closes the last open bot suggestion.
One survivor is a real regression (non-blocking, test-coverage only): moving the dry-run branch from elif to a separate if keeps all 45 tests green, but a dry-run release then packs 0/10 packages instead of 10/10 and still reports success. The ordering of the dry-run branch ahead of the existence probe is load-bearing and nothing pins it. (The other survivor — dropping channel-feishu from PUBLISHED_PACKAGES — is the same class: only qwen-code and channel-telegram are pinned.)
4. Still open — non-blocking
(a) Carried over from round 3, unchanged at HEAD: a transient registry error now hard-crashes the preview release.
getPreviewVersion calls getAndVerifyTags('latest', …) unguarded (scripts/get-release-version.js:363); detectRollbackAndGetBaseline re-throws any non-E404 error. getLatestStableReleaseTag() makes the identical call inside a try/catch, so the two call sites disagree. Pre-PR, getPreviewVersion never touched latest at all — this is a new failure surface on an unattended Tuesday cron, where a crash means a missed preview.
control (healthy) main → 0.21.0-preview.0 PR → 0.21.2-preview.0 both rc=0
variable (ECONNRESET) main → 0.21.0-preview.0 PR → rc=1, Error: Command failed: npm view … --tag=latest
The one-liner, verified — healthy output unchanged, ECONNRESET degrades to "no bump" (and doesVersionExist still routes around the taken versions), 45/45 pass:
- const latestTagResult = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]');
- const latestStable = latestTagResult?.latestVersion ?? '';
+ const latestStable = getLatestStableReleaseTag().replace(/^v/, '');(b) New: the broadened existence check makes this PR's own idempotency unreachable for --type=nightly.
getVersion() hard-throws for type: 'nightly' instead of auto-incrementing. Now that doesVersionExist consults all ten packages, a nightly that landed on @qwen-code/audio-capture but not yet on @qwen-code/qwen-code fails in prepare — one job before the new skip guard can run:
variable (@qwen-code/audio-capture already holds this exact nightly)
main → rc=0, 0.21.1-nightly.20260729.STUBHASH
PR → rc=1, Error: Version conflict! Nightly version … already exists
…and the publish job, given that same version:
main → 0/10 published, red PR → 9/10 published, skipped 1, green
Same shape as the blocker fixed in round 3, on the daily path. The reachable window is narrow — it needs a failure strictly between the audio-capture and qwen-code publish steps, since nightly versions embed the date and commit hash — which is why I'm filing it as non-blocking rather than a blocker. Letting the nightly path auto-increment (or skip the throw when the hit is on a non-main package) would close it.
5. Nits
- Cost of the check. Measured live for
--type=preview: npm round-trips 8 → 19, wall clock 9.2s → 22.6s. Nine of the extra calls are the new per-package probes (unavoidable, that's the fix); three are pure duplication —getAndVerifyTags('latest')now runs twice per preview run, so--tag=latest×2,versions --json×3,@0.21.1 deprecated×2. The fix in 4(a) is a natural place to memoize it away. Irrelevant for a release job either way. - The allowlist-drift nit is now concrete.
PUBLISHED_PACKAGES(JS) andfor channel in dingtalk feishu …(YAML) still agree exactly — I diffed them, 10 vs 10, no difference either way. Butpackages/channels/gitlabhas since landed onmain(feat(channels): add GitLab polling channel adapter #7862), is notprivate, is not published, and is in neither list. That's correct today (the YAML comment says new channel workspaces need release approval), but the moment it is approved both lists must move together, or the version check goes blind again — which is precisely the Release Failed for v0.21.0-preview.0 on 2026-07-29 #7969 root cause. A ~10-line test parsing both and diffing the sets would lock it in. - Skip visibility is still asymmetric. The channel loop emits
::warning::; the three single-package steps only emit::notice::, andCreate GitHub Release and Taghas no dependency on anything having shipped (if:is onlyis_dry_run == 'false') — so a fully-skipped publish still tags. In practice that reachable case is the intended idempotency (re-running a failedpublishjob withprepareoutputs reused), so this is cosmetic;::warning::on those three would cost nothing. PUBLISH_MARKER="$(mktemp)"is never removed. Ephemeral runner, cosmetic.expect(channelStep).toContain(')')is vacuous — it matches$(node -p …). Harmless, carries no weight.
6. Checks
scripts/tests/get-release-version.test.js+package-scripts.test.js: 45/45 pass. Fullnpm run test:scriptsconfig: 83/83 pass.eslintclean ·prettier --checkclean on all four changed files.release.ymlis actionlint/shellcheck-clean — worth stating, since the bot flagged it as "not linted (tool limitation)" on every round.actionlint .github/workflows/release.ymlreports exactly one finding (SC2129, line 112) and it is byte-identical on the merge-base, outside this PR's only hunk (line 438+). The four rewritten shell blocks add zero findings.node scripts/lint.js --actionlint --shellcheck --yamllint→ rc=0.- Not superseded:
mainhas not touched any of the four files since the merge-base, and no other open PR touches them. - No collateral:
--type=nightly,promote-nightly,stable,patch --patch-from=stable|previewand--preview_version_overrideare unchanged between the arms except where noted in 4(b).
Recommendation: merge. I'd fold in the 4(a) one-liner first — it's ~30 seconds and removes a crash path from an unattended cron — and file 4(b) plus the drift-guard test as follow-ups.
中文完整版
第 4 轮 —— 在 HEAD 210cb098 上的本地验证
在 210cb098 之后重新验证(这是第 3 轮以来唯一与本 PR 相关的提交 —— 16 行测试;e2fe65a873 是一次 main 合并)。以下全部在一对干净的 worktree(HEAD 对 merge-base c97026040e)上、对着真实的 npm registry 运行;release.yml 的四个 publish step 是逐字从 YAML 中取出的(yaml.parse → steps[].run → bash -e <file>,即 ubuntu-latest 上未指定 shell: 时的默认行为),${{ needs.prepare.outputs.* }} 用真实 needs 上下文求值。没有改写任何 run 内容。
结论:可以合入。 我前几轮提出的两个阻塞项都已关闭,并且在变异测试下依然关闭;最新提交的那条测试是承重的;而本 PR 要修的 bug 今天在 main 上仍然存在。还剩两个非阻塞发现 —— 一个从第 3 轮延续,一个是新的 —— 每个都附带已验证的一行修复,都不值得为此卡住发布列车。
1. 这个 bug 今天在 main 上仍然存在
同一台机器、同一个 registry,只有版本不同:
| 输出 | 空闲情况 | |
|---|---|---|
main |
0.21.0-preview.1 |
10 个里 9 个 —— @qwen-code/channel-dingtalk 自 2026-07-27T13:48 起就占用了它 → E403 |
| 本 PR | 0.21.2-preview.0 |
10 个里 10 个 |
注意 #7969 其实已经关闭了 —— 关闭它的是 #7970,那修的是同一次运行在 release notes 上的症状。E403 的成因从未被修复;本 PR 才是那个修复。
2. release.yml publish job —— 完整场景 A/B
先做工装保真度校验。 场景 C 在 main 一侧逐步复现了 run 30410661540 —— audio-capture、qwen-code、channel-base 成功,Publish remaining channel packages 失败 —— 这一点我是重新查了该 run 自己的 job API,而不是沿用上一轮的结论。所以下面的 A/B 衡量的是 PR,而不是工装。
| 场景 | main |
本 PR |
|---|---|---|
| A 什么都还没发布(正常发布) | 10/10,绿 | 10/10,绿 —— 不会误跳过 |
| C dingtalk 占用了该版本 (#7969 的形状) | 3/10,红 | 9/10,绿 |
| F 重跑一次部分完成的发布(前 3 个已落地) | 0/10,红,3 个 step 根本没跑到 | 7/10,绿 |
| B 每个包都已发布 | 0/10,红 | 0/10,绿 + ::warning::…nothing shipped |
| D dry run,且所有包都已发布 | 打包 10/10 | 打包 10/10 —— npm view 一次都没被调用 |
| E 循环中途真的发布失败 | 红 | 红 —— 守卫不会吞掉真实错误 |
每个 channel 外层 ( … ) 子 shell 里的 exit 0 是整段的承重细节,写对了:场景 C 在跳过 dingtalk 之后仍然访问了全部 7 个 channel。
npm view 这个前提对真实 registry 再次确认:npm view <包>@<精确版本> version 当且仅当该精确版本已发布时退出 0;版本不存在、包不存在、包未发布都退出 1。其他失败(网络、鉴权)同样非零 → 守卫会落到 npm publish,拿回原来的 E403。这是安全的方向。
3. 测试的牙齿 —— 13 个变异体杀掉 11 个,而第 2 轮那个存活者正是被新提交杀掉的
每个变异体都证明了确实落地(截图右列的 git diff --numstat)。
第 2 轮存活的那个变异体 —— 把 doesVersionExist 退回只查主包 —— 现在被杀掉了,并且正是 210cb098 杀掉它的:
有 210cb098 时 × should auto-increment when the version exists only on a channel package
Tests 1 failed | 29 passed (30) → KILLED
删掉那一个 it() 后 Tests 29 passed (29) → SURVIVED
那 16 行是承重的,不是装饰。这也关闭了 bot 最后一条未处理的建议。
其中一个存活者是真实的回归(非阻塞,仅测试覆盖问题):把 dry-run 分支从 elif 改成独立的 if,45 条测试全绿,但 dry-run 发布会从打包 10/10 变成打包 0/10,并且仍然报告成功。dry-run 分支排在存在性探测之前这个顺序是承重的,却没有任何测试钉住它。(另一个存活者 —— 从 PUBLISHED_PACKAGES 里删掉 channel-feishu —— 属于同一类:只有 qwen-code 和 channel-telegram 被钉住了。)
4. 仍未关闭 —— 非阻塞
(a) 第 3 轮延续、HEAD 上未变:registry 的瞬时错误现在会让 preview 发布硬崩溃。
getPreviewVersion 无保护地调用了 getAndVerifyTags('latest', …)(scripts/get-release-version.js:363);detectRollbackAndGetBaseline 会把任何非 E404 错误重新抛出。而 getLatestStableReleaseTag() 把完全相同的调用包在 try/catch 里,两个调用点的处理并不一致。改动前 getPreviewVersion 根本不碰 latest —— 这是一个新增的失败面,而它位于无人值守的周二 cron 上,崩溃就意味着漏掉一次 preview。
对照(健康) main → 0.21.0-preview.0 PR → 0.21.2-preview.0 两者 rc=0
变量(ECONNRESET) main → 0.21.0-preview.0 PR → rc=1, Error: Command failed: npm view … --tag=latest
已验证的一行修复 —— 健康路径输出不变,ECONNRESET 时退化为「不 bump」(而 doesVersionExist 这张网仍会绕开被占用的版本),45/45 通过:
- const latestTagResult = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]');
- const latestStable = latestTagResult?.latestVersion ?? '';
+ const latestStable = getLatestStableReleaseTag().replace(/^v/, '');(b) 新发现:扩大后的存在性检查,让本 PR 自己的幂等性在 --type=nightly 上变得不可达。
getVersion() 对 type: 'nightly' 是硬抛异常而不是自动递增。现在 doesVersionExist 会查全部 10 个包,于是一个「已落到 @qwen-code/audio-capture、还没落到 @qwen-code/qwen-code」的 nightly 会在 prepare 里失败 —— 比新的跳过守卫早了整整一个 job:
变量(@qwen-code/audio-capture 已占用这个确切的 nightly)
main → rc=0, 0.21.1-nightly.20260729.STUBHASH
PR → rc=1, Error: Version conflict! Nightly version … already exists
…而同一个版本交给 publish job:
main → 发布 0/10,红 PR → 发布 9/10,跳过 1,绿
和第 3 轮修掉的那个阻塞项是同一个形状,只是落在每日路径上。可达窗口很窄 —— 需要恰好在 audio-capture 和 qwen-code 两个 publish step 之间失败,因为 nightly 版本号里带了日期和 commit hash —— 所以我按非阻塞而不是阻塞来提。让 nightly 路径也走自动递增(或者当命中的是非主包时不抛异常)就能关闭它。
5. 吹毛求疵
- 这个检查的开销。 对
--type=preview实测:npm 往返 8 → 19,墙上时钟 9.2s → 22.6s。多出来的 9 次是新的逐包探测(不可避免,这就是修复本身);另外 3 次是纯重复 ——getAndVerifyTags('latest')现在每次 preview 会跑两遍,于是--tag=latest×2、versions --json×3、@0.21.1 deprecated×2。4(a) 的修复正好是顺手把它记忆化掉的地方。对发布 job 来说无论如何都无所谓。 - allowlist 漂移这条吹毛求疵现在有了具体例子。
PUBLISHED_PACKAGES(JS)和for channel in dingtalk feishu …(YAML)目前仍然完全一致 —— 我做了双向 diff,10 对 10,没有差异。但packages/channels/gitlab已经随 feat(channels): add GitLab polling channel adapter #7862 落到main了,它不是private,也没有发布,并且两个清单里都没有它。今天这是对的(YAML 注释说新增 channel workspace 需要发布审批),但一旦它获批,两个清单必须同步更新,否则版本检查又会变瞎 —— 而这恰恰就是 Release Failed for v0.21.0-preview.0 on 2026-07-29 #7969 的根因。一条约 10 行、解析两边并 diff 集合的测试就能钉住。 - 跳过的可见性仍然不对称。 channel 循环会发
::warning::;三个单包 step 只发::notice::,而Create GitHub Release and Tag并不依赖「是否真的发布了东西」(它的if:只有is_dry_run == 'false')—— 所以全部跳过时仍然会打 tag。实际上这个可达场景正是预期的幂等行为(重跑失败的publishjob、复用prepare的输出),所以只是观感问题;给那三个 step 也加上::warning::不花成本。 PUBLISH_MARKER="$(mktemp)"从未被删除。runner 是一次性的,纯观感。expect(channelStep).toContain(')')是空断言 —— 它会匹配到$(node -p …)。无害,但不承重。
6. 各项检查
scripts/tests/get-release-version.test.js+package-scripts.test.js:45/45 通过。完整的npm run test:scripts配置:83/83 通过。eslint干净 · 四个改动文件prettier --check全部干净。release.yml通得过 actionlint/shellcheck —— 值得明确说一句,因为 bot 每一轮都标注「未做 lint(工具限制)」。actionlint .github/workflows/release.yml只报一条(SC2129,第 112 行),而它在 merge-base 上逐字节相同,且位于本 PR 唯一那个 hunk(第 438 行起)之外。四段重写的 shell 没有新增任何 finding。node scripts/lint.js --actionlint --shellcheck --yamllint→ rc=0。- 没有被取代: 自 merge-base 以来
main没有碰过这四个文件,也没有其他 open PR 碰它们。 - 无连带影响:
--type=nightly、promote-nightly、stable、patch --patch-from=stable|preview和--preview_version_override在两个 arm 之间一致,除 4(b) 指出的部分。
建议:合入。 我会先把 4(a) 那一行折进来 —— 30 秒的事,而且能从一个无人值守的 cron 上移除一条崩溃路径 —— 然后把 4(b) 和漂移守卫测试作为后续项提出。
|
Released in v0.21.2. |








What
getPreviewVersion()now checks whether the nightly-derived base version is already published as stable on npm. If so, it bumps the patch before appending-preview.0.Why
The scheduled Tuesday preview release derives its version from the latest nightly tag:
But v0.21.0 is already published as stable. The main package might pass (auto-increment in
getVersion()), but channel packages (@qwen-code/channel-dingtalketc.) don't go through that loop and hit npm E403:Fixes #7969.
Fix
After deriving the base from the nightly tag, compare against
npm view @qwen-code/qwen-code version --tag=latest. If stable >= base, bump patch:Mirrors the guard
getStableVersion()already has (refusing retrograde baselines) but applies it proactively to preview derivation.Test
doesVersionExistauto-increment loop remains as secondary safety net.latest=0.21.0and nightly tagv0.21.0-nightly.*, script now outputs0.21.1-preview.0.