refactor(build,release): Use pnpm workspace builds and publishing (#12400)

* refactor(release): Use pnpm for workspace publishing and versioning

* fix(ci): revert desktop-release install to npm ci for Windows compatibility

The pnpm migration added a bash conditional to check for pnpm-lock.yaml,
but the step did not specify shell: 'bash'. Windows runners default to
PowerShell, which cannot parse bash syntax, causing the build to fail
with a ParserError.

Since desktop-release always checks out the same commit and the root
lockfile is now pnpm-lock.yaml, the conditional is unnecessary. Revert
to the simple npm ci command that works on all platforms.

Fixes the Windows build failure in Desktop release v0.24.2.

* refactor(build): Schedule workspace builds with pnpm

* fix(ci): Preserve Windows release installation and version filters

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-09-21 15:38:36 +00:00
1 parent ec109102e0
commit baf0ff41f7
30 files changed
+488 -292

No files matched your search

+10 -29
View File
@@ -11,8 +11,6 @@ step="${1:?usage: run-release-step.sh <step>}"
publish_package() {
local directory="$1"
local published_marker="${2:-}"
local marker_value="${3:-}"
(
cd "${directory}"
local package_name
@@ -25,10 +23,7 @@ publish_package() {
echo "::notice::${package_name}@${RELEASE_VERSION} already published; skipping"
exit 0
fi
npm publish --provenance "${publish_args[@]}"
if [[ -n "${published_marker}" ]]; then
echo "${marker_value}" >> "${published_marker}"
fi
corepack pnpm publish --no-git-checks --provenance "${publish_args[@]}"
)
}
@@ -181,31 +176,17 @@ case "${step}" in
;;
publish-packages)
if [[ "${PUBLISH_EXTERNAL_CONTEXT_MEM0}" == "true" ]]; then
publish_package 'integrations/external-context-mem0'
fi
if [[ "${PUBLISH_AUDIO_CAPTURE}" == "true" ]]; then
publish_package 'packages/audio-capture'
package_names="$(node "$(dirname "$0")/../../scripts/release-packages.mjs")"
publish_args=(--access public "--tag=${NPM_TAG}" --no-git-checks --provenance)
while IFS= read -r name; do
publish_args+=("--filter=${name}")
done <<< "${package_names}"
if [[ "${IS_DRY_RUN}" == "true" ]]; then
# Exercise packing and lifecycle checks even for an existing version.
publish_args+=(--dry-run --force)
fi
corepack pnpm -r publish "${publish_args[@]}"
publish_package 'dist'
publish_package 'packages/channels/base'
publish_marker="$(mktemp)"
# Explicit allowlist: new channel packages require release approval.
for channel in dingtalk dws feishu github qqbot telegram wecom weixin; do
echo "::group::Publishing @qwen-code/channel-${channel}"
publish_package "packages/channels/${channel}" "${publish_marker}" "${channel}"
echo "::endgroup::"
done
# Last on purpose: this is the only name in the sequence CI has never
# published, so a failure here leaves every long-shipped package already
# out instead of stranding them under `set -eo pipefail`.
publish_package 'packages/web-shell'
if [[ "${IS_DRY_RUN}" != "true" ]] && [[ ! -s "${publish_marker}" ]]; then
echo "::warning::Every channel package was already published; nothing shipped"
fi
;;
verify-archives)
+1
View File
@@ -308,6 +308,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libatk-bridge2.0-0 at-spi2-core dbus-x11 patchelf libfuse2 xdg-utils xvfb
- name: 'Install Qwen Code dependencies'
shell: 'bash'
working-directory: '${{ runner.temp }}/qwen-code-source'
env:
QWEN_SKIP_PREPARE: '1'
+6 -1
View File
@@ -168,6 +168,12 @@ jobs:
sparse-checkout: |-
/.github/scripts
/scripts/assert-release-version.mjs
/scripts/release-packages.mjs
/scripts/workspaces.js
/package.json
/packages/*/package.json
/packages/channels/*/package.json
/integrations/*/package.json
sparse-checkout-cone-mode: false
path: '.release-workflow'
@@ -650,7 +656,6 @@ jobs:
run: |-
corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only
npm run postinstall
npm run generate
- *reset_release_workflow_scripts
- *checkout_release_workflow_scripts
+78
View File
@@ -0,0 +1,78 @@
# Native pnpm release operations
[English](2026-09-21-pnpm-release.md) | [简体中文](2026-09-21-pnpm-release.zh-CN.md)
## Scope and decisions
Replace the main release's hand-written workspace publish loop and per-package
version commands with the repository's pinned pnpm. Keep independent releases,
version-collision guards, OIDC/provenance, optional-package gates, and generated
CLI packaging unchanged. This does not dispatch or merge a release.
Internal source packages declare `private: true`. SDK, Mobile MCP, Node REPL,
and Qwen Live remain public and independently versioned. The GitLab channel is
private because the existing release does not publish it. The generated CLI
manifest remains public; neither the repository root nor CLI source is published.
Derive release workspace names from workspace manifests, excluding private and
independent packages. Use that selection for both recursive publication and
version-collision checks. The guard checks optional packages even when their
publication gates are disabled, preserving its conservative behavior. Check out
the selection helper and manifests at the workflow SHA so a release-ref checkout
cannot replace the push guard's package list.
Run `pnpm -r publish` with explicit package filters, provenance, access, and tag.
pnpm handles dependency order and skips versions already on the registry.
`--force` is used only with `--dry-run` so existing versions still exercise their
packing and lifecycle checks. Publish the generated CLI separately after the
workspaces. Keep its existing version-skip check. A workspace failure stops the
step before CLI publication; publishing multiple packages is not atomic.
Resolve the root version once and use recursive versioning to align all coupled
workspaces, including private ones. Keep extension metadata, sandbox image tags,
and exact channel-base dependency pins aligned. pnpm version does not rewrite
those exact dependency pins. Remove the old npm-reify node_modules cleanup.
## Recursive builds
Replace the ordered workspace array with pnpm's recursive, topologically sorted
build. Independent packages run concurrently; dependency declarations provide
ordering, including browser-use before core and Web Shell before web-templates.
Declare VS Code's existing CLI source dependency so its compilation waits for
the CLI dependency tree. Preserve the Mobile MCP exclusion from the root build.
CLI-only selects the CLI directory and its transitive dependencies, plus Node
REPL and the channel plugin example, preserving the previous build coverage.
Make audio-capture's default build TypeScript-only, matching the previous root
build. Keep explicit native compilation as `build:native` and update the runtime
repair instruction. Generate settings schema after workspace builds finish.
Keep version/commit generation before compilation and bundling, since both are
standalone entrypoints. Validate clean builds and package artifacts before
claiming that recursive scheduling is equivalent. The SDK uses the same composite
TypeScript build as the CLI's project reference, so the CLI reuses its buildinfo
instead of overwriting bundled SDK declarations. Remove the publish job's
pre-version-bump generation; the build generates the release metadata afterwards.
## Verification and acceptance
- The effective published package set matches the existing set plus the Web
Shell restoration in #12387; independent releases remain excluded.
- Tests cover gated selection, workflow-pinned manifests, existing CLI versions,
tag/provenance arguments, dry-run flags, and failure propagation.
- Exercise native pnpm against a local registry: first/partial/complete releases,
dependency order, private exclusions, dry-run with no writes, and registry
failures. Preserve the trusted guard: pnpm's existence probes alone do not
fail closed on registry errors.
- Run actual versioning against temporary manifests and compare packed artifacts
and installability. Run targeted tests, build, and typecheck.
- No live npm release is part of verification. OIDC exchange and provenance on
GitHub-hosted runners require a subsequently authorized real release; local
dry-run cannot prove them.
## Risks and follow-up
Adding a non-private, non-independent workspace now opts it into the main release.
Review its npm permissions and trusted publisher before merging it. `private`
prevents publication; it does not change an already published package's access.
The CLI still needs its generated manifest and bundling logic, so this is a
bounded simplification rather than replacement of the whole release workflow.
@@ -0,0 +1,64 @@
# pnpm 原生发布操作
[English](2026-09-21-pnpm-release.md) | [简体中文](2026-09-21-pnpm-release.zh-CN.md)
## 范围与决策
用仓库锁定的 pnpm 替换主发布流程中手写的 workspace 发布循环和逐包改版本命令。
保留独立发布、版本冲突保护、OIDC/provenance、可选包开关和 CLI 产物打包逻辑。
本改动不触发或合并一次发布。
内部源码包声明 `private: true`。SDK、Mobile MCP、Node REPL 和 Qwen Live
继续公开发布并独立管理版本。GitLab channel 标记为 private,因为现有发布流程不发布它。
生成的 CLI manifest 继续可发布;仓库根包和 CLI 源码包都不直接发布。
从 workspace manifests 推导发布包名,排除 private 包和独立发版包。
递归发布和版本冲突检查使用同一份选择结果。即使可选包发布开关关闭,保护检查仍查询它们,
维持原来的保守行为。在 workflow SHA 检出选择辅助脚本及 manifests,
防止 release-ref 检出内容替换推送保护使用的包清单。
运行 `pnpm -r publish`,明确传入包过滤器、provenance、access 和 tag。
由 pnpm 处理依赖顺序并跳过 registry 上已有的版本。
`--force` 仅与 `--dry-run` 一起使用,保证已有版本也执行打包和生命周期检查。
workspace 发布完成后单独发布生成的 CLI,保留其现有版本跳过检查。
workspace 发布失败就停止,不继续发布 CLI;多包发布不具备原子性。
先解析一次根包版本,再递归对齐所有跟随主版本的 workspace,包括 private 包。
保留扩展元数据、sandbox 镜像 tag 和 channel-base 精确依赖版本的同步。
pnpm version 不会改写这些精确依赖版本。删除旧的 npm reify 遗留 node_modules 清理。
## 递归构建
使用 pnpm 按依赖拓扑排序的递归构建,替换手写的 workspace 顺序数组。
独立包并发执行,由依赖声明保证 browser-use 先于 core、Web Shell 先于
web-templates 等顺序。补充 VS Code 已存在的 CLI 源码依赖,使其编译等待
CLI 依赖树完成。根构建继续排除 Mobile MCP。CLI-only 选择 CLI 目录及其
传递依赖,并补充 Node REPL 和 channel plugin example,保留原构建范围。
audio-capture 的默认 build 改为仅编译 TypeScript,与原根构建一致;原生
编译保留为显式的 `build:native`,同步更新运行时修复提示。在 workspace
构建完成后生成 settings schema。编译与 bundle 都保留各自入口的版本和
commit 信息生成,因为这两个入口均可独立调用。通过干净构建和产物检查后
再判断递归调度是否等价。SDK 使用与 CLI project reference 相同的 composite
TypeScript 构建,使 CLI 复用 buildinfo,不再覆盖 SDK 打包后的声明。
移除发布 job 在更新版本前的生成步骤;构建会在随后生成发布元数据。
## 验证与验收
- 实际发布集合等于现有集合加上 #12387 恢复的 Web Shell;继续排除独立发版包。
- 测试覆盖可选包开关、workflow-pinned manifests、CLI 已有版本、tag/provenance 参数、
dry-run 参数和失败传播。
- 用本地 registry 验证原生 pnpm:首次/部分/完整发布、依赖顺序、private 排除、
dry-run 零写入和 registry 错误。保留可信保护检查:pnpm 的存在性探测本身不会在
registry 报错时拒绝继续。
- 在临时 manifests 上运行真实改版本流程,比较打包产物并验证可安装性。
运行定向测试、构建和类型检查。
- 验证不包括真实 npm 发布。GitHub-hosted runner 上的 OIDC 交换及 provenance
需要后续单独授权的真实发布才能验证;本地 dry-run 不能证明它们正常。
## 风险与后续
新增非 private、非独立发版的 workspace 会自动进入主发布流程。
合并前需要检查它的 npm 权限和 trusted publisher。
`private` 阻止发布,不会修改已发布包的访问权限。
CLI 仍需要生成 manifest 和 bundle,因此这是有边界的简化,不是重写整个发布工作流。
+2 -1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"private": true,
"version": "0.24.3",
"packageManager": "[email protected]+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000",
"engines": {
@@ -43,7 +44,7 @@
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:packages": "corepack pnpm -r run build",
"build:sandbox": "node scripts/build_sandbox.js",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" npm run test --workspaces --if-present",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/acp-bridge",
"private": true,
"version": "0.24.3",
"description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.",
"repository": {
+2 -1
View File
@@ -29,7 +29,8 @@
"scripts": {
"install": "node install.js",
"prebuildify": "prebuildify --napi --strip",
"build": "node-gyp rebuild && tsc --build",
"build": "tsc --build",
"build:native": "node-gyp rebuild && tsc --build",
"build:ts": "tsc --build",
"test": "vitest run",
"test:ci": "vitest run",
+1 -1
View File
@@ -63,7 +63,7 @@ function loadBinding(): NativeBinding {
} catch (error) {
throw new Error(
'Native audio capture addon could not be loaded. Reinstall ' +
'@qwen-code/audio-capture, or run "npm run build" in packages/audio-capture. ' +
'@qwen-code/audio-capture, or run "npm run build:native" in packages/audio-capture. ' +
`(${error instanceof Error ? error.message : String(error)})`,
);
}
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/browser-use",
"private": true,
"version": "0.24.3",
"description": "Browser Use SDK for the Qwen Code Node REPL",
"type": "module",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/channel-gitlab",
"private": true,
"version": "0.24.3",
"description": "GitLab polling channel adapter for Qwen Code",
"type": "module",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"private": true,
"version": "0.24.3",
"description": "Qwen Code",
"repository": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"private": true,
"version": "0.24.3",
"description": "Qwen Code Core",
"repository": {
+1
View File
@@ -39,6 +39,7 @@
},
"files": [
"dist",
"!dist/**/*.tsbuildinfo",
"README.md"
],
"scripts": {
+1 -1
View File
@@ -39,7 +39,7 @@ const MAX_TRANSCRIPT_BROWSER_BUNDLE_BYTES = 192 * 1024;
rmSync(join(rootDir, 'dist'), { recursive: true, force: true });
mkdirSync(join(rootDir, 'dist'), { recursive: true });
execSync('tsc --project tsconfig.build.json', {
execSync('tsc --build tsconfig.reference.json', {
stdio: 'inherit',
cwd: rootDir,
});
@@ -1,5 +1,6 @@
{
"name": "qwen-code-vscode-ide-companion",
"private": true,
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.24.3",
@@ -267,6 +268,7 @@
"test:ci": "vitest run"
},
"devDependencies": {
"@qwen-code/qwen-code": "file:../cli",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/markdown-it": "^14.1.2",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@qwen-code/web-templates",
"private": true,
"version": "0.24.3",
"description": "Web templates bundled as embeddable JS/CSS strings",
"repository": {
+3
View File
@@ -1331,6 +1331,9 @@ importers:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@qwen-code/qwen-code':
specifier: workspace:*
version: link:../cli
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
+3 -14
View File
@@ -8,26 +8,15 @@
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { RELEASE_WORKSPACES } from './release-packages.mjs';
export const PUBLISHED_PACKAGES = [
'@qwen-code/qwen-code',
'@qwen-code/external-context-mem0',
'@qwen-code/audio-capture',
'@qwen-code/channel-base',
'@qwen-code/channel-dingtalk',
'@qwen-code/channel-dws',
'@qwen-code/channel-feishu',
'@qwen-code/channel-github',
'@qwen-code/channel-qqbot',
'@qwen-code/channel-telegram',
'@qwen-code/channel-wecom',
'@qwen-code/channel-weixin',
'@qwen-code/web-shell',
...RELEASE_WORKSPACES,
];
// Deliberately a copy of `scripts/lib/release-helpers.js`, not an import:
// the workflow checks this file out alone (`/scripts/assert-release-version.mjs`
// in release.yml's sparse-checkout set), so importing from `scripts/lib/`
// the workflow's sparse checkout excludes `scripts/lib/`, so importing it
// resolves in every test lane and throws ERR_MODULE_NOT_FOUND only in the
// publish job. `scripts/tests/release-workflow.test.js` pins the two bodies
// identical — widen or harden them together, and never widen this copy alone:
+13 -66
View File
@@ -36,74 +36,21 @@ if (!existsSync(join(root, 'node_modules'))) {
// build all workspaces/packages in dependency order
execSync('npm run generate', { stdio: 'inherit', cwd: root });
// --cli-only: skip packages not needed by the CLI bundle. Web Shell still
// builds because the HTML export publishes its document renderer with npm.
// Select the CLI by path: the repository root has the same package name.
const cliOnly = process.argv.includes('--cli-only');
// Mobile MCP has an independent build and was never part of the root build.
const filter = cliOnly
? '--filter "{./packages/cli}..." --filter @qwen-code/node-repl-mcp --filter @qwen-code/channel-plugin-example'
: '--filter "!@qwen-code/mobile-mcp"';
execSync(`corepack pnpm -r ${filter} run build`, {
stdio: 'inherit',
cwd: root,
});
// Build in dependency order:
// 0. browser-use (built first so core can stage the builtin skill's runtime)
// 1. core (foundation package, includes test-utils)
// 2. channel-base (base channel infrastructure - used by channel adapters and cli)
// 3. channel adapters (depend on channel-base)
// 4. audio-capture (native microphone backend used by cli)
// 5. acp-bridge (depends on core - used by cli)
// 6. sdk (build-time devDep on acp-bridge for shared constants, used by cli channel worker)
// 7. web-shell (document renderer used by web-templates)
// 8. web-templates (embeddable web templates - used by cli)
// 9. cli (depends on core, acp-bridge, web-templates, channel packages, sdk)
// 10. vscode-ide-companion
// 11. external-context integrations (private Qwen extensions)
const buildOrder = [
'packages/browser-use',
'packages/core',
'packages/channels/base',
'packages/channels/telegram',
'packages/channels/weixin',
'packages/channels/dingtalk',
'packages/channels/dws',
'packages/channels/wecom',
'packages/channels/feishu',
'packages/channels/qqbot',
'packages/channels/github',
// gitlab is a builtin of the cli channel registry like its siblings; it
// used to build only transitively via cli's tsconfig project reference.
'packages/channels/gitlab',
'packages/channels/plugin-example',
'packages/audio-capture',
'packages/node-repl',
'packages/browser-use',
'packages/acp-bridge',
'packages/sdk-typescript',
'packages/web-shell',
'packages/web-templates',
'packages/cli',
...(cliOnly
? []
: [
'packages/qwen-live',
'packages/vscode-ide-companion',
'packages/chrome-extension',
'integrations/external-context',
'integrations/external-context-mem0',
]),
];
for (const workspace of buildOrder) {
const command =
workspace === 'packages/audio-capture'
? `npm run build:ts --workspace=${workspace}`
: `npm run build --workspace=${workspace}`;
execSync(command, { stdio: 'inherit', cwd: root });
// After cli is built, generate the JSON Schema for settings
// so the vscode-ide-companion extension can provide IntelliSense
if (workspace === 'packages/cli') {
execSync('node --import tsx/esm scripts/generate-settings-schema.ts', {
stdio: 'inherit',
cwd: root,
});
}
}
execSync('node --import tsx/esm scripts/generate-settings-schema.ts', {
stdio: 'inherit',
cwd: root,
});
// also build container image if sandboxing is enabled
// skip (-s) npm install + build since we did that above
+45
View File
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getWorkspacePackageJsonPaths } from './workspaces.js';
export const INDEPENDENT_PACKAGES = [
'@qwen-code/sdk',
'@qwen-code/mobile-mcp',
'@qwen-code/node-repl-mcp',
'@qwen-code/qwen-live',
];
// Resolve beside this script, not cwd: the push guard uses workflow-pinned
// manifests even when the release source comes from a different ref.
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const { workspaces } = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
);
export const RELEASE_WORKSPACES = getWorkspacePackageJsonPaths(root, workspaces)
.map((path) => JSON.parse(readFileSync(join(root, path), 'utf8')))
.filter((pkg) => !pkg.private && !INDEPENDENT_PACKAGES.includes(pkg.name))
.map((pkg) => pkg.name);
if (process.argv[1] === fileURLToPath(import.meta.url)) {
for (const name of RELEASE_WORKSPACES) {
if (
name === '@qwen-code/audio-capture' &&
process.env.PUBLISH_AUDIO_CAPTURE !== 'true'
)
continue;
if (
name === '@qwen-code/external-context-mem0' &&
process.env.PUBLISH_EXTERNAL_CONTEXT_MEM0 !== 'true'
)
continue;
console.log(name);
}
}
+10 -8
View File
@@ -8,7 +8,6 @@ import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parse } from 'yaml';
import { copyBrowserUseAssets } from '../copy-browser-use-assets.js';
@@ -220,14 +219,17 @@ describe('browser-use builtin resources', () => {
);
});
it('builds browser-use before core in the root build', () => {
const script = fs.readFileSync(
fileURLToPath(new URL('../build.js', import.meta.url)),
'utf8',
it('declares the browser-use build dependency for recursive scheduling', () => {
const core = JSON.parse(
fs.readFileSync(
new URL('../../packages/core/package.json', import.meta.url),
'utf8',
),
);
const browserUseIndex = script.indexOf("'packages/browser-use'");
expect(browserUseIndex).toBeGreaterThan(0);
expect(browserUseIndex).toBeLessThan(script.indexOf("'packages/core'"));
expect(
core.devDependencies?.['@qwen-code/browser-use'] ??
core.dependencies?.['@qwen-code/browser-use'],
).toBe('file:../browser-use');
});
it('builds browser-use before core when publishing Live Host', () => {
@@ -474,6 +474,7 @@ describe('Desktop release event', () => {
getWorkflowJob(releaseWorkflow, 'build'),
'Install Qwen Code dependencies',
);
expect(install).toContain("shell: 'bash'");
expect(install).toContain('if [ -f pnpm-lock.yaml ]');
expect(install).toContain(
'corepack pnpm install --frozen-lockfile --prefer-offline',
+5 -8
View File
@@ -21,7 +21,10 @@ import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
vi.mock('node:child_process');
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const fs = await importOriginal();
return { ...fs, readFileSync: vi.fn(fs.readFileSync) };
});
describe('getVersion', () => {
beforeEach(() => {
@@ -738,13 +741,7 @@ describe('assertVersionUnreleased', () => {
`Version 1.2.3 has already shipped; refusing to force-push the release branch over it. Found on: ${foundOn}. If a previous attempt published only part of the release, complete the remaining artifacts manually — re-running this job will keep failing here while the version stays published.`;
it('pins the full published-package set', () => {
// The push-time guard and the release-version picker derive from this
// list; the publish allowlist in `.github/scripts/run-release-step.sh`
// enumerates the same packages separately, so adding or removing one must
// update both. They are pinned against each other by "keeps the publish
// allowlist and the guard package set in step" in release-workflow.test.js
// — release.yml itself no longer contains any publish step. Append rather
// than insert: the tests below read this array by index.
// Changing private flags must not accidentally expand the release surface.
expect(PUBLISHED_PACKAGES).toEqual([
'@qwen-code/qwen-code',
'@qwen-code/external-context-mem0',
+2
View File
@@ -1006,6 +1006,7 @@ describe('package asset scripts', () => {
);
expect(distPackageJson.files).toContain('examples');
expect(distPackageJson.private).not.toBe(true);
expect(distPackageJson.bundledDependencies).toBeUndefined();
expect(distPackageJson.optionalDependencies).toMatchObject({
'@qwen-code/audio-capture': rootPackageJson.version,
@@ -1435,6 +1436,7 @@ describe('package asset scripts', () => {
name: '@qwen-code/qwen-code',
version: '0.17.0',
description: 'Qwen Code',
private: true,
repository: {
type: 'git',
url: 'https://github.com/QwenLM/qwen-code.git',
+45 -21
View File
@@ -24,6 +24,7 @@ import { parse, stringify } from 'yaml';
import { hooks as pnpmHooks, workspacePackageNames } from '../../.pnpmfile.mjs';
import { getPinnedPnpmPackage } from '../pnpm-package.js';
import { INDEPENDENT_PACKAGES } from '../release-packages.mjs';
import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
@@ -842,14 +843,8 @@ describe('package scripts', () => {
'utf8',
);
expect(versionScript).toContain(
'const workspacesToExclude = [\n' +
" '@qwen-code/sdk',\n" +
" '@qwen-code/mobile-mcp',\n" +
" '@qwen-code/node-repl-mcp',\n" +
" '@qwen-code/qwen-live',\n" +
'];',
);
expect(versionScript).toContain('INDEPENDENT_PACKAGES.map((name)');
expect(INDEPENDENT_PACKAGES).toContain('@qwen-code/node-repl-mcp');
});
it('smoke-tests the real worktree bootstrap on every supported host', () => {
@@ -949,22 +944,46 @@ describe('package scripts', () => {
expect(workflow.on.push.paths).toEqual(expectedPaths);
});
it('builds the standalone qwen-live daemon in the root build order', () => {
it('includes the standalone qwen-live daemon in recursive root builds', () => {
const buildScript = readFileSync(
path.join(root, 'scripts/build.js'),
'utf8',
);
// The qwen-live e2e harness spawns packages/qwen-live/dist/index.js and
// the workspace unit tests run from src, so this pin is what catches the
// root build silently dropping the package.
const startIndex = buildScript.indexOf('const buildOrder = [');
expect(startIndex).toBeGreaterThan(-1);
const buildOrder = buildScript.slice(
startIndex,
buildScript.indexOf('];', startIndex),
expect(buildScript).toContain('corepack pnpm -r');
expect(buildScript).not.toContain('!@qwen-code/qwen-live');
expect(readPackageJson().workspaces).toContain('packages/*');
});
it('selects the CLI dependency closure without selecting the same-named root', () => {
const buildScript = readFileSync(
path.join(root, 'scripts/build.js'),
'utf8',
);
expect(buildOrder).toContain("'packages/qwen-live',");
const selector = buildScript.match(/\? '--filter "([^"]+)"/)?.[1];
expect(selector).toBeDefined();
const result = spawnSync(
'corepack',
['pnpm', '--filter', selector, 'list', '--depth', '-1', '--json'],
{ cwd: root, encoding: 'utf8', shell: process.platform === 'win32' },
);
expect(result.status, result.stderr).toBe(0);
const paths = JSON.parse(result.stdout).map((pkg) =>
path.relative(root, pkg.path).replaceAll('\\', '/'),
);
expect(paths).toEqual(
expect.arrayContaining([
'packages/cli',
'packages/core',
'packages/browser-use',
'packages/sdk-typescript',
'packages/web-shell',
'packages/web-templates',
]),
);
expect(paths).not.toContain('');
expect(paths).not.toContain('packages/mobile-mcp');
expect(paths).not.toContain('packages/vscode-ide-companion');
});
it('keeps the Mem0 Extension manifest aligned with release versions', () => {
@@ -1469,12 +1488,14 @@ describe('package scripts', () => {
// The validation jobs reuse the anchored install step; only the anchor
// definition plus prepare and publish appear as full textual copies.
expect(installSteps.length).toBe(3);
for (const installStep of installSteps.slice(0, 2)) {
expect(installStep).toContain('npm run generate');
}
for (const installStep of installSteps) {
expect(installStep).toContain(
'corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only',
);
expect(installStep).toContain('npm run postinstall');
expect(installStep).toContain('npm run generate');
expect(installStep).not.toContain('QWEN_SKIP_PREPARE');
expect(installStep).not.toContain('CI_BOT_PAT');
}
@@ -1486,6 +1507,9 @@ describe('package scripts', () => {
}
const publishJob = getWorkflowJob(workflow, 'publish');
expect(getWorkflowStep(publishJob, 'Install Dependencies')).not.toContain(
'npm run generate',
);
expect(publishJob.slice(0, publishJob.indexOf('steps:'))).not.toContain(
'CI_BOT_PAT',
);
@@ -1542,10 +1566,10 @@ describe('package scripts', () => {
expect(releaseStepScript).toContain('already published; skipping');
expect(releaseStepScript).toContain('exit 0');
expect(releaseStepScript).toContain(
'npm publish --provenance "${publish_args[@]}"',
'corepack pnpm publish --no-git-checks --provenance "${publish_args[@]}"',
);
expect(releaseStepScript).toContain(
'Every channel package was already published; nothing shipped',
'corepack pnpm -r publish "${publish_args[@]}"',
);
});
+107
View File
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { expect, it } from 'vitest';
import semver from 'semver';
import { INDEPENDENT_PACKAGES } from '../release-packages.mjs';
import { getWorkspacePackageJsonPaths } from '../workspaces.js';
it.each(['patch', '99.0.0-preview.1'])(
'aligns coupled versions (%s) without changing independent packages or the pnpm lockfile',
(version) => {
const root = process.cwd();
const directory = mkdtempSync(join(tmpdir(), 'release-versioning-'));
const read = (path) =>
JSON.parse(readFileSync(join(directory, path), 'utf8'));
const manifest = JSON.parse(readFileSync('package.json', 'utf8'));
const paths = getWorkspacePackageJsonPaths(root, manifest.workspaces);
const extensionPath =
'integrations/external-context-mem0/qwen-extension.json';
try {
for (const path of [
'package.json',
'pnpm-workspace.yaml',
'pnpm-lock.yaml',
'.pnpmfile.mjs',
'scripts/version.js',
'scripts/release-packages.mjs',
'scripts/workspaces.js',
extensionPath,
...paths,
]) {
mkdirSync(dirname(join(directory, path)), { recursive: true });
copyFileSync(join(root, path), join(directory, path));
}
symlinkSync(
join(root, 'node_modules'),
join(directory, 'node_modules'),
'junction',
);
// An internal workspace can lag behind; patch must resolve from the root,
// not bump each package's existing version independently.
const internalPath = 'packages/core/package.json';
writeFileSync(
join(directory, internalPath),
JSON.stringify({ ...read(internalPath), version: '0.0.1' }),
);
const result = spawnSync(
process.execPath,
['scripts/version.js', version],
{
cwd: directory,
encoding: 'utf8',
timeout: 60_000,
},
);
expect(result.status, result.stdout + result.stderr).toBe(0);
const expected =
version === 'patch' ? semver.inc(manifest.version, 'patch') : version;
expect(read('package.json').version).toBe(expected);
for (const path of paths) {
const before = JSON.parse(readFileSync(join(root, path), 'utf8'));
const after = read(path);
if (INDEPENDENT_PACKAGES.includes(before.name)) {
expect(after, path).toEqual(before);
} else {
expect(after.version, path).toBe(expected);
}
if (
before.dependencies?.['@qwen-code/channel-base'] &&
!before.dependencies['@qwen-code/channel-base'].startsWith('file:')
) {
expect(after.dependencies['@qwen-code/channel-base'], path).toBe(
expected,
);
}
expect(after.private, path).toBe(before.private);
}
expect(read(extensionPath).version).toBe(expected);
for (const path of ['package.json', 'packages/cli/package.json']) {
expect(read(path).config.sandboxImageUri).toBe(
`ghcr.io/qwenlm/qwen-code:${expected}`,
);
}
expect(readFileSync(join(directory, 'pnpm-lock.yaml'), 'utf8')).toBe(
readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8'),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
60_000,
);
+72 -90
View File
@@ -24,6 +24,7 @@ import { globSync } from 'glob';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
import { PUBLISHED_PACKAGES } from '../assert-release-version.mjs';
import { RELEASE_WORKSPACES } from '../release-packages.mjs';
import { getTestCiWorkspacePackageJsonPaths } from '../workspaces.js';
// `realpath -m` (the script's canonicalization line) is a GNU coreutils
@@ -439,7 +440,7 @@ describe('release workflow', () => {
'fetch-depth': 1,
'persist-credentials': false,
'sparse-checkout':
'/.github/scripts\n/scripts/assert-release-version.mjs',
'/.github/scripts\n/scripts/assert-release-version.mjs\n/scripts/release-packages.mjs\n/scripts/workspaces.js\n/package.json\n/packages/*/package.json\n/packages/channels/*/package.json\n/integrations/*/package.json',
'sparse-checkout-cone-mode': false,
path: '.release-workflow',
});
@@ -503,44 +504,31 @@ describe('release workflow', () => {
expect(swept).toBeGreaterThan(10);
});
it('keeps the publish allowlist and the guard package set in step', () => {
// The push-time guard only probes what `PUBLISHED_PACKAGES` lists. A
// channel added to the publish loop but not to that array ships without
// ever being probed, so a retry of a partial release reports
// "unreleased" and force-pushes over the tip the shipped package anchors
// to. Before this, the only tie was a comment pointing at publish steps
// that no longer exist in release.yml.
const loop = releaseStepScript.match(/for channel in ([^;]+); do/);
expect(loop, 'publish-packages channel loop').not.toBeNull();
const published = loop[1].trim().split(/\s+/).sort();
const guarded = PUBLISHED_PACKAGES.filter((name) =>
name.startsWith('@qwen-code/channel-'),
)
.map((name) => name.replace('@qwen-code/channel-', ''))
.filter((name) => name !== 'base')
.sort();
expect(published).toEqual(guarded);
// Non-channel packages are literal calls, so verify those against the
// guard too. Resolve their names from package.json to avoid a second map.
const publishStep = releaseStepScript.slice(
releaseStepScript.indexOf('\n publish-packages)'),
releaseStepScript.indexOf('\n verify-archives)'),
);
const literalTargets = [
...publishStep.matchAll(/publish_package '([^']+)'/g),
].map(([, directory]) => directory);
expect(literalTargets.length).toBeGreaterThan(1);
const guardedNames = new Set(PUBLISHED_PACKAGES);
for (const directory of literalTargets) {
// `dist` is the root package's bundle output rather than a workspace
// directory, and does not exist in a source checkout.
const manifest =
directory === 'dist' ? 'package.json' : `${directory}/package.json`;
const { name } = JSON.parse(readFileSync(manifest, 'utf8'));
expect(guardedNames.has(name), `${directory} publishes ${name}`).toBe(
true,
it('uses workflow-pinned manifests for publishing and the version guard', () => {
const directory = mkdtempSync(join(tmpdir(), 'release-selection-'));
try {
// A release-ref cwd must not change the workflow's trusted selection.
writeFileSync(join(directory, 'package.json'), '{"workspaces":[]}');
const result = spawnSync(
process.execPath,
[join(process.cwd(), 'scripts/release-packages.mjs')],
{
cwd: directory,
encoding: 'utf8',
env: {
...process.env,
PUBLISH_AUDIO_CAPTURE: 'true',
PUBLISH_EXTERNAL_CONTEXT_MEM0: 'true',
},
},
);
expect(result.status, result.stderr).toBe(0);
expect([
'@qwen-code/qwen-code',
...result.stdout.trim().split('\n'),
]).toEqual(PUBLISHED_PACKAGES);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
@@ -597,41 +585,29 @@ describe('release workflow', () => {
},
);
it.skipIf(process.platform === 'win32')(
'continues publishing after already-published packages',
() => {
it.skipIf(process.platform === 'win32').each([
{ dryRun: 'false', optional: 'false', failure: '0', cliPublished: '1' },
{ dryRun: 'false', optional: 'true', failure: '0', cliPublished: '0' },
{ dryRun: 'true', optional: 'true', failure: '0', cliPublished: '0' },
{ dryRun: 'false', optional: 'true', failure: '7', cliPublished: '1' },
])(
'publishes the selected workspaces and CLI safely: %j',
({ dryRun, optional, failure, cliPublished }) => {
const directory = mkdtempSync(join(tmpdir(), 'release-publish-'));
const bin = join(directory, 'bin');
const publishLog = join(directory, 'published');
const channels = [
'dingtalk',
'dws',
'feishu',
'github',
'qqbot',
'telegram',
'wecom',
'weixin',
];
mkdirSync(bin);
for (const path of [
'dist',
'packages/web-shell',
'packages/channels/base',
...channels.map((channel) => `packages/channels/${channel}`),
]) {
mkdirSync(join(directory, path), { recursive: true });
}
writeFileSync(join(bin, 'node'), '#!/bin/sh\nbasename "$PWD"\n', {
mkdirSync(join(directory, 'dist'));
writeFileSync(
join(directory, 'dist/package.json'),
JSON.stringify({ name: '@qwen-code/qwen-code' }),
);
writeFileSync(join(bin, 'npm'), '#!/bin/sh\nexit "$CLI_PUBLISHED"\n', {
mode: 0o755,
});
writeFileSync(
join(bin, 'npm'),
'#!/bin/sh\n' +
'if [ "$1" = view ]; then\n' +
' case "$PWD" in */channels/base) exit 1 ;; */channels/*) exit 0 ;; *) exit 1 ;; esac\n' +
'fi\n' +
'if [ "$1" = publish ]; then printf "%s\\t%s\\n" "$PWD" "$*" >> "$PUBLISH_LOG"; fi\n',
join(bin, 'corepack'),
'#!/bin/sh\nprintf "%s\\t%s\\n" "$PWD" "$*" >> "$PUBLISH_LOG"\nexit "$PUBLISH_FAILURE"\n',
{ mode: 0o755 },
);
try {
@@ -644,38 +620,51 @@ describe('release workflow', () => {
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
IS_DRY_RUN: 'false',
NPM_TAG: 'latest',
PUBLISH_AUDIO_CAPTURE: 'false',
PUBLISH_EXTERNAL_CONTEXT_MEM0: 'false',
IS_DRY_RUN: dryRun,
NPM_TAG: 'preview',
PUBLISH_AUDIO_CAPTURE: optional,
PUBLISH_EXTERNAL_CONTEXT_MEM0: optional,
PUBLISH_FAILURE: failure,
CLI_PUBLISHED: cliPublished,
PUBLISH_LOG: publishLog,
RELEASE_VERSION: '1.2.3',
},
},
);
expect(result.status).toBe(0);
expect(result.status, result.stderr).toBe(Number(failure));
const canonicalDirectory = realpathSync(directory);
const publishCalls = readFileSync(publishLog, 'utf8')
.trim()
.split('\n')
.map((line) => line.split('\t'));
const publishesCli =
failure === '0' && (dryRun === 'true' || cliPublished !== '0');
expect(publishCalls.map(([cwd]) => cwd)).toEqual([
join(canonicalDirectory, 'dist'),
join(canonicalDirectory, 'packages/channels/base'),
join(canonicalDirectory, 'packages/web-shell'),
canonicalDirectory,
...(publishesCli ? [join(canonicalDirectory, 'dist')] : []),
]);
// The dist-tag is the reason NPM_TAG is set in this child env at all.
// Without it `npm publish` defaults to `latest`, so the 21:00 UTC
// nightly would take over the tag every end-user install and the ECS
// fleet updater resolve through. `--access public` is here for the
// same reason: one array feeds all thirteen published packages.
const selected = publishCalls[0][1]
.split(' ')
.filter((arg) => arg.startsWith('--filter='))
.map((arg) => arg.slice('--filter='.length));
expect(selected).toEqual(
RELEASE_WORKSPACES.filter(
(name) =>
optional === 'true' ||
![
'@qwen-code/audio-capture',
'@qwen-code/external-context-mem0',
].includes(name),
),
);
expect(publishCalls[0][1]).toContain('pnpm -r publish');
for (const [cwd, args] of publishCalls) {
expect(args, cwd).toContain('--access public');
expect(args, cwd).toContain('--tag=latest');
expect(args, cwd).toContain('--tag=preview');
expect(args, cwd).toContain('--provenance');
expect(args.includes('--dry-run'), cwd).toBe(dryRun === 'true');
}
expect(result.stdout).toContain(
'Every channel package was already published; nothing shipped',
);
expect(publishCalls[0][1].includes('--force')).toBe(dryRun === 'true');
} finally {
rmSync(directory, { recursive: true, force: true });
}
@@ -2290,13 +2279,6 @@ describe('release workflow', () => {
expect(publishStep.env.PUBLISH_AUDIO_CAPTURE).toContain(
"github.repository == 'QwenLM/qwen-code'",
);
expect(releaseStepScript).toContain(
'if [[ "${PUBLISH_AUDIO_CAPTURE}" == "true" ]]; then',
);
expect(releaseStepScript).toContain('integrations/external-context-mem0');
expect(
releaseStepScript.indexOf('integrations/external-context-mem0'),
).toBeLessThan(releaseStepScript.indexOf('packages/audio-capture'));
});
it('fires the fleet-moving npm-published dispatch on stable releases only', () => {
+7 -49
View File
@@ -5,14 +5,9 @@
*/
import { execSync } from 'node:child_process';
import {
existsSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { INDEPENDENT_PACKAGES } from './release-packages.mjs';
// A script to handle versioning and ensure all related changes are in a single, atomic commit.
@@ -39,41 +34,15 @@ if (!versionType) {
process.exit(1);
}
// 2. Bump the version in the root and all workspace package.json files.
// --no-workspaces-update stops npm from reifying node_modules after each bump:
// the tree is pnpm's, and an npm reify would silently rewrite it into npm's own
// layout. pnpm-lock.yaml needs no refresh either: .pnpmfile.mjs rewrites every
// internal dependency to workspace:*, so a version bump leaves it unchanged.
// Resolve patch/minor/etc. once, then align all release workspaces to it.
run(
`npm version ${versionType} --no-git-tag-version --allow-same-version --no-workspaces-update`,
`corepack pnpm version ${versionType} --no-git-tag-version --allow-same-version --no-git-checks`,
);
// 3. Get all workspaces and filter out the one we don't want to version.
// We intend to maintain sdk, mobile-mcp, node-repl, and qwen-live versions
// independently.
const workspacesToExclude = [
'@qwen-code/sdk',
'@qwen-code/mobile-mcp',
'@qwen-code/node-repl-mcp',
'@qwen-code/qwen-live',
];
const workspaceNames = JSON.parse(
execSync('npm pkg get name --workspaces --json').toString(),
);
const allWorkspaces = Object.keys(workspaceNames);
const workspacesToVersion = allWorkspaces.filter(
(wsName) => !workspacesToExclude.includes(wsName),
);
for (const workspaceName of workspacesToVersion) {
run(
`npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version --no-workspaces-update`,
);
}
// 4. Get the new version number from the root package.json
const rootPackageJsonPath = resolve(process.cwd(), 'package.json');
const newVersion = readJson(rootPackageJsonPath).version;
run(
`corepack pnpm -r ${INDEPENDENT_PACKAGES.map((name) => `--filter="!${name}"`).join(' ')} version ${newVersion} --no-git-tag-version --allow-same-version --no-git-checks`,
);
// 5. Keep the published Mem0 Extension manifest aligned with its package.
const mem0ManifestPath = resolve(
@@ -128,15 +97,4 @@ for (const entry of readdirSync(channelsDir)) {
}
}
// 9. An npm reify can nest a stale registry copy of channel-base under an
// adapter while ranges briefly mismatch, where it shadows the workspace link
// during tsc. Nothing above reifies any more, but a tree an earlier npm install
// left behind can still carry that directory, so remove it.
for (const entry of readdirSync(channelsDir)) {
rmSync(join(channelsDir, entry, 'node_modules', '@qwen-code'), {
recursive: true,
force: true,
});
}
console.log(`Successfully bumped versions to v${newVersion}.`);
+1 -2
View File
@@ -4,9 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { globSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { globSync } from 'glob';
function toGlobPath(filePath) {
return filePath.replaceAll('\\', '/');