mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
ec109102e0
commit
baf0ff41f7
30 files changed
+488
-292
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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,5 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"private": true,
|
||||
"version": "0.24.3",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"private": true,
|
||||
"version": "0.24.3",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.tsbuildinfo",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/web-templates",
|
||||
"private": true,
|
||||
"version": "0.24.3",
|
||||
"description": "Web templates bundled as embeddable JS/CSS strings",
|
||||
"repository": {
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[@]}"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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
@@ -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}.`);
|
||||
@@ -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('\\', '/');
|
||||
|
||||
Reference in new issue
Block a user