ci(pnpm): install with pnpm everywhere and retire package-lock.json (#11859)

* ci(pnpm): install dependencies with pnpm in CI and release

CI tests the graph release ships only if both install it the same way, so
this moves both, keeping every `npm run` script and `npm publish` as they are.

- ci.yml (7), release.yml (3) and release-vscode-companion.yml (2) install
  with `corepack pnpm install --frozen-lockfile`; release keeps
  --ignore-scripts plus its explicit postinstall and generate steps.
- setup-node drops the npm cache and sets package-manager-cache: false,
  which setup-node v5+ needs once package.json declares a pnpm
  packageManager.
- A composite action caches the pnpm store on the three always-hosted jobs;
  the ECS pool keeps its store on local disk.
- version.js no longer lets npm reify node_modules: --no-workspaces-update on
  both `npm version` calls, and a package-lock-only refresh in step 9.
- The notices generator, prepare-package's sharp pin and the standalone
  release's native package specs read the installed tree or pnpm-lock.yaml
  instead of package-lock.json keys.
- New size baselines for the three workflows, which were already over them.

* chore(vscode): regenerate NOTICES from the pnpm-installed tree

Generated in CI (pnpm Worktree Smoke on ubuntu-latest) by the ported
generator. Same entry set as before except the five packages npm and pnpm
resolve differently: fdir 6.5.0, picomatch 4.0.5, mdurl 2.1.0,
@types/node 22.20.1 and mime-db 1.52.0 (form-data's pinned copy). No entry
lacks a license text or repository.

* fix(pnpm): resolve undeclared zod peers to the repo's zod, as npm does

The first CI run on this branch failed the export renderer's size budget:
1,930,015 bytes against 1,930,000, where the same commit built on npm's
tree comes to 1,858,186. Comparing both trees' esbuild metafiles and
prebuilt bundles put the whole difference in web-shell's transcript.js
(+100,903 bytes pre-minify), and the added code is zod 4 plus the
@modelcontextprotocol/ext-apps schemas; the sdk daemon bundle is identical.

web-shell bundles ext-apps, whose zod and MCP SDK are peers, and declares
no zod itself, so pnpm filled the peer with the highest locked zod (4.4.3)
while npm serves the hoisted 3.25.76. Declaring zod 3.25.76 as a root
devDependency makes pnpm resolve every such peer to it
(resolve-peers-from-workspace-root is on by default), which is npm's
hoisting behaviour. mobile-mcp keeps its own zod 4.4.3, as under npm; the
npm tree does not change.

* fix(pnpm): keep npm's root @types/node so root-level typechecks match

The no-AK integration gate typechecks integration-tests/, which takes
Node's types from the root node_modules. npm hoists @types/node 20.19.1
there, because acp-bridge and core pin it; pnpm, with no root declaration,
hoisted 22.20.1, whose spawnSync overloads type process-registry.ts's
stdout as string | NonSharedBuffer and fail TS2322. The same code passes on
every npm-installed PR.

Declaring 20.19.1 as a root devDependency puts npm's copy back where a
root-level tsconfig looks. The npm tree does not change.

* ci(pnpm): install with pnpm in the remaining workflows

The follow-up to switching CI and release: every other workflow that
installs the root workspace now uses the pinned pnpm with
--frozen-lockfile, so no job tests a graph that release does not ship.

- e2e, tui-parity, repo-hygiene, web-shell-visuals, serve-ab, sdk-java,
  windows-runner-smoke, qwen-autofix, the SDK releases, finalize-release,
  sync-release-to-oss, the root install in desktop-release and
  cd-cua-driver, and the sandbox Dockerfile.
- setup-node drops the root npm cache (package-manager-cache: false); hosted
  jobs restore the pnpm store through the shared composite action, and
  qwen-autofix keeps af-012's rule that the persistent pool restores no
  remote cache.
- serve-ab and web-shell-visuals install a separate base checkout that can
  predate pnpm-lock.yaml, so those installs fall back to npm ci there.
- Out of scope, unchanged: packages with their own lockfile (cua-driver's
  TypeScript SDK, desktop-shell, live-host, mobile-mcp), and the triage
  lanes with npm-cache.yml and the npm audit gate, which move with the
  package-lock.json retirement.
- New size baselines for the touched workflows; qwen-autofix stays under
  the 470000-byte gate.

* fix(pnpm): link the workspace packages that root-level code imports

npm links every workspace package into the root node_modules, so
integration-tests/ and scripts/, which are not workspace packages
themselves, can import @qwen-code/* by name. pnpm links a workspace
package only where it is a declared dependency, and the root declared
none, so the no-AK integration gate could not find
@qwen-code/qwen-code-core.

The root now declares the seven packages that root-level code imports at
runtime as file: devDependencies: acp-bridge, channel-base, the CLI,
core, sdk, web-shell and web-templates. npm already links them, so its
tree does not change (letting npm re-resolve the lock reproduces the
same root entry); .pnpmfile.mjs rewrites them to workspace:*, which pnpm
links at the root.

* ci(pnpm): move triage, the store producer and the root audit to pnpm

- qwen-triage's verify and tmux-testing lanes install the PR with
  `corepack pnpm install --frozen-lockfile` from a restored pnpm store.
  The one retry clears node_modules first, because pnpm install, unlike
  npm ci, keeps it.
- npm-cache.yml becomes pnpm-store.yml, keyed on pnpm-lock.yaml.
- security-checks audits the root workspace with `pnpm audit`, which reads
  the lockfile, so the root install step goes. The vendored lockfiles keep
  npm, and the retry recognises pnpm's endpoint error too.
- qwen-autofix drops the hosted-only store cache: its heavy jobs forbid
  local `uses: './...'` actions, and the file sits at the size gate.
- cd-cua-driver: setup-node@v4 has no package-manager-cache input.
- package-assets fixtures install sharp rather than listing it in
  package-lock.json, matching how prepare-package now resolves it.
- AGENTS.md, CONTRIBUTING.md and the npm workspace guide install with pnpm.

* feat(review): install pnpm repos in qwen review's build/test

qwen review installed dependencies only for a repo with package-lock.json
and reported a pnpm repo as unsupported, so reviews of qwen-code would
lose their build and test once its npm lockfile goes.

- lockfileInstaller() picks npm or pnpm from the committed lockfile. With
  both, `packageManager: pnpm@...` chooses pnpm; anything else keeps npm.
- A pnpm repo installs with `corepack pnpm install --frozen-lockfile`
  through the same disk, budget and sandbox path as `npm ci`, and counts
  as installed once node_modules/.modules.yaml exists.
- Scripts still run through `npm run`, and the npm path is unchanged.

* ci(pnpm): retire the root package-lock.json

pnpm-lock.yaml is now the only root lockfile.

- check-lockfile keeps the pnpm integrity check and Playwright parity,
  which now reads pnpm importers and snapshots. The npm-vs-pnpm agreement
  and build-approval checks go: pnpm 11's strictDepBuilds and every CI
  install's --frozen-lockfile enforce what they covered.
- pnpm-lock-freshness.yml goes; it regenerated pnpm-lock.yaml from
  package-lock.json.
- Release: version.js drops its package-lock refresh, the release commit
  stages pnpm-lock.yaml and the SDK release stages no lockfile, so neither
  `git add` fails on the missing file. `npm version` in the SDK and
  mobile-mcp releases passes --no-workspaces-update so it cannot reinstall
  the root with npm.
- cd-mobile-mcp installs from the root pnpm lockfile. setup-node in
  cd-mobile-mcp, qwen-code-pr-review and desktop-release no longer keys an
  npm cache on the root lockfile.
- check-desktop-isolation reads pnpm importers; build.js, build_sandbox.js
  and preflight install with pnpm.
- pnpm-lock.yaml, pnpm-workspace.yaml and .pnpmfile.mjs join the autofix
  supply-chain class and repo-hygiene's deny list; pnpm-lock.yaml joins
  autofix's generated-diff exclusions, sdk-java's path filter and the
  platform-sensitivity manifests.
- .gitignore lists /package-lock.json so an npm install cannot commit it
  back. Tests and docs follow.

* fix(standalone): read the node-pty versions from the pnpm lockfile

* chore(vscode-ide-companion): regenerate NOTICES.txt after the lockfile swap

* chore(vscode-ide-companion): regenerate NOTICES.txt from the pnpm dependency tree

The committed NOTICES.txt was generated from an npm install and does not
match the tree produced by the pnpm install the release pipeline now uses
(659 resolved dependencies, differing @types/node and mime-db versions).
Regenerating with the pinned pnpm install makes the freshness check
reproducible.

* fix(ci): drop the npm cache from the daily dependency audit

actions/setup-node's npm cache keys off the root package-lock.json, which this branch retires, so the scheduled job would fail at Set up Node before the audit ran. No pull_request run reaches this workflow to catch it. The root tree is never installed here either: pnpm audit reads pnpm-lock.yaml directly.

Matches the sibling workflows that already set package-manager-cache: false; scripts/tests/security-workflows.test.js now pins it.

* chore(ci): stop banking main-side drift in the workflow size ratchet

The ratchet exists to turn a workflow's growth into one reviewed line. This branch re-recorded entries for 17 workflows it never touches, several upward by 0.4-2.8 KB - drift that happened on main, banked into the manifest without review.

Restore those to their merge-base values, so .size-baseline changes only for the 22 workflows this branch actually edits. The entries were not needed to go green: ci.yml passes the PR's base commit, so a file that drifted on main and is otherwise untouched earns a warning, not a failure.

* fix(release): initialize standalone state before main

* test(browser-use): read Playwright pin from pnpm lock

* fix(ci): address pnpm migration review blockers

* fix(desktop): stop reading the retired root lockfile

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
易良 2026-09-20 02:11:25 +00:00 • committed by GitHub
parent 3e95761208
commit 64dd058c9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 2080 additions and 34789 deletions

View file

@ -0,0 +1,20 @@
name: 'pnpm store cache'
description: >-
Restore and save the pnpm content-addressable store on hosted runners,
keyed on pnpm-lock.yaml. Self-hosted runners keep their store on local disk
between jobs, so they do not need this.
runs:
using: 'composite'
steps:
- name: 'Locate the pnpm store'
id: 'store'
shell: 'bash'
run: 'echo "path=$(corepack pnpm store path --silent)" >> "${GITHUB_OUTPUT}"'
- name: 'Cache the pnpm store'
uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
with:
path: '${{ steps.store.outputs.path }}'
key: "pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
restore-keys: 'pnpm-store-${{ runner.os }}-'

View file

@ -42,9 +42,10 @@ function lintStep(name) {
}
describe('ci.yml disk-pressure evidence', () => {
it('starts sampling before npm ci and preserves those samples for upload', () => {
it('starts sampling before the install and preserves those samples for upload', () => {
const install = step('Install dependencies').run;
const npmCi = install.indexOf('npm ci');
const npmCi = install.indexOf('pnpm install');
assert.ok(npmCi !== -1, 'the install step must run pnpm install');
assert.match(
install,
@ -241,9 +242,11 @@ describe('ci.yml disk-pressure evidence', () => {
it('keeps install failure status while writing the pre-install sample', () => {
const root = mkdtempSync(join(tmpdir(), 'ci-disk-pressure-'));
const npm = join(root, 'npm');
writeFileSync(npm, '#!/usr/bin/env bash\nexit 42\n');
chmodSync(npm, 0o755);
// The install step runs `corepack pnpm install`; stub corepack so the
// failing exit comes from the install command itself.
const corepack = join(root, 'corepack');
writeFileSync(corepack, '#!/usr/bin/env bash\nexit 42\n');
chmodSync(corepack, 0o755);
try {
const result = spawnSync(

View file

@ -47,7 +47,7 @@ const RUNNER_CONFIG = /(?:^|\/)vitest(?:\.[^/]*)?\.config\.[cm]?[jt]s$/i;
// The dependency and script manifests: a changed `test:ci`, a native module, or
// an optional per-platform dependency changes what each lane executes.
const MANIFEST = new Set(['package.json', 'package-lock.json']);
const MANIFEST = new Set(['package.json', 'pnpm-lock.yaml']);
// Source subtrees whose subject IS the host.
//

View file

@ -75,7 +75,7 @@ test('the runner configuration decides which lane runs what', () => {
test('the manifests change what each lane executes', () => {
assert.equal(classifyChangedFiles(['package.json']), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(['package-lock.json']), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(['pnpm-lock.yaml']), PLATFORM_SENSITIVE);
// A workspace manifest is not the root one; it reaches the lanes through
// the subsystem rules or not at all.
assert.equal(

View file

@ -33,7 +33,7 @@ const cacheProducerPath = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'workflows',
'npm-cache.yml',
'pnpm-store.yml',
);
const cacheProducerDoc = parse(readFileSync(cacheProducerPath, 'utf8'));
const prWorkflowPath = join(
@ -1139,16 +1139,19 @@ describe('qwen-triage: Stage 1e revert-pattern signals', () => {
});
});
describe('qwen-triage: npm cache restore-only invariant', () => {
describe('qwen-triage: pnpm store restore-only invariant', () => {
for (const [jobName, jobDef] of [
['verify', verifyJob],
['tmux-testing', tmuxJob],
]) {
it(`${jobName}: uses actions/cache/restore with no save path`, () => {
const cacheStep = jobDef.steps.find(
(s) => s.name === 'Restore npm cache',
(s) => s.name === 'Restore pnpm store',
);
assert.ok(
cacheStep,
`'Restore pnpm store' step must exist in ${jobName}`,
);
assert.ok(cacheStep, `'Restore npm cache' step must exist in ${jobName}`);
assert.match(
cacheStep.uses,
/^actions\/cache\/restore@/,
@ -1165,9 +1168,9 @@ describe('qwen-triage: npm cache restore-only invariant', () => {
}
});
it(`${jobName}: npm ci --cache matches the restored directory`, () => {
it(`${jobName}: pnpm install --store-dir matches the restored directory`, () => {
const cacheStep = jobDef.steps.find(
(s) => s.name === 'Restore npm cache',
(s) => s.name === 'Restore pnpm store',
);
const prepareStep = jobDef.steps.find(
(s) => s.name === 'Install and build PR app',
@ -1182,25 +1185,25 @@ describe('qwen-triage: npm cache restore-only invariant', () => {
);
assert.ok(dir, 'cache path must resolve to a directory name');
assert.ok(
prepareStep.run.includes(`--cache "$RUNNER_TEMP/${dir}"`),
`npm ci must use --cache "$RUNNER_TEMP/${dir}"`,
prepareStep.run.includes(`--store-dir "$RUNNER_TEMP/${dir}"`),
`pnpm install must use --store-dir "$RUNNER_TEMP/${dir}"`,
);
});
it(`${jobName}: clears stale npm cache before restore`, () => {
it(`${jobName}: clears stale pnpm store before restore`, () => {
const clearIdx = jobDef.steps.findIndex(
(s) => s.name === 'Clear stale npm cache',
(s) => s.name === 'Clear stale pnpm store',
);
const restoreIdx = jobDef.steps.findIndex(
(s) => s.name === 'Restore npm cache',
(s) => s.name === 'Restore pnpm store',
);
assert.ok(
clearIdx !== -1,
`'Clear stale npm cache' step must exist in ${jobName}`,
`'Clear stale pnpm store' step must exist in ${jobName}`,
);
assert.ok(
restoreIdx !== -1,
`'Restore npm cache' step must exist in ${jobName}`,
`'Restore pnpm store' step must exist in ${jobName}`,
);
assert.ok(
clearIdx < restoreIdx,
@ -1212,7 +1215,7 @@ describe('qwen-triage: npm cache restore-only invariant', () => {
'clear step must rm -rf the cache directory',
);
const cacheStep = jobDef.steps.find(
(s) => s.name === 'Restore npm cache',
(s) => s.name === 'Restore pnpm store',
);
const dir = cacheStep.with.path.replace(
/^\$\{\{\s*runner\.temp\s*\}\}\//,
@ -1226,34 +1229,34 @@ describe('qwen-triage: npm cache restore-only invariant', () => {
it(`${jobName}: reports the cache hit so a permanent miss is visible`, () => {
const cacheStep = jobDef.steps.find(
(s) => s.name === 'Restore npm cache',
(s) => s.name === 'Restore pnpm store',
);
assert.equal(
cacheStep.id,
'npm-cache',
'pnpm-store',
'restore step needs an id so its cache-hit output is readable',
);
const reportStep = jobDef.steps.find(
(s) => s.name === 'Report npm cache hit',
(s) => s.name === 'Report pnpm store hit',
);
assert.ok(reportStep, "'Report npm cache hit' step must exist");
assert.ok(reportStep, "'Report pnpm store hit' step must exist");
assert.match(
reportStep.run,
/steps\.npm-cache\.outputs\.cache-hit/,
/steps\.pnpm-store\.outputs\.cache-hit/,
'report step must surface the cache-hit output',
);
});
}
});
describe('qwen-triage: npm cache producer workflow', () => {
describe('qwen-triage: pnpm store producer workflow', () => {
const saveJob = cacheProducerDoc.jobs.save;
it('triggers on push to main only', () => {
const push = cacheProducerDoc.on.push ?? cacheProducerDoc[true]?.push;
assert.ok(push, 'must have a push trigger');
assert.deepEqual(push.branches, ['main']);
assert.deepEqual(push.paths, ['package-lock.json']);
assert.deepEqual(push.paths, ['pnpm-lock.yaml']);
});
it('saves with the same key and path the triage lanes restore', () => {
@ -1266,7 +1269,7 @@ describe('qwen-triage: npm cache producer workflow', () => {
['tmux-testing', tmuxJob],
]) {
const restoreStep = jobDef.steps.find(
(s) => s.name === 'Restore npm cache',
(s) => s.name === 'Restore pnpm store',
);
assert.equal(
saveStep.with.path,
@ -1292,12 +1295,12 @@ describe('qwen-triage: npm cache producer workflow', () => {
);
assert.ok(dir, 'save path must resolve to a directory name');
const populateStep = saveJob.steps.find(
(s) => s.name === 'Populate npm cache',
(s) => s.name === 'Populate pnpm store',
);
assert.ok(populateStep, "'Populate npm cache' step must exist");
assert.ok(populateStep, "'Populate pnpm store' step must exist");
assert.ok(
populateStep.run.includes(`--cache "$RUNNER_TEMP/${dir}"`),
`populate step must fill the saved cache directory (--cache "$RUNNER_TEMP/${dir}")`,
populateStep.run.includes(`--store-dir "$RUNNER_TEMP/${dir}"`),
`populate step must fill the saved store directory (--store-dir "$RUNNER_TEMP/${dir}")`,
);
});

View file

@ -731,7 +731,7 @@ sensitive_class_of() {
scripts/tests/*) ;;
scripts/*) echo 'repo-scripts' ;;
.npmrc | .nvmrc | */.npmrc | */.nvmrc) echo 'toolchain-config' ;;
package-lock.json | npm-shrinkwrap.json | */package-lock.json | */npm-shrinkwrap.json | patches/*) echo 'supply-chain' ;;
package-lock.json | npm-shrinkwrap.json | */package-lock.json | */npm-shrinkwrap.json | pnpm-lock.yaml | pnpm-workspace.yaml | .pnpmfile.mjs | patches/*) echo 'supply-chain' ;;
.gitattributes | */.gitattributes) echo 'measurement-config' ;;
*) case "${f##*/}" in
eslint.config.* | eslint.legacy-filenames.mjs | eslint.legacy-core-barrel-imports.mjs | vitest.config.* | tsconfig.json | tsconfig.*.json)

View file

@ -123,7 +123,7 @@ case "${step}" in
push-release-branch)
release_branch_name="${BRANCH_NAME:?}"
git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json integrations/*/package.json integrations/*/qwen-extension.json
git add package.json pnpm-lock.yaml packages/*/package.json packages/channels/*/package.json integrations/*/package.json integrations/*/qwen-extension.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else

View file

@ -17,56 +17,55 @@
3480 audio-capture-prebuilds.yml
9023 auto-minimize-spam.yml
9256 build-and-publish-image.yml
49610 cd-cua-driver.yml
2076 cd-mobile-mcp.yml
137297 ci.yml
50773 cd-cua-driver.yml
2222 cd-mobile-mcp.yml
137804 ci.yml
1482 codeql.yml
9389 comment-attachment-guard.yml
1634 desktop-packaging-check.yml
35684 desktop-release.yml
38401 desktop-release.yml
2038 docs-page-action.yml
10005 dsw-swe-verified-release.yml
32363 e2e.yml
11394 finalize-release.yml
33227 e2e.yml
11634 finalize-release.yml
16647 live-host-release.yml
5950 live-host.yml
1343 mobile-shell.yml
7642 main-ci-failure-issue.yml
1686 npm-cache.yml
2709 pnpm-lock-freshness.yml
2277 pnpm-store.yml
2489 pnpm-worktree-smoke.yml
7299 pr-force-push-reminder.yml
6495 pr-self-report-label.yml
9646 qwen-autofix-fork-bridge.yml
5942 qwen-autofix-fork-signal.yml
469165 qwen-autofix.yml
466739 qwen-autofix.yml
7061 qwen-ci-flaky-rerun.yml
265415 qwen-code-pr-review.yml
265236 qwen-code-pr-review.yml
107075 qwen-fleet-shepherd.yml
22680 qwen-issue-followup-bot.yml
5760 qwen-pr-safety-precheck.yml
2518 qwen-review-runner-schedule.yml
27648 qwen-triage-finalize.yml
350381 qwen-triage.yml
9657 release-sdk-java.yml
22037 release-sdk-python.yml
19094 release-sdk.yml
14546 release-vscode-companion.yml
34861 release.yml
43717 repo-hygiene.yml
354514 qwen-triage.yml
9701 release-sdk-java.yml
22345 release-sdk-python.yml
20008 release-sdk.yml
16655 release-vscode-companion.yml
35955 release.yml
44285 repo-hygiene.yml
1079 scorecard-monthly.yml
10691 sdk-java.yml
12011 sdk-java.yml
3886 sdk-python.yml
3197 security-checks.yml
5595 security-checks.yml
6777 serve-ab-publish.yml
17013 serve-ab.yml
20096 serve-ab.yml
2641 stale.yml
11328 sync-desktop-to-oss.yml
10018 sync-live-host-to-oss.yml
10988 sync-release-to-oss.yml
2508 tui-parity.yml
11206 sync-release-to-oss.yml
2676 tui-parity.yml
7187 update-ecs-runner-qwen.yml
2307 web-shell-visuals-cleanup.yml
20047 web-shell-visuals-publish.yml
16384 web-shell-visuals.yml
4712 windows-runner-smoke.yml
17127 web-shell-visuals.yml
5155 windows-runner-smoke.yml

View file

@ -699,14 +699,12 @@ jobs:
- uses: 'actions/setup-node@v4'
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Install npm 11'
run: 'npm install --global npm@11.19.0'
- name: 'Install workspace dependencies'
run: 'npm ci --ignore-scripts --no-audit --no-fund --progress=false'
run: 'corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only'
- name: 'Test standalone Node REPL package'
working-directory: 'packages/node-repl'
run: |

View file

@ -29,8 +29,7 @@ jobs:
- uses: 'actions/setup-node@v5'
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
@ -47,13 +46,14 @@ jobs:
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# mobile-mcp is a root workspace package, so it installs from the root
# pnpm lockfile, as `npm ci` in this directory used the root npm one.
- name: 'Install dependencies'
working-directory: 'packages/mobile-mcp'
run: 'npm ci --ignore-scripts'
run: 'corepack pnpm install --frozen-lockfile --ignore-scripts --reporter=append-only'
- name: 'Set version'
working-directory: 'packages/mobile-mcp'
run: 'npm version "${{ steps.version.outputs.version }}" --no-git-tag-version'
run: 'npm version "${{ steps.version.outputs.version }}" --no-git-tag-version --no-workspaces-update'
- name: 'Build'
working-directory: 'packages/mobile-mcp'

View file

@ -577,8 +577,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Use pre-installed Node.js (self-hosted)'
@ -626,7 +625,7 @@ jobs:
( while sleep 10; do sample_disk; done ) &
SAMPLER_PID=$!
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (self-hosted)'
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
@ -1074,8 +1073,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Use pre-installed Node.js (self-hosted)'
@ -1123,7 +1121,7 @@ jobs:
( while sleep 10; do sample_disk; done ) &
SAMPLER_PID=$!
trap 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true; kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (self-hosted)'
if: "${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
@ -1431,10 +1429,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
@ -1463,7 +1464,7 @@ jobs:
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Install Playwright Chromium (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
@ -1583,10 +1584,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
@ -1598,7 +1602,7 @@ jobs:
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
@ -1727,10 +1731,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
@ -1770,7 +1777,7 @@ jobs:
- name: 'Install dependencies'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
@ -1982,8 +1989,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
@ -2007,7 +2013,7 @@ jobs:
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (self-hosted)'
if: "${{ always() && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}"
@ -2195,8 +2201,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
@ -2221,7 +2226,7 @@ jobs:
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Report npm cache usage (self-hosted)'
if: "${{ always() && runner.environment == 'self-hosted' }}"

View file

@ -277,9 +277,7 @@ jobs:
with:
node-version: '${{ env.NODE_VERSION }}'
cache: 'npm'
cache-dependency-path: |
package-lock.json
packages/desktop-shell/package-lock.json
cache-dependency-path: 'packages/desktop-shell/package-lock.json'
- name: 'Configure bundled Node.js cache'
id: 'node-cache-path'
@ -313,7 +311,12 @@ jobs:
working-directory: '${{ runner.temp }}/qwen-code-source'
env:
QWEN_SKIP_PREPARE: '1'
run: 'npm ci --no-audit --progress=false'
run: |-
if [ -f pnpm-lock.yaml ]; then
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
else
npm ci --no-audit --progress=false
fi
- name: 'Install desktop dependencies'
working-directory: 'packages/desktop-shell'

View file

@ -58,10 +58,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
@ -74,7 +77,7 @@ jobs:
# The install must not build; the two steps below do it explicitly.
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Build project'
run: |-
@ -230,10 +233,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '${{ matrix.node-version }}'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'
@ -262,7 +268,7 @@ jobs:
# so it keeps building during install.)
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
# The bundle and workspace dist/ trees come from the `build` job.
# One bounded retry, same transient runner-to-blobstore contract as
@ -385,10 +391,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
@ -402,7 +411,7 @@ jobs:
# outputs instead.
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
# The bundle and workspace dist/ trees come from the `build` job.
# One bounded retry, same transient runner-to-blobstore contract as
@ -464,10 +473,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
# The opentui renderer leg runs the CLI bundle under bun: the locked
# @opentui/core loads its native renderer through bun:ffi, and the CI
# node build cannot load node:ffi. The renderer matrix also pins
@ -495,7 +507,7 @@ jobs:
# outputs instead.
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
# The bundle and workspace dist/ trees come from the `build` job.
# One bounded retry, same transient runner-to-blobstore contract as
@ -566,10 +578,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
@ -583,7 +598,7 @@ jobs:
# outputs instead.
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
# The bundle and workspace dist/ trees come from the `build` job.
# One bounded retry, same transient runner-to-blobstore contract as
@ -644,10 +659,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
@ -657,7 +675,7 @@ jobs:
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Install Playwright Chromium'
run: |-

View file

@ -61,8 +61,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Install Dependencies'
if: |-
@ -70,7 +69,11 @@ jobs:
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
if [ -f pnpm-lock.yaml ]; then
corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only
else
npm ci --ignore-scripts --no-audit --progress=false
fi
# Replay the root postinstall (patch-package) and commit-info
# generation; dependency and workspace lifecycle scripts remain
# disabled.

View file

@ -357,7 +357,7 @@ jobs:
registry-url: 'https://registry.npmjs.org'
- name: 'Install dependencies'
run: 'npm install --ignore-scripts'
run: 'corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only'
- name: 'Build dependency chain (browser-use → core → acp-bridge → sdk → qwen-live)'
run: |-

View file

@ -1,83 +0,0 @@
name: 'pnpm Lock Freshness'
# The version agreement gate in scripts/check-lockfile.js is set membership:
# it catches a pnpm resolution npm never locked, but not a stale one whose
# superseded version still exists elsewhere in npm's tree. Only regenerating
# the lockfile proves it is current, and that needs registry metadata — so it
# runs here, on dependency changes only, instead of in the full-profile lane
# every non-docs PR pays for.
on:
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- '.github/workflows/pnpm-lock-freshness.yml'
- '.npmrc'
- '.pnpmfile.mjs'
- 'package.json'
- 'package-lock.json'
- 'packages/*/package.json'
- '!packages/desktop-shell/package.json'
- '!packages/live-host/package.json'
- 'packages/channels/*/package.json'
- 'integrations/*/package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
push:
branches:
- 'main'
paths:
- '.github/workflows/pnpm-lock-freshness.yml'
- '.npmrc'
- '.pnpmfile.mjs'
- 'package.json'
- 'package-lock.json'
- 'packages/*/package.json'
- '!packages/desktop-shell/package.json'
- '!packages/live-host/package.json'
- 'packages/channels/*/package.json'
- 'integrations/*/package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}'
cancel-in-progress: "${{ github.event_name == 'pull_request' }}"
permissions:
contents: 'read'
jobs:
import:
name: 'Lockfile freshness'
runs-on: 'ubuntu-latest'
timeout-minutes: 15
steps:
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Set up Node.js 22.x'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
- name: 'Regenerate the pnpm lockfile from package-lock.json'
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
run: 'corepack pnpm import'
- name: 'Compare with the committed lockfile'
shell: 'bash'
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo 'pnpm-lock.yaml is what corepack pnpm import produces.'
exit 0
fi
echo 'pnpm-lock.yaml is stale: it is not what `corepack pnpm import`'
echo 'produces from the committed package-lock.json. Update the npm'
echo 'lockfile first, then run `corepack pnpm import` and commit both.'
git diff --stat -- pnpm-lock.yaml
exit 1

View file

@ -1,13 +1,13 @@
# Populates the shared npm cache that qwen-triage.yml's verify and
# Populates the shared pnpm store that qwen-triage.yml's verify and
# tmux-testing lanes restore read-only (actions/cache/restore). Without
# this producer every restore is a guaranteed miss and `npm ci` in those
# lanes downloads from the registry each time.
name: 'npm cache producer'
# this producer every restore is a guaranteed miss and `pnpm install` in
# those lanes downloads every package from the registry.
name: 'pnpm store producer'
on:
push:
branches: ['main']
paths: ['package-lock.json']
paths: ['pnpm-lock.yaml']
workflow_dispatch:
permissions:
@ -20,7 +20,7 @@ defaults:
jobs:
save:
name: 'Save npm cache'
name: 'Save pnpm store'
# Share the verify/tmux consumers' runs-on + container (qwen-triage.yml).
# actions/cache scopes an entry by a hash of the literal cache path plus
# the compression method, so a producer on ubuntu-latest (host path,
@ -34,21 +34,23 @@ jobs:
options: '--init --user node'
# This producer always cold-downloads -- RUNNER_TEMP is fresh per job and
# there is no cache/restore step here -- so its cost is a full registry
# fetch every time, and on the shared pool that is what varies. 22 green
# runs took 4.5-11.8 min, already within 3.2 min of the old 15; five more
# were cancelled at 15.2-16.8, i.e. at the cap. 30 covers the same ~4x
# contention factor measured on the pool's other npm ci steps.
# fetch every time, and on the shared pool that is what varies. Under npm,
# 22 green runs took 4.5-11.8 min and five more were cancelled at the old
# 15-minute cap; 30 covers the same ~4x contention factor measured on the
# pool's other install steps.
timeout-minutes: 30
steps:
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 1
- name: 'Populate npm cache'
run: 'npm ci --no-audit --progress=false --cache "$RUNNER_TEMP/npm-cache"'
# --ignore-scripts: only the store is saved, and no lifecycle script
# writes to it.
- name: 'Populate pnpm store'
run: 'corepack pnpm install --frozen-lockfile --ignore-scripts --reporter=append-only --store-dir "$RUNNER_TEMP/pnpm-store"'
- name: 'Save npm cache'
- name: 'Save pnpm store'
uses: 'actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
with:
path: '${{ runner.temp }}/npm-cache'
key: "npm-ci-${{ hashFiles('package-lock.json') }}"
path: '${{ runner.temp }}/pnpm-store'
key: "pnpm-store-triage-${{ hashFiles('pnpm-lock.yaml') }}"

View file

@ -900,12 +900,9 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
# No remote npm cache on the persistent pool: one measured
# review-address leg spent 339s in `Set up Node.js` restoring
# Full rationale → qwen-autofix.md#af-012
cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}"
# No remote cache on the pool (qwen-autofix.md#af-012): its pnpm
# store stays on disk; only the hosted fallback restores it.
package-manager-cache: false
cache-dependency-path: 'package-lock.json'
- name: 'Install tmux'
run: |-
@ -932,7 +929,7 @@ jobs:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then
break
fi
if [[ "${attempt}" == "3" ]]; then
@ -3607,12 +3604,8 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
# No remote npm cache on the persistent pool — the rationale and
# the ternary's operand-order trap live in issue-autofix's 'Set up
# Node.js' step; the contract tests pin all three copies.
cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}"
# No remote cache on the pool: see issue-autofix's setup step.
package-manager-cache: false
cache-dependency-path: 'package-lock.json'
# The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below
# are duplicated in issue-autofix and review-address; the workflow
@ -3622,7 +3615,7 @@ jobs:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then
break
fi
if [[ "${attempt}" == "3" ]]; then
@ -3993,12 +3986,8 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
# No remote npm cache on the persistent pool — the rationale and
# the ternary's operand-order trap live in issue-autofix's 'Set up
# Node.js' step; the contract tests pin all three copies.
cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}"
# No remote cache on the pool: see issue-autofix's setup step.
package-manager-cache: false
cache-dependency-path: 'package-lock.json'
- name: 'Install tmux'
run: |-
@ -4029,7 +4018,7 @@ jobs:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then
break
fi
if [[ "${attempt}" == "3" ]]; then
@ -4483,7 +4472,7 @@ jobs:
# `generate:settings-schema` run regenerates the committed schema —
# skimmed, not reviewed, so they measure no review burden.
# Full rationale → qwen-autofix.md#af-117
GENERATED_EXCLUDES=(':(exclude,glob)**/package-lock.json' ':(exclude,glob)**/npm-shrinkwrap.json' ':(exclude)packages/vscode-ide-companion/schemas/settings.schema.json')
GENERATED_EXCLUDES=(':(exclude,glob)**/package-lock.json' ':(exclude,glob)**/npm-shrinkwrap.json' ':(exclude,glob)**/pnpm-lock.yaml' ':(exclude)packages/vscode-ide-companion/schemas/settings.schema.json')
# An orphan-history branch (fork takeover / adoption admits one —
# nothing on this job's fetch requires a common ancestor) has no
# Full rationale → qwen-autofix.md#af-044

View file

@ -3479,8 +3479,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Prepare pull request branch'
id: 'prepare'

View file

@ -1332,23 +1332,27 @@ jobs:
ref: 'refs/pull/${{ steps.pr.outputs.pr_number }}/merge'
fetch-depth: 1
- name: 'Clear stale npm cache'
- name: 'Clear stale pnpm store'
if: "steps.pr.outputs.decision == 'run'"
run: 'rm -rf "$RUNNER_TEMP/npm-cache"'
run: 'rm -rf "$RUNNER_TEMP/pnpm-store"'
- name: 'Restore npm cache'
id: 'npm-cache'
- name: 'Restore pnpm store'
id: 'pnpm-store'
if: "steps.pr.outputs.decision == 'run'"
# restore-only: no post-save, so PR code cannot write to the shared cache
uses: 'actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
with:
path: '${{ runner.temp }}/npm-cache'
key: "npm-ci-${{ hashFiles('package-lock.json') }}"
restore-keys: 'npm-ci-'
path: '${{ runner.temp }}/pnpm-store'
key: "pnpm-store-triage-${{ hashFiles('pnpm-lock.yaml') }}"
restore-keys: 'pnpm-store-triage-'
- name: 'Report npm cache hit'
- name: 'Report pnpm store hit'
if: "steps.pr.outputs.decision == 'run'"
run: 'echo "npm cache: hit=${{ steps.npm-cache.outputs.cache-hit }} key=${{ steps.npm-cache.outputs.cache-matched-key }}" >> "$GITHUB_STEP_SUMMARY"'
run: 'echo "pnpm store: hit=${{ steps.pnpm-store.outputs.cache-hit }} key=${{ steps.pnpm-store.outputs.cache-matched-key }}" >> "$GITHUB_STEP_SUMMARY"'
- name: 'Bootstrap pnpm'
if: "steps.pr.outputs.decision == 'run'"
run: 'runuser -u node -- corepack pnpm --version'
- name: 'Install and build PR app'
id: 'prepare'
@ -1361,17 +1365,17 @@ jobs:
unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL
mkdir -p "$RUNNER_TEMP/tmux-results"
chown -R node:node "$GITHUB_WORKSPACE"
# Make the npm cache readable+writable by the build user so
# `npm ci --prefer-offline --cache …` can use it without
# touching the Actions cache API (whose credentials were
# Make the pnpm store readable+writable by the build user so
# `pnpm install --prefer-offline --store-dir …` can use it
# without touching the Actions cache API (whose credentials were
# stripped above).
mkdir -p "$RUNNER_TEMP/npm-cache"
chown -R node:node "$RUNNER_TEMP/npm-cache"
mkdir -p "$RUNNER_TEMP/pnpm-store"
chown -R node:node "$RUNNER_TEMP/pnpm-store"
prepare_log="$RUNNER_TEMP/tmux-results/prepare.log"
set +e
{
# Retried once for the same reason as the verify lane: npm can
# Retried once for the same reason as the verify lane: an install can
# exec a dependency's binary before its own write is closed and
# take an ETXTBSY that has nothing to do with the PR, and this
# lane blames the PR for any install failure. Unconditional, so
@ -1379,19 +1383,22 @@ jobs:
# build stays single-shot: compile errors are deterministic.
install_attempt=1
while :; do
printf '%s\n' '$ npm ci --prefer-offline --no-audit --progress=false --cache "$RUNNER_TEMP/npm-cache"'
printf '%s\n' '$ corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only --store-dir "$RUNNER_TEMP/pnpm-store"'
runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \
npm ci --prefer-offline --no-audit --progress=false \
--cache "$RUNNER_TEMP/npm-cache"
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only \
--store-dir "$RUNNER_TEMP/pnpm-store"
install_status=$?
if [ "$install_status" -eq 0 ] || [ "$install_attempt" -ge 2 ]; then
break
fi
printf '\n%s\n' "npm ci failed with exit code ${install_status}; retrying once."
printf '\n%s\n' "pnpm install failed with exit code ${install_status}; retrying once."
# pnpm install, unlike npm ci, keeps an existing node_modules;
# clear it so the retry cannot inherit a half-written file.
find . -name node_modules -type d -prune -exec rm -rf {} +
install_attempt=$((install_attempt + 1))
done
if [ "$install_status" -ne 0 ]; then
printf '\n%s\n' "npm ci failed with exit code ${install_status} after ${install_attempt} attempts."
printf '\n%s\n' "pnpm install failed with exit code ${install_status} after ${install_attempt} attempts."
else
printf '\n%s\n' '$ npm run build'
runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \
@ -1407,7 +1414,7 @@ jobs:
if [ "${install_status:-0}" -ne 0 ]; then
echo "verdict=fail" >> "$GITHUB_OUTPUT"
echo "failure_phase=install" >> "$GITHUB_OUTPUT"
echo "::error::npm ci failed; reporting a tmux fail verdict instead of an infrastructure failure."
echo "::error::pnpm install failed; reporting a tmux fail verdict instead of an infrastructure failure."
exit 0
fi
if [ "${build_status:-0}" -ne 0 ]; then
@ -1439,7 +1446,7 @@ jobs:
exit 1
fi
# `npm ci`/`npm run build` ran the PR's lifecycle scripts as `node`
# `pnpm install`/`npm run build` ran the PR's lifecycle scripts as `node`
# in the previous step. A detached postinstall child can outlive that
# step and the one-shot sweep below — re-planting a `*-tmux-*`
# artifact dir after the sweep runs, or tampering with the agent's
@ -1475,7 +1482,7 @@ jobs:
exit 1
fi
# `npm ci`/`npm run build` just ran the PR's lifecycle scripts, which
# `pnpm install`/`npm run build` just ran the PR's lifecycle scripts, which
# can plant a `tmp/<name>-tmux-<ts>/` directory holding a report.md
# and a transcript. The collector below globs `*-tmux-*` and the
# publisher takes the first match, so a planted dir can supply the
@ -2048,7 +2055,7 @@ jobs:
PREPARE_ATTEMPTS=''
case "$PREPARE_FAILURE_PHASE" in
install)
PREPARE_COMMAND='npm ci'
PREPARE_COMMAND='pnpm install'
# Retried once in the prepare step; see the verify lane.
PREPARE_ATTEMPTS=' twice in a row'
;;
@ -2449,9 +2456,9 @@ jobs:
# the registry name as npmjs, so
# `https://registry.npmjs.org.evil.com/pkg.tgz` would pass —
# and the tarball's own postinstall never appears in the diff.
hit="$(grep -E '^\+' "$diff_file" | grep -E '"resolved"[[:space:]]*:[[:space:]]*"http' | grep -Ev '"resolved"[[:space:]]*:[[:space:]]*"https://registry\.npmjs\.org/' | head -1 || true)"
hit="$(grep -E '^\+' "$diff_file" | grep -E '"resolved"[[:space:]]*:[[:space:]]*"http|(tarball|repo):[[:space:]]*[^[:alnum:]]?https?://' | grep -Ev '"resolved"[[:space:]]*:[[:space:]]*"https://registry\.npmjs\.org/|(tarball|repo):[[:space:]]*[^[:alnum:]]?https://registry\.npmjs\.org/' | head -1 || true)"
[ -n "$hit" ] && add_reason 'a dependency resolves outside registry.npmjs.org'
hit="$(grep -E '^\+\+\+ b/(.*/)?(\.npmrc|\.yarnrc(\.yml)?|bunfig\.toml)$|^rename to (.*/)?(\.npmrc|\.yarnrc(\.yml)?|bunfig\.toml)$' "$diff_file" | head -1 || true)"
hit="$(grep -E '^\+\+\+ b/(.*/)?(\.npmrc|\.yarnrc(\.yml)?|bunfig\.toml|pnpm-workspace\.yaml|\.pnpmfile\.mjs)$|^rename to (.*/)?(\.npmrc|\.yarnrc(\.yml)?|bunfig\.toml|pnpm-workspace\.yaml|\.pnpmfile\.mjs)$' "$diff_file" | head -1 || true)"
[ -n "$hit" ] && add_reason 'the diff modifies package-manager configuration'
hit="$(grep -E '^\+' "$diff_file" | grep -Ev '"(integrity|resolved)"' | awk '{ for (i = 1; i <= NF; i++) if (length($i) >= 600) { print; exit } }' || true)"
[ -n "$hit" ] && add_reason 'the diff adds long opaque single-line content'
@ -2888,7 +2895,7 @@ jobs:
# The flakiness gate (#9125) re-runs the PR's changed test files and
# compares outcomes across identical rounds. The file list is recorded
# HERE — before the workspace is handed to the build user — because
# after `npm ci` a PR lifecycle script owns .git and could rewrite the
# after `pnpm install` a PR lifecycle script owns .git and could rewrite the
# diff to hide a test file from the gate. The list lives root-owned in
# RUNNER_TEMP, so the gate later runs exactly what was recorded. This
# pin is honesty, not a security boundary: the gate's verdict can only
@ -3109,23 +3116,23 @@ jobs:
rm -f "$GATE_HOME/files-all"
echo "Recorded $(tr -cd '\0' < "$GATE_HOME/files" | wc -c) changed test file(s) for the flakiness gate."
- name: 'Clear stale npm cache'
- name: 'Clear stale pnpm store'
if: "steps.pr.outputs.decision == 'run'"
run: 'rm -rf "$RUNNER_TEMP/npm-cache"'
run: 'rm -rf "$RUNNER_TEMP/pnpm-store"'
- name: 'Restore npm cache'
id: 'npm-cache'
- name: 'Restore pnpm store'
id: 'pnpm-store'
if: "steps.pr.outputs.decision == 'run'"
# restore-only: no post-save, so PR code cannot write to the shared cache
uses: 'actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830' # v4.3.0
with:
path: '${{ runner.temp }}/npm-cache'
key: "npm-ci-${{ hashFiles('package-lock.json') }}"
restore-keys: 'npm-ci-'
path: '${{ runner.temp }}/pnpm-store'
key: "pnpm-store-triage-${{ hashFiles('pnpm-lock.yaml') }}"
restore-keys: 'pnpm-store-triage-'
- name: 'Report npm cache hit'
- name: 'Report pnpm store hit'
if: "steps.pr.outputs.decision == 'run'"
run: 'echo "npm cache: hit=${{ steps.npm-cache.outputs.cache-hit }} key=${{ steps.npm-cache.outputs.cache-matched-key }}" >> "$GITHUB_STEP_SUMMARY"'
run: 'echo "pnpm store: hit=${{ steps.pnpm-store.outputs.cache-hit }} key=${{ steps.pnpm-store.outputs.cache-matched-key }}" >> "$GITHUB_STEP_SUMMARY"'
- name: 'Install and build PR app'
id: 'prepare'
@ -3145,23 +3152,23 @@ jobs:
rm -f "$RUNNER_TEMP/verify-chromium-path"
mkdir -p "$RUNNER_TEMP/verify-results"
chown -R node:node "$GITHUB_WORKSPACE"
# Make the npm cache readable+writable by the build user so
# `npm ci --prefer-offline --cache …` can use it without
# touching the Actions cache API (whose credentials were
# Make the pnpm store readable+writable by the build user so
# `pnpm install --prefer-offline --store-dir …` can use it
# without touching the Actions cache API (whose credentials were
# stripped above).
mkdir -p "$RUNNER_TEMP/npm-cache"
chown -R node:node "$RUNNER_TEMP/npm-cache"
mkdir -p "$RUNNER_TEMP/pnpm-store"
chown -R node:node "$RUNNER_TEMP/pnpm-store"
prepare_log="$RUNNER_TEMP/verify-results/prepare.log"
set +e
{
# `npm ci` can die on a race that has nothing to do with the PR:
# npm writes a dependency's binary and that package's install
# script execs it before the write is closed, so Linux answers
# ETXTBSY. Observed on esbuild in run 30319209722, which turned
# it into a `fail` verdict against a PR that never touched the
# dependency. One clean retry absorbs that class — `npm ci`
# removes node_modules before installing, so the second attempt
# The install can die on a race that has nothing to do with the
# PR: a dependency's binary is written and that package's
# install script execs it before the write is closed, so Linux
# answers ETXTBSY. Observed on esbuild under npm in run
# 30319209722, which turned it into a `fail` verdict against a PR
# that never touched the dependency. One clean retry absorbs that
# class — node_modules is cleared before the second attempt, so it
# cannot inherit the half-written file.
#
# Unconditional by design. Gating the retry on the log would
@ -3174,19 +3181,22 @@ jobs:
# would only double the cost of an honest failure.
install_attempt=1
while :; do
printf '%s\n' '$ npm ci --prefer-offline --no-audit --progress=false --cache "$RUNNER_TEMP/npm-cache"'
printf '%s\n' '$ corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only --store-dir "$RUNNER_TEMP/pnpm-store"'
runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \
npm ci --prefer-offline --no-audit --progress=false \
--cache "$RUNNER_TEMP/npm-cache"
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only \
--store-dir "$RUNNER_TEMP/pnpm-store"
install_status=$?
if [ "$install_status" -eq 0 ] || [ "$install_attempt" -ge 2 ]; then
break
fi
printf '\n%s\n' "npm ci failed with exit code ${install_status}; retrying once."
printf '\n%s\n' "pnpm install failed with exit code ${install_status}; retrying once."
# pnpm install, unlike npm ci, keeps an existing node_modules;
# clear it so the retry cannot inherit a half-written file.
find . -name node_modules -type d -prune -exec rm -rf {} +
install_attempt=$((install_attempt + 1))
done
if [ "$install_status" -ne 0 ]; then
printf '\n%s\n' "npm ci failed with exit code ${install_status} after ${install_attempt} attempts."
printf '\n%s\n' "pnpm install failed with exit code ${install_status} after ${install_attempt} attempts."
else
printf '\n%s\n' '$ npm run build'
runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \
@ -3222,10 +3232,10 @@ jobs:
if [ "${install_status:-0}" -ne 0 ]; then
if registry_unreachable; then
echo "verdict=infra-error" >> "$GITHUB_OUTPUT"
echo "::error::npm ci failed (exit ${install_status}) and the registry is unreachable from this runner; reporting infrastructure."
echo "::error::pnpm install failed (exit ${install_status}) and the registry is unreachable from this runner; reporting infrastructure."
else
echo "verdict=fail" >> "$GITHUB_OUTPUT"
echo "::error::npm ci failed (exit ${install_status})."
echo "::error::pnpm install failed (exit ${install_status})."
fi
echo "failure_phase=install" >> "$GITHUB_OUTPUT"
exit 0
@ -3760,7 +3770,7 @@ jobs:
# reset leaves file i seeing what files 1..i-1 left THIS
# round — residue, staged mutations, HOME state — and round
# 1 must sample the same restored tree as rounds 2..N (PR
# lifecycle scripts, npm ci/build run as node, may have
# lifecycle scripts, pnpm install/build run as node, may have
# mutated the tree since the list was recorded).
reset_round_state
reset_rc=$?
@ -4016,7 +4026,7 @@ jobs:
fi
# ── Re-establish the trust boundary after the PR's own code ran ──
# `npm ci`/`npm run build` executed PR-authored lifecycle scripts as
# `pnpm install`/`npm run build` executed PR-authored lifecycle scripts as
# `node`. Everything below undoes what those could have left behind,
# in order, BEFORE the agent starts.
@ -4090,7 +4100,7 @@ jobs:
mkdir -p "$AGENT_HOME"
chown node:node "$AGENT_HOME"
# 5. Same for the upload staging dir: it is flushed before npm ci,
# 5. Same for the upload staging dir: it is flushed before pnpm install,
# but the lifecycle scripts that ran since could have planted
# files there too, and everything in it is uploaded verbatim.
# Recreate it empty (prepare.log is re-copied below if present).
@ -5691,7 +5701,7 @@ jobs:
PREPARE_ATTEMPTS_ZH=''
case "$PREPARE_FAILURE_PHASE" in
install)
PREPARE_COMMAND='npm ci'
PREPARE_COMMAND='pnpm install'
# The prepare step retries the install once, so reaching
# this phase means two consecutive failures. Say so: the
# sentence below blames the PR for it, and "failed twice in
@ -5708,7 +5718,7 @@ jobs:
echo "::warning::Unrecognized prepare failure phase: ${UNKNOWN_PREPARE_PHASE}"
;;
esac
# infra-error can now arise from exactly one condition: npm ci
# infra-error can now arise from exactly one condition: pnpm install
# failed AND the registry was unreachable from the runner (the
# log-pattern classifier is gone, and build failures are always
# `fail`). The copy must name that condition and nothing else —

View file

@ -56,7 +56,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22'
cache: 'npm'
package-manager-cache: false
- name: 'Validate release version and tag'
id: 'preflight'
@ -151,7 +151,7 @@ jobs:
run: 'mvn --batch-mode --no-transfer-progress clean verify -Dgpg.skip=true'
- name: 'Install Node.js dependencies'
run: 'npm ci --prefer-offline --no-audit --progress=false'
run: 'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only'
- name: 'Build Qwen Code'
run: 'npm run build'

View file

@ -139,7 +139,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
package-manager-cache: false
- name: 'Get the version'
id: 'version'
@ -210,7 +210,7 @@ jobs:
- name: 'Install Dependencies'
run: |
npm ci
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
python -m pip install --upgrade pip
python -m pip install -e 'packages/sdk-python[dev]' build

View file

@ -113,7 +113,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
@ -122,7 +122,7 @@ jobs:
- name: 'Install Dependencies'
run: |-
npm ci
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Get the version'
id: 'version'
@ -183,7 +183,7 @@ jobs:
run: |-
# Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version --no-workspaces-update
- name: 'Determine CLI source and version'
id: 'cli_source'
@ -334,7 +334,7 @@ jobs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json
git add packages/sdk-typescript/package.json
if git diff --staged --quiet; then
echo "No version changes to commit"
echo "::notice::No version changes in sdk-typescript; skipping release branch push and PR creation."

View file

@ -91,14 +91,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Verify published export renderer'
# An exported transcript loads its renderer from unpkg, pinned to this
@ -245,14 +244,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Install VSCE'
run: |-

View file

@ -189,9 +189,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
package-manager-cache: false
cache-dependency-path: 'package-lock.json'
# ECS uses its pre-installed Node because nodejs.org may be unreachable.
- name: 'Use pre-installed Node.js (self-hosted)'
@ -213,7 +211,7 @@ jobs:
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci --ignore-scripts --no-audit --progress=false
corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only
npm run postinstall
npm run generate
@ -270,9 +268,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
package-manager-cache: false
cache-dependency-path: 'package-lock.json'
# ECS uses its pre-installed Node because nodejs.org may be unreachable.
- &setup_release_node_self_hosted
@ -286,7 +282,7 @@ jobs:
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci --ignore-scripts --no-audit --progress=false
corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only
npm run postinstall
npm run generate
@ -641,9 +637,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}"
package-manager-cache: false
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
@ -654,7 +648,7 @@ jobs:
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
npm ci --ignore-scripts --no-audit --progress=false
corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only
npm run postinstall
npm run generate

View file

@ -110,15 +110,18 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Install dependencies and build'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then
break
fi
if [[ "${attempt}" == "3" ]]; then exit 1; fi
@ -309,8 +312,11 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Cache pnpm store (hosted)'
if: "${{ steps.dedup.outputs.open_prs_present == 'false' && runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Install dependencies and build'
if: "${{ steps.dedup.outputs.open_prs_present == 'false' }}"
@ -318,7 +324,7 @@ jobs:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then break; fi
if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then break; fi
if [[ "${attempt}" == "3" ]]; then exit 1; fi
sleep $((attempt * 15))
done
@ -418,6 +424,9 @@ jobs:
"write_file(/.qwen/**)",
"write_file(**/package.json)",
"write_file(**/package-lock.json)",
"write_file(**/pnpm-lock.yaml)",
"write_file(**/pnpm-workspace.yaml)",
"write_file(**/.pnpmfile.mjs)",
"write_file(**/.npmrc)",
"write_file(**/makefile)",
"write_file(**/Makefile)",

View file

@ -14,7 +14,7 @@ on:
- 'packages/acp-bridge/**'
- 'integration-tests/fake-openai-server.ts'
- 'package.json'
- 'package-lock.json'
- 'pnpm-lock.yaml'
- 'docs/developers/sdk-java.md'
- 'docs/design/java-daemon-sdk-alpha.md'
- 'scripts/run-java-daemon-sdk-e2e.ts'
@ -33,7 +33,7 @@ on:
- 'packages/acp-bridge/**'
- 'integration-tests/fake-openai-server.ts'
- 'package.json'
- 'package-lock.json'
- 'pnpm-lock.yaml'
- 'docs/developers/sdk-java.md'
- 'docs/design/java-daemon-sdk-alpha.md'
- 'scripts/run-java-daemon-sdk-e2e.ts'
@ -199,7 +199,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22'
cache: 'npm'
package-manager-cache: false
- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
@ -232,7 +232,7 @@ jobs:
echo "${RUNNER_TEMP}/apache-maven-${MAVEN_VERSION}/bin" >> "${GITHUB_PATH}"
- name: 'Install Node.js dependencies'
run: 'npm ci --prefer-offline --no-audit --progress=false'
run: 'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only'
- name: 'Build Qwen Code'
run: 'npm run build'

View file

@ -37,18 +37,16 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
# --ignore-scripts: CI must not run dependency install hooks. The audit
# only needs the resolved dependency tree, so skipping postinstall
# (patch-package) is safe here.
- name: 'Install dependencies'
run: 'npm ci --ignore-scripts --no-audit --progress=false'
package-manager-cache: false
# Hard gate: the high-severity baseline is clean, so any new high CVE
# fails the check. Keep scripts/tests/security-workflows.test.js in
# sync with this policy.
#
# The root workspace is audited with pnpm, which reads pnpm-lock.yaml
# directly, so nothing is installed for it. The two vendored lockfiles
# outside the pnpm workspace are still installed and audited with npm.
#
# A registry-side audit failure is not a CVE finding, so `audit` retries
# it once and then reports it as its own error. npm's retired
# `security/audits/quick` route intermittently answers HTTP 400 ("Invalid
@ -77,26 +75,27 @@ jobs:
return 0
fi
case "$out" in
*'audit endpoint returned an error'*) retryable=1 ;;
# npm's and pnpm's wording for a registry-side audit failure.
*'audit endpoint returned an error'* | *'ERR_PNPM_AUDIT_BAD_RESPONSE'*) retryable=1 ;;
esac
# 124 = `timeout` killed a stalled audit.
if [ "$rc" -eq 124 ]; then
retryable=1
fi
if [ "$retryable" -eq 1 ] && [ "$attempt" -lt 2 ]; then
echo "::warning::npm audit returned no verdict (attempt $attempt/2), retrying"
echo "::warning::dependency audit returned no verdict (attempt $attempt/2), retrying"
attempt=$((attempt + 1))
sleep 15
continue
fi
if [ "$retryable" -eq 1 ]; then
echo "::error::npm audit returned no verdict after $attempt attempts (registry endpoint error or timeout). This is an audit-infrastructure failure, not a CVE finding - re-run this job."
echo "::error::dependency audit returned no verdict after $attempt attempts (registry endpoint error or timeout). This is an audit-infrastructure failure, not a CVE finding - re-run this job."
fi
return "$rc"
done
}
status=0
audit npm audit --omit=dev --audit-level=high || status=$?
audit corepack pnpm audit --prod --audit-level high || status=$?
for lockfile in packages/*/package-lock.json; do
[ -f "$lockfile" ] || continue
# Covered by the root workspace audit; this vendored lockfile is not installed directly.

View file

@ -268,8 +268,7 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'head/package-lock.json'
package-manager-cache: false
# Unconditional, and BEFORE either drive: on the persistent pool
# `${RUNNER_TEMP}` outlives a run, and the drive's own reset lives inside
@ -288,7 +287,7 @@ jobs:
working-directory: 'head'
run: |-
set -euo pipefail
npm ci --prefer-offline --no-audit --progress=false
if [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; else npm ci --prefer-offline --no-audit --progress=false; fi
npm run build
node .github/scripts/serve-ab-drive.mjs \
packages/cli/dist/index.js "${RUNNER_TEMP}/after"
@ -301,7 +300,7 @@ jobs:
working-directory: 'base'
run: |-
set -euo pipefail
npm ci --prefer-offline --no-audit --progress=false
if [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; else npm ci --prefer-offline --no-audit --progress=false; fi
npm run build
node ../head/.github/scripts/serve-ab-drive.mjs \
packages/cli/dist/index.js "${RUNNER_TEMP}/before"

View file

@ -54,14 +54,17 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
run: |-
if [ -f pnpm-lock.yaml ]; then
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
else
npm ci --no-audit --progress=false
fi
- name: 'Download Release Assets from GitHub'
env:

View file

@ -16,8 +16,9 @@ jobs:
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Cache pnpm store (hosted)'
uses: './.github/actions/pnpm-store-cache'
# Pinned to DEFAULT_BUN_VERSION (scripts/build-standalone-release.js,
# the --runtime=bun archive flavor): a gating parity check must not
# silently follow 'latest'.
@ -28,7 +29,7 @@ jobs:
env:
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Build cli'
run: 'npm run build'
- name: 'Component parity snapshots (ink vs opentui)'
@ -47,8 +48,9 @@ jobs:
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
- name: 'Cache pnpm store (hosted)'
uses: './.github/actions/pnpm-store-cache'
# Pinned to DEFAULT_BUN_VERSION (scripts/build-standalone-release.js,
# the --runtime=bun archive flavor): a gating parity check must not
# silently follow 'latest'.
@ -59,7 +61,7 @@ jobs:
env:
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --prefer-offline --no-audit --progress=false
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Build cli'
run: 'npm run build'
- name: 'OpenTUI no-flicker gate (0 clears, balanced DEC2026)'

View file

@ -70,10 +70,13 @@ jobs:
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org/'
- name: 'Cache pnpm store (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: './.github/actions/pnpm-store-cache'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
@ -82,7 +85,7 @@ jobs:
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: 'npm ci --prefer-offline --no-audit --progress=false'
run: 'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only'
- name: 'Install Playwright Chromium'
run: 'npx playwright install --with-deps chromium'
@ -160,7 +163,14 @@ jobs:
if: "${{ steps.base_checkout.outcome == 'success' }}"
continue-on-error: true
working-directory: 'base'
run: 'npm ci --prefer-offline --no-audit --progress=false'
# The base checkout can predate pnpm-lock.yaml; install it the way
# that commit was built.
run: |-
if [ -f pnpm-lock.yaml ]; then
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
else
npm ci --prefer-offline --no-audit --progress=false
fi
# Run ONLY when the checkout AND install both succeeded. `base/` is nested
# under the head checkout, so if the base's own node_modules is absent (a

View file

@ -102,7 +102,7 @@ jobs:
- name: 'Install dependencies'
shell: 'bash'
run: 'npm ci --prefer-offline --no-audit --progress=false'
run: 'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only'
- name: 'Run tests and generate reports'
shell: 'bash'

4
.gitignore vendored
View file

@ -148,3 +148,7 @@ tmp/
# Brand build workspaces (created by the desktop-brand-builder skill)
brand-builds/
workspace/
# Dependencies install with pnpm from pnpm-lock.yaml; keep an npm install
# from bringing the retired root lockfile back.
/package-lock.json

View file

@ -182,10 +182,10 @@ differs only by the change under test; the verdict is the pair of counts.
never rely on it), then rebuild **only the
affected workspace or file** — e.g. `npm run build -w packages/<ws>` inside
the base tree wired to the already-installed root `node_modules`, or
recompile the single changed module. A full base `npm ci` rarely fits the
recompile the single changed module. A full base `pnpm install` rarely fits the
budget; say so in the report if you had to spend it.
- ⚠️ Reusing the root `node_modules` for the base side is only a clean
control when the PR leaves `package.json`/`package-lock.json` untouched.
control when the PR leaves `package.json`/`pnpm-lock.yaml` untouched.
If the PR changes the dependency tree, the tree itself is part of the
change: either make the A/B dependency-aware (install the base lockfile in
the base worktree for the affected package) or name the confound

View file

@ -52,7 +52,7 @@ approve.**
### Building
```bash
npm install # Install all dependencies
corepack pnpm install --frozen-lockfile # Install all dependencies (the tree CI tests)
npm run build # Build all packages (TypeScript compilation + asset copying)
npm run build:all # Build everything including sandbox container
npm run bundle # Bundle dist/ into a single dist/cli.js via esbuild
@ -81,27 +81,26 @@ packages (`@qwen-code/acp-bridge`, `@qwen-code/web-templates`,
`packages/channels/*`, ...) through their built `dist/` output, and
`packages/core` tests import the package's own entry
(`@qwen-code/qwen-code-core`), which also resolves into `dist/`. A plain
`npm ci` already builds them via the `prepare` script, but a worktree that
shares the main checkout's `node_modules` (or a deep-cleaned copy) does not
have them. If any prerequisite is missing, a vitest `globalSetup` guard stops
the run and names the fix; build once from the repository root:
`corepack pnpm install --frozen-lockfile` already builds them via the
`prepare` script, but a worktree that shares the main checkout's
`node_modules` (or a deep-cleaned copy) does not have them. If any
prerequisite is missing, a vitest `globalSetup` guard stops the run and names
the fix; build once from the repository root:
```bash
npm run build
```
**pnpm worktree bootstrap (opt-in):** an additional Git worktree can install
dependencies with `node scripts/setup-worktree.js`, which runs the pinned
pnpm with `--frozen-lockfile` (warm store ≈ 99 MiB on copy-on-write
filesystems such as APFS, btrfs, and XFS with reflink; without reflink, as on
ext4, it is ≈ 1.2 GiB, close to a plain npm install). The bootstrap skips the
`prepare` build, so run `npm run build` before package tests. npm remains the
authoritative path for build, CI, packaging, and release; the pnpm layout is
install-only for now. When dependencies change, update `package-lock.json`
with npm first, then regenerate the pnpm lockfile from it with
`corepack pnpm import` and commit both lockfiles together;
`npm run check:lockfile` fails when pnpm resolves a version npm has not
locked.
**Installing dependencies:** CI, release and every workflow install with the
pnpm version pinned in `packageManager` (`corepack pnpm install
--frozen-lockfile`); scripts still run through `npm run`. An additional Git
worktree can use `node scripts/setup-worktree.js`, which runs the same install
from a shared store (≈ 99 MiB on copy-on-write filesystems such as APFS, btrfs,
and XFS with reflink; ≈ 1.2 GiB on ext4) and skips the `prepare` build, so run
`npm run build` before package tests. When dependencies change, edit the
manifest, run `corepack pnpm install` (or `corepack pnpm add`), and commit
`pnpm-lock.yaml`; every CI install runs with `--frozen-lockfile`, so a
lockfile that no longer matches the manifests fails the build.
**Run individual test files** (always preferred):

View file

@ -104,10 +104,10 @@ git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL
cd qwen-code
```
To install dependencies defined in `package.json` as well as root dependencies:
To install dependencies defined in `package.json` as well as root dependencies, use the pnpm version the repository pins in `packageManager` (Corepack, bundled with Node.js, fetches it):
```bash
npm install
corepack pnpm install --frozen-lockfile
```
To build the entire project (all packages):
@ -325,7 +325,7 @@ We publish an artifact for each commit to our internal registry. But if you need
```
npm run clean
npm install
corepack pnpm install --frozen-lockfile
npm run auth
npm run prerelease:dev
npm publish --workspaces

View file

@ -25,9 +25,9 @@ COPY . /home/node/app
WORKDIR /home/node/app
# Install dependencies, build workspaces, bundle into a single distributable, and pack.
# QWEN_SKIP_PREPARE=1 stops npm ci's prepare script from building and bundling —
# QWEN_SKIP_PREPARE=1 stops the install's prepare script from building and bundling —
# the explicit build and bundle steps below already do that.
RUN QWEN_SKIP_PREPARE=1 npm ci \
RUN QWEN_SKIP_PREPARE=1 corepack pnpm install --frozen-lockfile --reporter=append-only \
&& npm run build \
&& npm run bundle \
&& npm run prepare:package \

View file

@ -101,7 +101,7 @@ The above pattern for creating patch or hotfix releases from current or older co
out this tag gets the exact code that was published.
2. The Branch (`release-vX.Y.Z-patch.1`): This branch contains one new commit on top of the
tagged commit. That new commit only contains the version number change in package.json
(and other related files like package-lock.json).
(and the other workspace manifests).
This separation is good. It keeps your main branch history clean of release-specific
version bumps until you decide to merge them.
@ -253,6 +253,6 @@ This tells NPM that any folder inside the `packages` directory is a separate pac
### Benefits of Workspaces
- **Simplified Dependency Management**: Running `npm install` from the root of the project will install all dependencies for all packages in the workspace and link them together. This means you don't need to run `npm install` in each package's directory.
- **Automatic Linking**: Packages within the workspace can depend on each other. When you run `npm install`, NPM will automatically create symlinks between the packages. This means that when you make changes to one package, the changes are immediately available to other packages that depend on it.
- **Simplified Dependency Management**: Running `corepack pnpm install --frozen-lockfile` from the root of the project installs the dependencies of every package in the workspace and links the packages together, so you don't need to install in each package's directory.
- **Automatic Linking**: Packages within the workspace can depend on each other. When you install from the root, pnpm creates symlinks between the packages. This means that when you make changes to one package, the changes are immediately available to other packages that depend on it.
- **Simplified Script Execution**: You can run scripts in any package from the root of the project using the `--workspace` flag. For example, to run the `build` script in the `cli` package, you can run `npm run build --workspace @qwen-code/qwen-code`.

32485
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -84,7 +84,7 @@
"smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py",
"build:sdk:python": "python3 -m build packages/sdk-python",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci && npm run check:serve-fast-path-bundle",
"preflight": "npm run clean && corepack pnpm install --frozen-lockfile && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci && npm run check:serve-fast-path-bundle",
"postinstall": "patch-package",
"prepare": "node scripts/prepare.js",
"prepare:package": "node scripts/prepare-package.js",
@ -129,12 +129,20 @@
"devDependencies": {
"@emnapi/core": "1.11.3",
"@emnapi/runtime": "1.11.3",
"@qwen-code/acp-bridge": "file:packages/acp-bridge",
"@qwen-code/channel-base": "file:packages/channels/base",
"@qwen-code/qwen-code": "file:packages/cli",
"@qwen-code/qwen-code-core": "file:packages/core",
"@qwen-code/sdk": "file:packages/sdk-typescript",
"@qwen-code/web-shell": "file:packages/web-shell",
"@qwen-code/web-templates": "file:packages/web-templates",
"@types/chrome": "^0.1.32",
"@types/jsdom": "^28.0.3",
"@types/marked": "^5.0.2",
"@types/mime-types": "^3.0.1",
"@types/minimatch": "^5.1.2",
"@types/mock-fs": "^4.13.4",
"@types/node": "20.19.1",
"@types/proper-lockfile": "^4.1.4",
"@types/shell-quote": "^1.7.5",
"@types/uuid": "^10.0.0",
@ -174,7 +182,8 @@
"typescript-eslint": "^8.30.1",
"vitest": "^3.2.4",
"yaml": "^2.8.1",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"zod": "3.25.76"
},
"dependencies": {
"@testing-library/dom": "^10.4.1",

View file

@ -436,7 +436,9 @@ export const baseTreeCommand: CommandModule = {
.option('install', {
type: 'boolean',
default: true,
describe: 'Run `npm ci` first when node_modules is absent',
describe:
'Run `npm ci` (or `corepack pnpm install` for a pnpm lockfile) ' +
'first when node_modules is absent',
}),
handler: (argv) => {
const args = argv as unknown as BaseTreeArgs;

View file

@ -426,6 +426,53 @@ describe('runBuildTest', () => {
expect(calls.some((c) => c.startsWith('npm ci'))).toBe(true);
});
it("installs a pnpm repo with corepack pnpm, gated on pnpm's own marker", () => {
// pnpm-lock.yaml alone — this repo's own shape once package-lock.json is
// gone. The npm marker the beforeEach left must not pass for a pnpm tree.
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ name: 'r', workspaces: ['packages/*'] }),
);
rmSync(join(root, 'package-lock.json'), { force: true });
writeFileSync(join(root, 'pnpm-lock.yaml'), '');
pkg('packages/a', { name: '@x/a', scripts: { build: 'exit 0' } });
writePlan(['packages/a/src/x.ts']);
const calls: string[] = [];
const exec = (command: string, cwd: string): CommandResult => {
calls.push(command);
if (command.startsWith('corepack pnpm install')) {
writeFileSync(join(cwd, 'node_modules', '.modules.yaml'), '');
}
return { command, exitCode: 0, seconds: 1, timedOut: false, output: '' };
};
const rep = runBuildTest({
plan: planPath,
worktree: root,
timeout: 60,
install: true,
exec,
});
expect(calls[0]).toBe(
'corepack pnpm install --frozen-lockfile --reporter=append-only',
);
expect(rep.install?.command).toBe(calls[0]);
// Scripts still run through npm.
expect(rep.toolchain).toBe('npm');
expect(calls.some((c) => c.startsWith('npm run build'))).toBe(true);
// The next run finds the pnpm tree complete and does not reinstall.
calls.length = 0;
runBuildTest({
plan: planPath,
worktree: root,
timeout: 60,
install: true,
exec,
});
expect(calls.some((c) => c.includes('install'))).toBe(false);
});
it('builds and tests nothing for a LICENSE-only diff — the license family cannot fail a suite', () => {
// A LICENSE edit outside every workspace cannot fail any suite, so
// "nothing to run" is the honest answer, not a skipped step — and no

View file

@ -1047,7 +1047,8 @@ export const buildTestCommand: CommandModule = {
type: 'boolean',
default: true,
describe:
'Fetch dependencies first: `npm ci` when node_modules is absent',
'Fetch dependencies first: `npm ci`, or `corepack pnpm install` for a ' +
'pnpm lockfile, when node_modules is absent',
})
.option('build-only', {
type: 'boolean',

View file

@ -8,7 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { npmToolchainAdapter } from './npm-toolchain.js';
import {
lockfileInstaller,
npmInstallComplete,
npmToolchainAdapter,
} from './npm-toolchain.js';
import { selectToolchainAdapter } from './toolchain.js';
const statfsSyncMock = vi.hoisted(() => vi.fn());
@ -231,3 +235,51 @@ describe('npm toolchain adapter', () => {
]);
});
});
describe('lockfileInstaller', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'lockfile-installer-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it('reads the installer off the committed lockfile', () => {
expect(lockfileInstaller(root)).toBeNull();
writeFileSync(join(root, 'package-lock.json'), '{}');
expect(lockfileInstaller(root)).toBe('npm');
rmSync(join(root, 'package-lock.json'));
writeFileSync(join(root, 'pnpm-lock.yaml'), '');
expect(lockfileInstaller(root)).toBe('pnpm');
});
it('lets packageManager break the tie when both lockfiles are committed', () => {
writeFileSync(join(root, 'package-lock.json'), '{}');
writeFileSync(join(root, 'pnpm-lock.yaml'), '');
// No manifest, or one that does not name pnpm: npm, exactly as before.
expect(lockfileInstaller(root)).toBe('npm');
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ packageManager: 'npm@10.9.8' }),
);
expect(lockfileInstaller(root)).toBe('npm');
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@11.24.0' }),
);
expect(lockfileInstaller(root)).toBe('pnpm');
});
it("gates completeness on the installer's own marker", () => {
mkdirSync(join(root, 'node_modules'));
writeFileSync(join(root, 'node_modules', '.package-lock.json'), '{}');
writeFileSync(join(root, 'pnpm-lock.yaml'), '');
// npm's marker says nothing about whether a pnpm install finished.
expect(npmInstallComplete(root)).toBe(false);
writeFileSync(join(root, 'node_modules', '.modules.yaml'), '');
expect(npmInstallComplete(root)).toBe(true);
});
});

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { existsSync, rmSync } from 'node:fs';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import type { BuildTestReport, CommandResult } from '../build-test.js';
import {
@ -64,15 +64,40 @@ function testCommand(dir: string): string {
return dir === '.' ? 'npm test' : `npm test --workspace=${shellArg(dir)}`;
}
/**
* npm's completeness marker — `npm ci` writes
* `node_modules/.package-lock.json` only once the tree is fully
* materialised. This is the gate the install phase below reads, exported so
* `prebuild.ts` answers the fetch report's `installed` off the SAME
* predicate: the report and Agent 7 can never disagree about whether the
* tree needs an install.
* The installer a repo's lockfile calls for, or null when it commits neither.
* `pnpm-lock.yaml` alone means pnpm. Beside a `package-lock.json` (a repo
* mid-migration commits both) the root manifest's `packageManager` decides,
* and anything but `pnpm@…` keeps npm, so an npm repo installs exactly as
* before. Only the install differs: scripts still run through `npm run`.
*/
export function lockfileInstaller(root: string): 'npm' | 'pnpm' | null {
const npmLock = existsSync(join(root, 'package-lock.json'));
if (!existsSync(join(root, 'pnpm-lock.yaml'))) return npmLock ? 'npm' : null;
if (!npmLock) return 'pnpm';
try {
const manager: unknown = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
)?.packageManager;
return typeof manager === 'string' && manager.startsWith('pnpm@')
? 'pnpm'
: 'npm';
} catch {
return 'npm';
}
}
/**
* The installer's completeness marker — `npm ci` writes
* `node_modules/.package-lock.json`, and pnpm `node_modules/.modules.yaml`,
* only once the tree is fully materialised. This is the gate the install
* phase below reads, exported so `prebuild.ts` answers the fetch report's
* `installed` off the SAME predicate: the report and Agent 7 can never
* disagree about whether the tree needs an install.
*/
export function npmInstallComplete(root: string): boolean {
return existsSync(join(root, 'node_modules', '.package-lock.json'));
const marker =
lockfileInstaller(root) === 'pnpm' ? '.modules.yaml' : '.package-lock.json';
return existsSync(join(root, 'node_modules', marker));
}
/**
* The one grammar `testCommand` above emits. Exported for the `--resume`
@ -735,33 +760,34 @@ function runNpmToolchain(args: ToolchainRunArgs): BuildTestReport {
// in the diff and are not — so a timed-out install aborts, exactly like an install
// that left no tree at all.
//
// Whether to install is gated on npm's **completeness marker**, not the bare
// directory. `npm ci` writes `node_modules/.package-lock.json` only once the tree
// is fully materialised, so a partial tree — left by a timeout here, or by the
// Whether to install is gated on the installer's **completeness marker**, not the
// bare directory. `npm ci` writes `node_modules/.package-lock.json`, and pnpm
// `node_modules/.modules.yaml`, only once the tree is fully materialised, so a
// partial tree — left by a timeout here, or by the
// agent's own shell-tool kill one level up — has the directory but not the marker.
// Gating on the directory would let every later run *skip* the install and build
// against that partial tree; gating on the marker reinstalls it.
//
// But `npm ci` is only right for an npm repo. `workspaces` is also yarn/bun/pnpm
// syntax, and those write no `package-lock.json`, so `npm ci` would fail-fast on
// the missing lockfile and mislabel a perfectly usable `node_modules` as a failed
// install. So install only when there IS a `package-lock.json` (an npm repo) whose
// tree is incomplete; a non-npm repo that already has a tree is trusted — the build
// But the installer has to match the lockfile. `workspaces` is also yarn/bun
// syntax, and those commit neither `package-lock.json` nor `pnpm-lock.yaml`, so
// `npm ci` would fail-fast on the missing lockfile and mislabel a perfectly usable
// `node_modules` as a failed install. So install only when there IS one of the two
// (`lockfileInstaller` picks `npm ci` or `corepack pnpm install`) and the tree
// is incomplete; a yarn/bun repo that already has a tree is trusted — the build
// is the authoritative signal, by this command's own argument.
const npmLock = existsSync(join(root, 'package-lock.json'));
const installer = lockfileInstaller(root);
const installComplete = (): boolean => npmInstallComplete(root);
// A non-npm repo (yarn/bun/pnpm — `workspaces` is their syntax too) with no
// installed tree cannot be installed here: `npm ci` needs the npm lockfile, and
// A yarn or bun repo (`workspaces` is their syntax too) with no installed tree
// cannot be installed here: neither installer reads its lockfile, and
// building against absent dependencies fails with `Cannot find module` **inside the
// PR's own changed files** — the false-Critical steer this command exists to
// prevent. A review worktree is cold by construction, so this is the common case,
// not an edge. Hand it to the brief, naming the tool to install with. (The warm
// case — a tree already present — is trusted below and never reaches here.)
if (args.install && !npmLock && !existsSync(join(root, 'node_modules'))) {
if (args.install && !installer && !existsSync(join(root, 'node_modules'))) {
const altLock = [
['yarn.lock', 'yarn install --frozen-lockfile'],
['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
['bun.lockb', 'bun install --frozen-lockfile'],
['bun.lock', 'bun install --frozen-lockfile'],
].find(([f]) => existsSync(join(root, f)));
@ -775,14 +801,17 @@ function runNpmToolchain(args: ToolchainRunArgs): BuildTestReport {
'build/test precedence in your brief.',
);
}
if (args.install && npmLock && !installComplete()) {
if (args.install && installer && !installComplete()) {
// Disk preflight. The deadline already treats "cannot finish in time" as an
// infrastructure result and skips ahead with a disclosure; "cannot fit on
// the disk" is the same class of result, discovered before the command runs
// instead of 33 seconds into it. An `npm ci` that dies on ENOSPC is
// strictly worse than one that never starts: it leaves a partial tree AND a
// full disk that fails every agent scheduled after this one.
const installCmd = 'npm ci --no-audit --no-fund';
const installCmd =
installer === 'pnpm'
? 'corepack pnpm install --frozen-lockfile --reporter=append-only'
: 'npm ci --no-audit --no-fund';
const free = freeDiskBytes(root);
if (free !== null && free < INSTALL_MIN_FREE_BYTES) {
results.ok = false;

View file

@ -353,7 +353,7 @@ export function sandboxVerdict(
* the phase has committed to producing a verdict, and a per-command refusal
* would read as a build failure rather than as absent evidence;
* - one of them is not even on the path. When the toolchain cannot be scoped
* (a yarn/pnpm/bun repo, no `package-lock.json` — `unsupportedReport`), the
* (a yarn/bun repo, no npm or pnpm lockfile — `unsupportedReport`), the
* pipeline hands the install/build/test to the AGENT's own shell, which
* never passes through `run()` at all. A gate at the spawn would leave that
* route wide open under the very policy that forbids it.

View file

@ -12,7 +12,8 @@ export interface ToolchainRunArgs {
changedFiles: string[];
timeout: number;
/**
* Gates the adapter's dependency-acquisition step — npm's `npm ci` today.
* Gates the adapter's dependency-acquisition step — `npm ci`, or pnpm for a
* pnpm lockfile, today.
*/
install: boolean;
buildOnly?: boolean;

View file

@ -52,6 +52,7 @@ Apply these common exclusions automatically:
- `**/test/**`, `**/tests/**`
- `**/package-lock.json`
- `**/yarn.lock`
- `**/pnpm-lock.yaml`
- `**/*.min.js`
- Binary files (images, fonts, etc.)
- Files larger than 500KB (check size if needed)

View file

@ -300,27 +300,24 @@ function stageNodePty(desktopTarget) {
}
}
// The exact pinned versions of these packages, read from the checkout the
// release job installed (QWEN_CODE_ROOT) so the runtime carries what
// package-lock.json was built against. Returns null when the repo pins none of
// them, which is how an unsupported target degrades instead of inventing a
// version the lockfile never tested.
// The exact versions declared by the checkout the release job installed
// (QWEN_CODE_ROOT). The source's frozen install already verifies these pins
// against its lockfile. Returns null when the repo pins none of them, which is
// how an unsupported target degrades instead of inventing a version.
function nodePtyPackageSpecs(packageNames) {
const rootPackage = JSON.parse(
fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'),
);
const packageLock = JSON.parse(
fs.readFileSync(path.join(sourceRoot, 'package-lock.json'), 'utf8'),
);
const pinned = rootPackage.optionalDependencies ?? {};
const specs = [];
for (const packageName of packageNames) {
if (!pinned[packageName]) return null;
const version =
packageLock.packages?.[`node_modules/${packageName}`]?.version;
if (!version) {
const version = pinned[packageName];
if (!version) return null;
if (
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)
) {
throw new Error(
`node-pty package version is not locked for ${packageName}`,
`node-pty package version must be exact for ${packageName}`,
);
}
specs.push(`${packageName}@${version}`);

View file

@ -513,27 +513,16 @@ function testRuntimeNodePtyTargetMapping() {
);
}
// The pins are real registry metadata, and they are what makes the release
// matrix's cross-built leg work: `@lydell/node-pty-darwin-x64` declares
// os darwin / cpu x64, so the `npm ci` on the arm64 macos-15 runner that
// builds x86_64-apple-darwin skips it. Staging therefore has to fetch the
// target's locked package instead of reading the host's node_modules
// (#11872).
// Cross-built prebuilds must stay exact so staging fetches the same version
// that the frozen root install verified (#11872).
const rootPackage = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
);
const packageLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'),
);
const crossBuilt =
packageLock.packages['node_modules/@lydell/node-pty-darwin-x64'];
assert.equal(
assert.match(
rootPackage.optionalDependencies?.['@lydell/node-pty-darwin-x64'],
crossBuilt?.version,
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/,
'the darwin-x64 prebuild must stay pinned so a cross-building leg can fetch its locked version',
);
assert.deepEqual(crossBuilt?.os, ['darwin']);
assert.deepEqual(crossBuilt?.cpu, ['x64']);
}
function testRuntimePreparation(directory) {
@ -561,8 +550,7 @@ function testRuntimePreparation(directory) {
fs.mkdirSync(path.join(sourceRoot, 'dist', 'web-shell', 'assets'), {
recursive: true,
});
// The pins staging resolves its package specs from: root package.json
// optionalDependencies for the names, package-lock.json for the versions.
// The exact pins staging resolves its package specs from.
const nodePtyPins = {
'@lydell/node-pty': '0.0.0-test',
'@lydell/node-pty-darwin-x64': '0.0.0-test',
@ -574,17 +562,6 @@ function testRuntimePreparation(directory) {
optionalDependencies: nodePtyPins,
}),
);
fs.writeFileSync(
path.join(sourceRoot, 'package-lock.json'),
JSON.stringify({
packages: Object.fromEntries(
Object.keys(nodePtyPins).map((name) => [
`node_modules/${name}`,
{ version: nodePtyPins[name] },
]),
),
}),
);
fs.mkdirSync(path.dirname(testScript), { recursive: true });
fs.copyFileSync(
path.join(packageDir, 'scripts', 'prepare-runtime.js'),
@ -737,7 +714,7 @@ globalThis.fetch = async (url) => {
);
// ...and it has to get there by fetching the TARGET's pinned package, at the
// version package-lock.json locks, not out of a host node_modules that cannot
// version package.json pins, not out of a host node_modules that cannot
// contain a darwin-x64 addon.
const installs = fs
.readFileSync(npmLog, 'utf8')

File diff suppressed because it is too large Load diff

View file

@ -44,19 +44,14 @@ SOFTWARE.`;
*
* @param {string} depName - Package name
* @param {string} depVersion - Resolved version string
* @param {string} resolvedKey - Lockfile key indicating where the package is installed
* @param {string} packageDir - Directory the package is installed in
* @returns {Promise<{name: string, version: string, repository: string, license: string}>}
*/
async function getDependencyLicense(depName, depVersion, resolvedKey) {
async function getDependencyLicense(depName, depVersion, packageDir) {
let licenseContent = 'License text not found.';
let repositoryUrl = 'No repository found';
// Derive the on-disk path directly from the lockfile key
const depPackageJsonPath = path.join(
projectRoot,
resolvedKey,
'package.json',
);
const depPackageJsonPath = path.join(packageDir, 'package.json');
try {
const depPackageJsonContent = await fs.readFile(
@ -333,114 +328,82 @@ export function getFallbackLicenseText(license, author) {
}
/**
* Resolve a package in the lockfile by walking up the node_modules chain,
* mirroring Node.js module resolution algorithm.
* Find where `packageName` resolves from `fromDir` by walking up the
* node_modules chain the way Node.js does. This reads the installed tree
* rather than a lockfile, so the notices describe what the extension actually
* bundles, whichever package manager laid that tree out.
*
* @param {string} packageName - Package to find
* @param {object} packages - packageLock.packages map
* @param {string} resolveFrom - Lockfile key to start resolution from
* @returns {{info: object, key: string} | null}
* @param {string} fromDir - Real directory of the package that depends on it
* @returns {Promise<{dir: string, manifest: object} | null>}
*/
function resolveInLockfile(packageName, packages, resolveFrom) {
// Walk up from resolveFrom, trying each node_modules level
let current = resolveFrom;
while (current) {
const candidate = `${current}/node_modules/${packageName}`;
if (packages[candidate]) {
return { info: packages[candidate], key: candidate };
async function resolveInstalled(packageName, fromDir) {
let dir = fromDir;
for (;;) {
const candidate = path.join(dir, 'node_modules', packageName);
try {
const manifest = JSON.parse(
await fs.readFile(path.join(candidate, 'package.json'), 'utf-8'),
);
return { dir: await fs.realpath(candidate), manifest };
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
}
// Move up: strip the last /node_modules/... segment
const lastNm = current.lastIndexOf('/node_modules/');
if (lastNm === -1) break;
current = current.slice(0, lastNm);
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
// Finally try root hoisted level
const hoistedKey = `node_modules/${packageName}`;
if (packages[hoistedKey]) {
return { info: packages[hoistedKey], key: hoistedKey };
}
return null;
}
/**
* Recursively collect third-party dependencies by walking the lockfile.
* Mirrors Node.js module resolution: walks up the node_modules chain from
* the current package's location.
* Recursively collect third-party dependencies from the installed tree,
* resolving each one from the real location of the package that needs it.
*
* @param {string} packageName - Package to resolve
* @param {object} packageLock - Parsed package-lock.json
* @param {Map<string, {version: string, resolvedKey: string}>} dependenciesMap - Accumulated results
* @param {string} resolveFrom - Lockfile key prefix to resolve from (e.g. "packages/vscode-ide-companion")
* @param {Set<string>} visitedKeys - Lockfile keys already traversed
* @param {string} fromDir - Real directory to resolve from
* @param {Map<string, {name: string, version: string, dir: string}>} dependenciesMap - Accumulated results
* @param {Set<string>} visitedDirs - Installed directories already traversed
*/
export function collectDependencies(
export async function collectDependencies(
packageName,
packageLock,
fromDir,
dependenciesMap,
resolveFrom,
visitedKeys,
visitedDirs,
) {
const resolved = resolveInLockfile(
packageName,
packageLock.packages,
resolveFrom,
);
const resolved = await resolveInstalled(packageName, fromDir);
if (!resolved) {
console.warn(
`Warning: Could not find package info for ${packageName} in package-lock.json.`,
`Warning: ${packageName} is not installed where ${fromDir} resolves it.`,
);
return;
}
const { info: packageInfo, key: resolvedKey } = resolved;
const { dir, manifest } = resolved;
// Traversal guard: skip if this exact lockfile path was already visited.
// Keyed by resolved path (not package name) so different installed versions
// of the same package are each traversed once.
if (visitedKeys.has(resolvedKey)) {
return;
}
visitedKeys.add(resolvedKey);
// Workspace-linked packages: follow resolved pointer to collect their third-party deps
if (packageInfo.link) {
const realInfo = packageLock.packages[packageInfo.resolved];
if (realInfo?.dependencies) {
for (const depName of Object.keys(realInfo.dependencies)) {
collectDependencies(
depName,
packageLock,
dependenciesMap,
packageInfo.resolved,
visitedKeys,
);
}
}
// Traversal guard: keyed by real directory, not package name, so different
// installed versions of the same package are each traversed once.
if (visitedDirs.has(dir)) {
return;
}
visitedDirs.add(dir);
// A workspace package resolves outside every node_modules directory: follow
// it for its third-party dependencies without listing it.
if (dir.split(path.sep).includes('node_modules')) {
// Output dedup: emit each (name, version) pair once, even when the same
// version is installed at multiple paths.
const outputKey = `${packageName}@${packageInfo.version}`;
const outputKey = `${packageName}@${manifest.version}`;
if (!dependenciesMap.has(outputKey)) {
dependenciesMap.set(outputKey, {
name: packageName,
version: packageInfo.version,
resolvedKey,
version: manifest.version,
dir,
});
}
if (packageInfo.dependencies) {
for (const depName of Object.keys(packageInfo.dependencies)) {
// Resolve transitive deps from THIS package's location
collectDependencies(
depName,
packageLock,
dependenciesMap,
resolvedKey,
visitedKeys,
);
}
for (const depName of Object.keys(manifest.dependencies ?? {})) {
await collectDependencies(depName, dir, dependenciesMap, visitedDirs);
}
}
@ -450,33 +413,25 @@ async function main() {
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);
const packageLockJsonPath = path.join(projectRoot, 'package-lock.json');
const packageLockJsonContent = await fs.readFile(
packageLockJsonPath,
'utf-8',
);
const packageLockJson = JSON.parse(packageLockJsonContent);
const allDependencies = new Map();
const visitedKeys = new Set();
const visitedDirs = new Set();
const directDependencies = Object.keys(packageJson.dependencies);
const workspacePrefix = path.relative(projectRoot, packagePath);
// Sequential on purpose: the traversal order is the file's order, and it
// must not depend on which filesystem read finishes first.
for (const depName of directDependencies) {
collectDependencies(
await collectDependencies(
depName,
packageLockJson,
packagePath,
allDependencies,
workspacePrefix,
visitedKeys,
visitedDirs,
);
}
const dependencyEntries = Array.from(allDependencies.values());
const licensePromises = dependencyEntries.map(
({ name, version, resolvedKey }) =>
getDependencyLicense(name, version, resolvedKey),
const licensePromises = dependencyEntries.map(({ name, version, dir }) =>
getDependencyLicense(name, version, dir),
);
const dependencyLicenses = await Promise.all(licensePromises);

View file

@ -51,9 +51,6 @@ describe('runNoticeGeneration', () => {
if (name.endsWith('package.json')) {
return JSON.stringify({ dependencies: {} });
}
if (name.endsWith('package-lock.json')) {
return JSON.stringify({ packages: {} });
}
throw Object.assign(new Error(`ENOENT: ${name}`), { code: 'ENOENT' });
});
const write = vi.spyOn(fs, 'writeFile').mockImplementation(async () => {});
@ -122,24 +119,55 @@ describe('findLicenseFile', () => {
});
describe('collectDependencies', () => {
it('resolves workspace dependencies from the linked package location', () => {
const packageLock = {
packages: {
'packages/companion/node_modules/@qwen-code/core': {
link: true,
resolved: 'packages/core',
},
'packages/core': { dependencies: { nested: '1.0.0' } },
'packages/core/node_modules/nested': { version: '1.0.0' },
},
};
let root;
beforeEach(async () => {
// realpath: on macOS the temp dir sits behind /var -> /private/var, and
// the traversal reports real directories.
root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'notices-tree-')),
);
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
async function writePackage(dir, manifest) {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'package.json'),
JSON.stringify(manifest),
);
}
it('follows a workspace link to its dependencies without listing it', async () => {
const companion = path.join(root, 'packages', 'companion');
const core = path.join(root, 'packages', 'core');
await writePackage(companion, { name: 'companion' });
await writePackage(core, {
name: '@qwen-code/core',
version: '0.1.0',
dependencies: { nested: '^1.0.0' },
});
await writePackage(path.join(core, 'node_modules', 'nested'), {
name: 'nested',
version: '1.0.0',
});
await fs.mkdir(path.join(root, 'node_modules', '@qwen-code'), {
recursive: true,
});
await fs.symlink(
core,
path.join(root, 'node_modules', '@qwen-code', 'core'),
'junction',
);
const dependencies = new Map();
collectDependencies(
await collectDependencies(
'@qwen-code/core',
packageLock,
companion,
dependencies,
'packages/companion',
new Set(),
);
@ -147,10 +175,34 @@ describe('collectDependencies', () => {
{
name: 'nested',
version: '1.0.0',
resolvedKey: 'packages/core/node_modules/nested',
dir: path.join(core, 'node_modules', 'nested'),
},
]);
});
it('resolves each dependency from the nearest node_modules above it', async () => {
const companion = path.join(root, 'packages', 'companion');
await writePackage(companion, { name: 'companion' });
await writePackage(path.join(root, 'node_modules', 'hoisted'), {
name: 'hoisted',
version: '2.0.0',
dependencies: { leaf: '^3.0.0' },
});
// The nested copy wins over the hoisted one for the package that nests it.
await writePackage(
path.join(root, 'node_modules', 'hoisted', 'node_modules', 'leaf'),
{ name: 'leaf', version: '3.1.0' },
);
await writePackage(path.join(root, 'node_modules', 'leaf'), {
name: 'leaf',
version: '4.0.0',
});
const dependencies = new Map();
await collectDependencies('hoisted', companion, dependencies, new Set());
expect([...dependencies.keys()]).toEqual(['hoisted@2.0.0', 'leaf@3.1.0']);
});
});
describe('normalizeRepositoryUrl', () => {

119
pnpm-lock.yaml generated
View file

@ -43,6 +43,27 @@ importers:
'@emnapi/runtime':
specifier: 1.11.3
version: 1.11.3
'@qwen-code/acp-bridge':
specifier: workspace:*
version: link:packages/acp-bridge
'@qwen-code/channel-base':
specifier: workspace:*
version: link:packages/channels/base
'@qwen-code/qwen-code':
specifier: workspace:*
version: link:packages/cli
'@qwen-code/qwen-code-core':
specifier: workspace:*
version: link:packages/core
'@qwen-code/sdk':
specifier: workspace:*
version: link:packages/sdk-typescript
'@qwen-code/web-shell':
specifier: workspace:*
version: link:packages/web-shell
'@qwen-code/web-templates':
specifier: workspace:*
version: link:packages/web-templates
'@types/chrome':
specifier: ^0.1.32
version: 0.1.43
@ -61,6 +82,9 @@ importers:
'@types/mock-fs':
specifier: ^4.13.4
version: 4.13.4
'@types/node':
specifier: 20.19.1
version: 20.19.1
'@types/proper-lockfile':
specifier: ^4.1.4
version: 4.1.4
@ -72,10 +96,10 @@ importers:
version: 10.0.0
'@vitest/coverage-v8':
specifier: ^3.1.1
version: 3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))
version: 3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitest/eslint-plugin':
specifier: ^1.3.4
version: 1.3.4(eslint@9.29.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))
version: 1.3.4(eslint@9.29.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))
'@xterm/headless':
specifier: ^5.5.0
version: 5.5.0
@ -141,7 +165,7 @@ importers:
version: 5.5.0
msw:
specifier: ^2.10.4
version: 2.10.4(@types/node@22.20.1)(typescript@5.8.3)
version: 2.10.4(@types/node@20.19.1)(typescript@5.8.3)
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
@ -162,7 +186,7 @@ importers:
version: 7.8.5
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@22.20.1)
version: 0.35.4(@types/node@20.19.1)
strip-ansi:
specifier: ^7.1.2
version: 7.1.2
@ -174,13 +198,16 @@ importers:
version: 8.35.0(eslint@9.29.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
yaml:
specifier: ^2.8.1
version: 2.9.0
yargs:
specifier: ^17.7.2
version: 17.7.2
zod:
specifier: 3.25.76
version: 3.25.76
optionalDependencies:
'@lydell/node-pty':
specifier: 1.2.0-beta.10
@ -286,7 +313,7 @@ importers:
version: 5.8.3
vitest:
specifier: ^3.1.1
version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
packages/browser-use:
dependencies:
@ -1387,7 +1414,7 @@ importers:
version: 0.1.12(@types/react@19.2.10)(echarts@6.1.0)(react@19.2.4)(supports-color@7.2.0)
'@modelcontextprotocol/ext-apps':
specifier: ^1.7.5
version: 1.7.5(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)
version: 1.7.5(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1526,7 +1553,7 @@ importers:
version: 19.2.3(@types/react@19.2.10)
'@vitejs/plugin-react':
specifier: ^4.2.0
version: 4.7.0(supports-color@7.2.0)(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0))
version: 4.7.0(supports-color@7.2.0)(vite@5.4.21(@types/node@20.19.1)(lightningcss@1.32.0))
autoprefixer:
specifier: ^10.4.22
version: 10.4.22(postcss@8.5.26)
@ -1547,7 +1574,7 @@ importers:
version: 5.8.3
vite:
specifier: ^5.0.0
version: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)
version: 5.4.21(@types/node@20.19.1)(lightningcss@1.32.0)
packages:
@ -11320,6 +11347,7 @@ snapshots:
'@inquirer/type': 3.0.8(@types/node@22.20.1)
optionalDependencies:
'@types/node': 22.20.1
optional: true
'@inquirer/core@10.1.15(@types/node@20.19.1)':
dependencies:
@ -11346,6 +11374,7 @@ snapshots:
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.20.1
optional: true
'@inquirer/figures@1.0.13': {}
@ -11356,6 +11385,7 @@ snapshots:
'@inquirer/type@3.0.8(@types/node@22.20.1)':
optionalDependencies:
'@types/node': 22.20.1
optional: true
'@isaacs/cliui@8.0.2':
dependencies:
@ -11568,11 +11598,11 @@ snapshots:
dependencies:
zod: 4.4.3
'@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)':
'@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76)':
dependencies:
'@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@3.25.76)
'@standard-schema/spec': 1.1.0
zod: 4.4.3
zod: 3.25.76
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -11621,28 +11651,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.4.3)':
dependencies:
'@hono/node-server': 2.0.12(hono@4.13.3)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.3
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
hono: 4.13.3
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.0
raw-body: 3.0.2
zod: 4.4.3
zod-to-json-schema: 3.25.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
'@mswjs/interceptors@0.39.5':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@ -13958,6 +13966,18 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
'@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@5.4.21(@types/node@20.19.1)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21(@types/node@20.19.1)(lightningcss@1.32.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
@ -13989,7 +14009,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))':
'@vitest/coverage-v8@3.2.7(supports-color@7.2.0)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@ -14004,17 +14024,17 @@ snapshots:
std-env: 3.9.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
vitest: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
'@vitest/eslint-plugin@1.3.4(eslint@9.29.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))':
'@vitest/eslint-plugin@1.3.4(eslint@9.29.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)(vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0))':
dependencies:
'@typescript-eslint/utils': 8.35.0(eslint@9.29.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)
eslint: 9.29.0(jiti@2.7.0)(supports-color@7.2.0)
optionalDependencies:
typescript: 5.8.3
vitest: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
vitest: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(supports-color@7.2.0)(tsx@4.20.3)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
@ -14032,23 +14052,23 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.7(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))':
'@vitest/mocker@3.2.7(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(vite@7.3.6(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.10.4(@types/node@20.19.1)(typescript@5.8.3)
vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)
vite: 7.3.6(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)
'@vitest/mocker@3.2.7(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))':
'@vitest/mocker@3.2.7(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(vite@7.3.6(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.10.4(@types/node@22.20.1)(typescript@5.8.3)
vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)
vite: 7.3.6(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0)
'@vitest/pretty-format@3.2.7':
dependencies:
@ -17899,6 +17919,7 @@ snapshots:
typescript: 5.8.3
transitivePeerDependencies:
- '@types/node'
optional: true
mute-stream@0.0.8: {}
@ -20159,6 +20180,16 @@ snapshots:
- tsx
- yaml
vite@5.4.21(@types/node@20.19.1)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.26
rollup: 4.62.5
optionalDependencies:
'@types/node': 20.19.1
fsevents: 2.3.3
lightningcss: 1.32.0
vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5
@ -20240,7 +20271,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.7
'@vitest/mocker': 3.2.7(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitest/mocker': 3.2.7(msw@2.10.4(@types/node@20.19.1)(typescript@5.8.3))(vite@7.3.6(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.7
'@vitest/snapshot': 3.2.7
@ -20283,7 +20314,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.7
'@vitest/mocker': 3.2.7(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitest/mocker': 3.2.7(msw@2.10.4(@types/node@22.20.1)(typescript@5.8.3))(vite@7.3.6(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.7
'@vitest/snapshot': 3.2.7

View file

@ -20,6 +20,11 @@ packages:
- '!packages/live-host'
- '!packages/mobile-shell'
# npm links every workspace package into the root node_modules; pnpm links
# one only where it is a declared dependency. The root package therefore
# declares, as file: devDependencies, the workspace packages that root-level
# code (integration-tests/, scripts/) imports, and .pnpmfile.mjs rewrites them
# to workspace:* like every other internal dependency.
nodeLinker: 'hoisted'
# The root postinstall runs patch-package, which rewrites files inside
# node_modules in place. Under the default import method those files are
@ -33,6 +38,10 @@ nodeLinker: 'hoisted'
packageImportMethod: 'clone-or-copy'
linkWorkspacePackages: true
verifyDepsBeforeRun: false
fetchRetries: 5
fetchRetryMintimeout: 20000
fetchRetryMaxtimeout: 120000
fetchTimeout: 300000
overrides:
typescript: '5.8.3'
@ -55,14 +64,19 @@ overrides:
# pinned here rather than exempted there.
'@tybys/wasm-util': '0.10.3'
react-markdown: '^9.0.0'
# The root package declares zod 3.25.76 as a devDependency on purpose. With
# resolve-peers-from-workspace-root (on by default), every zod peer that a
# workspace package does not declare itself resolves to it — the copy npm
# hoists. Without it pnpm fills such a peer with the highest locked zod
# (4.4.3): web-shell bundles @modelcontextprotocol/ext-apps, whose zod is a
# peer, and zod 4 pushed the export renderer past its size budget. It also
# covers the override below, which predates it.
# packages/acp-bridge declares no zod, so pnpm is free to satisfy the SDK's
# `^3.25.0 || ^4.0.0` peer with the highest locked zod while npm serves it
# 3.25.76. Both are npm-locked, so the version agreement gate cannot see the
# split — and it is not cosmetic: the SDK imports `zod` and `zod/v4` from
# `^3.25.0 || ^4.0.0` peer with the highest locked zod, where npm served it
# 3.25.76. The split is not cosmetic: the SDK imports `zod` and `zod/v4` from
# different modules, so under zod 4 its own `instanceof z.ZodError` test
# stops matching and a malformed request answers -32602 instead of npm's
# -32603. Pin it until npm moves, so every ACP consumer behaves the way CI
# verifies.
# stops matching and a malformed request answers -32602 instead of -32603.
# Pin it so every ACP consumer keeps the behaviour the tests verify.
'@agentclientprotocol/sdk>zod': '3.25.76'
allowBuilds:

View file

@ -14,6 +14,7 @@ import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
import {
TARGET_CLIPBOARD_PACKAGE,
standaloneArchiveName,
@ -71,8 +72,7 @@ const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download';
// packaging cross-builds all targets from a single host. Linux is glibc-only
// on purpose: RELEASE_TARGETS bundles glibc-linked Bun binaries that cannot
// start on musl hosts, so the -musl render packages would be dead weight
// claiming support the archive cannot deliver. Declared before the
// top-level `main()` call below (ESM const TDZ).
// claiming support the archive cannot deliver.
const OPENTUI_PLATFORM_PACKAGES = [
'@opentui/core-darwin-arm64',
'@opentui/core-darwin-x64',
@ -82,15 +82,6 @@ const OPENTUI_PLATFORM_PACKAGES = [
'@opentui/core-win32-x64',
];
if (isMainModule()) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
@ -239,10 +230,23 @@ async function packageTarget({
});
}
function readClipboardPackageSpecs() {
const packageLock = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
// The release installs from pnpm-lock.yaml, so that is where every target's
// native package version comes from. Only the build host's own platform
// package is installed, so the installed tree cannot answer for the others.
let pnpmLockedKeys;
function readPnpmLockedVersion(packageName) {
pnpmLockedKeys ??= Object.keys(
parseYaml(fs.readFileSync(path.join(rootDir, 'pnpm-lock.yaml'), 'utf8'))
?.packages ?? {},
);
const versions = pnpmLockedKeys
.filter((key) => key.startsWith(`${packageName}@`))
.map((key) => key.slice(packageName.length + 1));
// Two locked versions would leave the staged one ambiguous; fail instead.
return versions.length === 1 ? versions[0] : undefined;
}
function readClipboardPackageSpecs() {
const cliPackage = JSON.parse(
fs.readFileSync(
path.join(rootDir, 'packages', 'cli', 'package.json'),
@ -255,8 +259,7 @@ function readClipboardPackageSpecs() {
];
return packageNames.map((packageName) => {
const version =
packageLock.packages?.[`node_modules/${packageName}`]?.version;
const version = readPnpmLockedVersion(packageName);
const declaredVersion = cliPackage.optionalDependencies?.[packageName];
if (!version || ![version, `^${version}`].includes(declaredVersion)) {
fail(`Clipboard package version is not locked for ${packageName}`);
@ -273,9 +276,6 @@ function readClipboardPackageSpecs() {
// runtime (#11872). Deriving the list from the root manifest keeps it in sync
// when a platform pin is added (e.g. linux-arm64).
function readNodePtyPackageSpecs() {
const packageLock = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
);
const rootPackage = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'),
);
@ -284,8 +284,7 @@ function readNodePtyPackageSpecs() {
).filter((packageName) => packageName.startsWith('@lydell/node-pty'));
return packageNames.map((packageName) => {
const version =
packageLock.packages?.[`node_modules/${packageName}`]?.version;
const version = readPnpmLockedVersion(packageName);
if (!version) {
fail(`node-pty package version is not locked for ${packageName}`);
}
@ -329,13 +328,8 @@ function stageNativeModules(runtimeDir) {
// render library at runtime via `import('@opentui/core-<platform>-<arch>')`,
// so each standalone archive must ship the matching platform package(s).
function readOpenTuiPackageSpecs() {
const packageLock = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
);
return OPENTUI_PLATFORM_PACKAGES.map((packageName) => {
const version =
packageLock.packages?.[`node_modules/${packageName}`]?.version;
const version = readPnpmLockedVersion(packageName);
if (!version) {
fail(`OpenTUI platform package version is not locked for ${packageName}`);
}
@ -559,3 +553,12 @@ export {
readNodePtyPackageSpecs,
RELEASE_TARGETS,
};
if (isMainModule()) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}

View file

@ -25,9 +25,12 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
// npm install if node_modules was removed (e.g. via npm run clean or scripts/clean.js)
// Install if node_modules was removed (e.g. via npm run clean or scripts/clean.js)
if (!existsSync(join(root, 'node_modules'))) {
execSync('npm install', { stdio: 'inherit', cwd: root });
execSync('corepack pnpm install --frozen-lockfile', {
stdio: 'inherit',
cwd: root,
});
}
// build all workspaces/packages in dependency order

View file

@ -36,7 +36,7 @@ const argv = yargs(hideBin(process.argv))
alias: 'skip-npm-install-build',
type: 'boolean',
default: false,
description: 'skip npm install + npm run build',
description: 'skip dependency install + npm run build',
})
.option('f', {
alias: 'dockerfile',
@ -91,7 +91,7 @@ if (!image.length) {
}
if (!argv.s) {
execSync('npm install', { stdio: 'inherit' });
execSync('corepack pnpm install --frozen-lockfile', { stdio: 'inherit' });
execSync('npm run build', { stdio: 'inherit' });
console.log('bundling...');

View file

@ -10,6 +10,8 @@ import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const nativePrefixes = [
@ -72,16 +74,14 @@ if (nativeWorkspaces.length > 0) {
);
}
const lockfile = JSON.parse(
readFileSync(join(root, 'package-lock.json'), 'utf8'),
);
const nativeLockfileEntries = Object.keys(lockfile.packages ?? {}).filter(
const lockfile = parseYaml(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8'));
const nativeLockfileEntries = Object.keys(lockfile.importers ?? {}).filter(
isNativeLocation,
);
if (nativeLockfileEntries.length > 0) {
reportError(
'Root package-lock.json should not contain native package entries.',
'Root pnpm-lock.yaml should not contain native package importers.',
nativeLockfileEntries,
);
}

View file

@ -17,7 +17,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// lockfiles happen to sit in that directory as if they were the repository's.
const envRoot = process.env.CHECK_LOCKFILE_ROOT?.trim();
const root = envRoot ? envRoot : join(__dirname, '..');
const lockfilePath = join(root, 'package-lock.json');
function readJsonFile(filePath) {
try {
@ -29,57 +28,11 @@ function readJsonFile(filePath) {
}
}
console.log('Checking lockfile...');
const lockfile = readJsonFile(lockfilePath);
if (lockfile === null) {
process.exit(1);
}
const packages = lockfile.packages || {};
const invalidPackages = [];
for (const [location, details] of Object.entries(packages)) {
// 1. Skip the root package itself.
if (location === '') {
continue;
}
// 2. Skip local workspace packages.
// They are identifiable in two ways:
// a) As a symlink within node_modules.
// b) As the source package definition, whose path is not in node_modules.
if (details.link === true || !location.includes('node_modules')) {
continue;
}
// 3. Any remaining package should be a third-party dependency.
// 1) Registry package with both "resolved" and "integrity" fields is valid.
if (details.resolved && details.integrity) {
continue;
}
// 2) Git and file dependencies only need a "resolved" field.
const isGitOrFileDep =
details.resolved?.startsWith('git') ||
details.resolved?.startsWith('file:');
if (isGitOrFileDep) {
continue;
}
// Mark the left dependency as invalid.
invalidPackages.push(location);
}
if (invalidPackages.length > 0) {
console.error(
'\nError: The following dependencies in package-lock.json are missing the "resolved" or "integrity" field:',
);
invalidPackages.forEach((pkg) => console.error(`- ${pkg}`));
process.exitCode = 1;
} else {
console.log('Lockfile check passed.');
process.exitCode = 0;
}
// pnpm enforces build approvals itself: strictDepBuilds (on by default in
// pnpm 11) fails any install in which a dependency's install script has no
// decision in pnpm-workspace.yaml's allowBuilds, and every CI install is a
// --frozen-lockfile one, which also rejects a lockfile that no longer matches
// the manifests. This gate covers what an install does not check.
console.log('Checking pnpm lockfile...');
const pnpmLockfilePath = join(root, 'pnpm-lock.yaml');
@ -91,12 +44,19 @@ try {
process.exit(1);
}
const pnpmPackages = pnpmLockfile?.packages ?? {};
if (Object.keys(pnpmPackages).length === 0) {
console.error(
'\nError: pnpm-lock.yaml has no packages section; regenerate it with `corepack pnpm install`.',
);
process.exit(1);
}
const invalidPnpmPackages = [];
for (const [key, details] of Object.entries(pnpmLockfile?.packages ?? {})) {
for (const [key, details] of Object.entries(pnpmPackages)) {
const resolution = details?.resolution ?? {};
// Registry packages carry a sha512 integrity hash; git and tarball
// resolutions identify their source directly, mirroring the npm rules
// above.
// resolutions identify their source directly.
const hasIntegrity =
typeof resolution.integrity === 'string' &&
resolution.integrity.startsWith('sha512-');
@ -127,7 +87,7 @@ console.log('Checking Playwright parity...');
//
// Three other Playwright declarations are deliberately outside this invariant:
// - `packages/mobile-mcp` declares `@playwright/test` as a range directly.
// npm satisfies it from the single hoisted copy at the pinned version
// pnpm satisfies it from the single hoisted copy at the pinned version
// today, and the harness never resolves into that workspace, so it stays
// out of scope here — but it is the only in-workspace range on a name this
// check pins, which makes it the first manifest to look at when the
@ -140,11 +100,11 @@ console.log('Checking Playwright parity...');
// root, so the root `playwright-core` bin is NOT part of this parity — only
// the `playwright` CLIs are.
// - `integration-tests/terminal-capture` declares a `playwright` range but is
// not a workspace member, so it enters neither lockfile. Bringing it inside
// not a workspace member, so it does not enter the lockfile. Bringing it inside
// the invariant takes three steps, not one: pin it exact, list the directory
// in BOTH the root `workspaces` and `pnpm-workspace.yaml`'s `packages:`
// (scripts/tests/package-scripts.test.js asserts the two lists are equal),
// then append it below and regenerate both lockfiles. Two test-side edits
// then append it below and regenerate the lockfile. Two test-side edits
// ride with the append: `scripts/tests/check-lockfile.test.js` copies only
// the manifests its fixtures perturb, so the new one joins its `FILES`, and
// its third-manifest arm builds a third entry by rewriting a literal copy
@ -191,48 +151,44 @@ for (const { manifest, name, spec, exact } of restEntries) {
}
// Manifest agreement is not enough on its own: a stale or hand-edited lockfile
// can still resolve the set apart. Assert the resolved outcome too, deriving
// each location from the manifest list so an entry added above is covered by
// both halves rather than only by the exactness check.
// can still resolve the set apart. The importer check below asserts what each
// manifest resolves to; this one asserts that no pinned package pulls in
// another pinned package at a different revision, which would install a second
// chromium beside the one the harness launches. Derived from the manifest list
// in both directions, so an entry appended above is covered here too.
if (pinEntry.exact) {
for (const { name } of playwrightSpecs) {
const location = `node_modules/${name}`;
const actual = packages[location]?.version;
if (actual === undefined) {
parityErrors.push(
`package-lock.json has no ${location} entry, so nothing hoists the package the harness imports; regenerate the lockfile`,
);
} else if (actual !== pinned) {
parityErrors.push(
`package-lock.json resolves ${location} to ${actual} instead of ${pinned}; regenerate the lockfile, and if that does not settle it then something is rewriting the resolved version — a manifest range resolving above the pin (packages/mobile-mcp's @playwright/test is the only in-workspace one) or the root package.json \`overrides:\` block, which carries no Playwright entry today`,
);
}
}
// A nested copy at the pinned version is a redundant install, not a split
// revision, so only a differing one is an error. Derived from the manifest
// list in both directions, so an entry appended above is covered by this
// check as well as by the two over it — a split revision is the failure this
// whole block exists to own, and it is the one check a hardcoded path would
// silently stop applying to a third manifest.
const pinnedNames = playwrightSpecs.map(({ name }) => name);
const snapshots = pnpmLockfile?.snapshots ?? {};
for (const outer of pinnedNames) {
const key = Object.keys(snapshots).find(
(candidate) =>
candidate === `${outer}@${pinned}` ||
candidate.startsWith(`${outer}@${pinned}(`),
);
if (key === undefined) {
parityErrors.push(
`pnpm-lock.yaml has no snapshot for ${outer}@${pinned}; regenerate it`,
);
continue;
}
for (const inner of pinnedNames) {
if (outer === inner) {
continue;
}
const nested = `node_modules/${outer}/node_modules/${inner}`;
if (packages[nested] && packages[nested].version !== pinned) {
const version = snapshots[key]?.dependencies?.[inner];
// A peer-resolved version carries a `(peer@x)` suffix; the revision is
// the part before it.
if (version !== undefined && String(version).split('(')[0] !== pinned) {
parityErrors.push(
`package-lock.json nests ${nested} at ${packages[nested].version}, splitting the chromium revision; regenerate the lockfile`,
`pnpm-lock.yaml resolves ${inner} under ${outer}@${pinned} to ${version}, splitting the chromium revision; regenerate the lockfile`,
);
}
}
}
}
// Both lockfiles are committed together, so a specifier or a resolved version
// that lands in one and not the other is the same drift. pnpm's
// --frozen-lockfile validates specifiers only, so the version is asserted too.
// pnpm's --frozen-lockfile validates specifiers only, so the resolved version
// is asserted too.
// The recorded value is not always the manifest string: pnpm-workspace.yaml's
// `overrides:` and .pnpmfile.mjs's readPackage hook both rewrite it — web-shell
// declares `typescript: ^5.3.3` while the lockfile records `5.8.3`, because
@ -279,164 +235,3 @@ if (parityErrors.length > 0) {
} else {
console.log('Playwright parity check passed.');
}
// The dependency name behind a package-lock.json location. An aliased install
// (`"string-width-cjs": "npm:string-width@…"`) sits under the alias and
// records the real name in `name`.
function npmPackageName(location, details) {
if (details.name) return details.name;
const marker = 'node_modules/';
return location.slice(location.lastIndexOf(marker) + marker.length);
}
// pnpm decides a build from the whole allowBuilds key, not from its name
// alone: a bare name covers every version, `name@1.2.3` — or a `||` union of
// exact versions — covers only those, and a source-like key such as
// `name@file:packages/core` is an exact-instance rule no registry dependency
// can match. A range is not a scope at all: pnpm's parseVersionPolicyRule
// throws INVALID_VERSION_UNION ('Use exact versions only') on
// `esbuild@^0.25.0` and refuses to install, so counting such a key as no
// decision keeps this gate red for a tree pnpm would not build either.
// Reducing a key to its name would let an approval scoped to one version
// silently cover the next version npm installs.
function allowBuildDecisions(allowBuilds) {
const names = new Set();
const versions = new Set();
for (const [key, decision] of Object.entries(allowBuilds ?? {})) {
// pnpm records an undecided entry as the string 'set this to true or
// false'; only a boolean runs or skips a build.
if (typeof decision !== 'boolean') continue;
const at = key.indexOf('@', 1);
if (at === -1) {
names.add(key);
continue;
}
const name = key.slice(0, at);
for (const spec of key.slice(at + 1).split('||')) {
const version = spec.trim();
if (/^\d+\.\d+\.\d+[\w.+-]*$/.test(version)) {
versions.add(`${name}@${version}`);
}
}
}
return { names, versions };
}
console.log('Checking pnpm lockfile against package-lock.json...');
const npmLockedVersions = new Set();
const npmLockedSources = new Set();
for (const [location, details] of Object.entries(packages)) {
if (details.link === true || !location.includes('node_modules/')) {
continue;
}
npmLockedVersions.add(
`${npmPackageName(location, details)}@${details.version}`,
);
// pnpm keys a git or file: dependency by its source instead of a version
// (`name@git+https://…#hash`), so those keys can only ever match npm's
// `resolved`.
if (
details.resolved?.startsWith('git') ||
details.resolved?.startsWith('file:')
) {
npmLockedSources.add(
`${npmPackageName(location, details)}@${details.resolved}`,
);
}
}
// form-data nests mime-types@2.1.35, which requires exactly mime-db 1.52.0,
// but package-lock.json locks no nested mime-db there, so npm serves it the
// hoisted 1.54.0 while pnpm honours the pin. Drop the entry once npm locks
// 1.52.0 or form-data moves off mime-types@2.
const knownNpmLockGaps = new Set(['mime-db@1.52.0']);
// The two graphs are never equal, because pnpm dedupes where npm keeps nested
// copies: npm locks esbuild 0.25.6 at the root and 0.25.12 nested, and pnpm
// uses 0.25.12 for both. Read that as an accepted parity gap rather than
// harmless deduplication — a pnpm worktree bundles with a different esbuild
// than CI does, bounded only by npm staying authoritative for build,
// packaging and release. What this gate enforces is the direction that stays
// safe under that gap: a pnpm worktree must not run a dependency version that
// CI's npm install has never locked. Closing the gap itself is what the
// hoisted-parity measurement in docs/verification/pnpm-stage2-evidence/
// exists to settle.
const pnpmVersions = Object.keys(pnpmLockfile?.packages ?? {});
if (pnpmVersions.length === 0) {
console.error(
'Error: pnpm-lock.yaml has no packages section; the version agreement gate read nothing.',
);
process.exit(1);
}
const unlockedPnpmVersions = pnpmVersions.filter(
(key) =>
!npmLockedVersions.has(key) &&
!npmLockedSources.has(key) &&
!knownNpmLockGaps.has(key),
);
const staleNpmLockGaps = [...knownNpmLockGaps].filter(
(key) => !pnpmVersions.includes(key) || npmLockedVersions.has(key),
);
if (unlockedPnpmVersions.length > 0) {
console.error(
'\nError: pnpm-lock.yaml resolves versions that package-lock.json does not lock. Regenerate it from package-lock.json with `corepack pnpm import`. If the divergence survives that, a pnpm-workspace.yaml `overrides:` entry is deciding the version (three pin typescript today) and no npm-side regeneration can match it:',
);
unlockedPnpmVersions.forEach((key) => console.error(`- ${key}`));
process.exitCode = 1;
}
if (staleNpmLockGaps.length > 0) {
console.error(
'\nError: remove these entries from knownNpmLockGaps in scripts/check-lockfile.js; they no longer describe a gap:',
);
staleNpmLockGaps.forEach((key) => console.error(`- ${key}`));
process.exitCode = 1;
}
if (unlockedPnpmVersions.length === 0 && staleNpmLockGaps.length === 0) {
console.log('pnpm lockfile matches package-lock.json.');
}
console.log('Checking pnpm build approvals...');
const pnpmWorkspacePath = join(root, 'pnpm-workspace.yaml');
let pnpmWorkspace;
try {
pnpmWorkspace = parseYaml(fs.readFileSync(pnpmWorkspacePath, 'utf-8'));
} catch (error) {
console.error(`Error reading or parsing ${pnpmWorkspacePath}:`, error);
process.exit(1);
}
// npm runs every dependency install script; pnpm runs one only when
// allowBuilds approves it. Requiring an entry for each script npm runs keeps
// that difference a reviewed decision instead of a silent one.
const decidedBuilds = allowBuildDecisions(pnpmWorkspace?.allowBuilds);
// A bare name decides name-wide here, while pnpm honours that only for a
// registry-shaped depPath and wants a git-repo key for a git dependency. No
// install-script entry in package-lock.json resolves from git or a tarball
// today, so the two agree; the first one that does needs that key shape
// modelled here as well.
const undecidedBuilds = new Set();
for (const [location, details] of Object.entries(packages)) {
if (
details.hasInstallScript !== true ||
!location.includes('node_modules/')
) {
continue;
}
const name = npmPackageName(location, details);
if (decidedBuilds.names.has(name)) continue;
if (decidedBuilds.versions.has(`${name}@${details.version}`)) continue;
undecidedBuilds.add(`${name}@${details.version}`);
}
if (undecidedBuilds.size > 0) {
console.error(
'\nError: these dependencies have install scripts but no allowBuilds entry in pnpm-workspace.yaml covering the version npm locks; add each with true (run it) or false (skip it):',
);
[...undecidedBuilds].sort().forEach((name) => console.error(`- ${name}`));
process.exitCode = 1;
} else {
console.log('pnpm build approvals cover every install script.');
}

View file

@ -279,28 +279,34 @@ function writeDistPackageJson(rootDir, distDir) {
const rootPackageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
);
let lockfile;
try {
lockfile = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf-8'),
);
} catch (error) {
throw new Error(`Cannot read package-lock.json: ${error.message}`);
}
const coreManifest = JSON.parse(
fs.readFileSync(
path.join(rootDir, 'packages', 'core', 'package.json'),
'utf-8',
),
);
const sharpVersion =
lockfile.packages?.['packages/core/node_modules/sharp']?.version ??
lockfile.packages?.['node_modules/sharp']?.version;
// Resolve sharp the way core does at runtime — the nearest
// node_modules/sharp above packages/core — so the published manifest pins
// the version this release was built against, whichever package manager
// installed the tree.
let sharpVersion;
for (
let dir = path.join(rootDir, 'packages', 'core');
;
dir = path.dirname(dir)
) {
const manifest = path.join(dir, 'node_modules', 'sharp', 'package.json');
if (fs.existsSync(manifest)) {
sharpVersion = JSON.parse(fs.readFileSync(manifest, 'utf-8')).version;
break;
}
if (dir === rootDir || path.dirname(dir) === dir) break;
}
const declared = coreManifest.dependencies?.sharp;
if (!sharpVersion || !declared || !semver.satisfies(sharpVersion, declared)) {
throw new Error(
`sharp version is not locked in package-lock.json ` +
`(resolved ${sharpVersion ?? 'none'}, ` +
`sharp is not installed at a version packages/core accepts ` +
`(installed ${sharpVersion ?? 'none'}, ` +
`packages/core declares ${declared ?? 'none'})`,
);
}

View file

@ -103,6 +103,10 @@ describe('stable release notes workflow', () => {
expect(validate).toContain('is not a stable release tag');
expect(validate).toContain('exit 1');
expect(checkout).toContain('persist-credentials: false');
expect(install).toContain(
'corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only',
);
expect(install).toContain('if [ -f pnpm-lock.yaml ]');
expect(install).toContain(
'npm ci --ignore-scripts --no-audit --progress=false',
);

View file

@ -10,6 +10,7 @@ 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';
import { copyBundleAssets } from '../copy_bundle_assets.js';
import { copyFiles } from '../copy_files.js';
@ -256,22 +257,20 @@ describe('browser-use builtin resources', () => {
'utf8',
),
);
const lock = JSON.parse(
fs.readFileSync(
new URL('../../package-lock.json', import.meta.url),
'utf8',
),
const lock = parse(
fs.readFileSync(new URL('../../pnpm-lock.yaml', import.meta.url), 'utf8'),
);
const workspace = lock.packages['packages/browser-use'];
const playwright =
lock.packages['packages/browser-use/node_modules/playwright-core'];
lock.importers['packages/browser-use'].dependencies['playwright-core'];
expect(manifest.dependencies['playwright-core']).toMatch(/^\d+\.\d+\.\d+$/);
expect(manifest.bundledDependencies).toBeUndefined();
expect(manifest.bundleDependencies).toBeUndefined();
expect(workspace.bundleDependencies).toBeUndefined();
expect(playwright.inBundle).not.toBe(true);
expect(playwright.specifier).toBe(manifest.dependencies['playwright-core']);
expect(playwright.version).toBe(manifest.dependencies['playwright-core']);
expect(
lock.packages[`playwright-core@${playwright.version}`],
).toBeDefined();
});
function write(relativePath, contents) {

View file

@ -27,13 +27,12 @@ const script = join(root, 'scripts', 'check-lockfile.js');
// is gitignored, so a crashed run cannot leave tracked residue, and Node
// resolves the script's `yaml` import by walking up into the real
// node_modules. Copying the real manifests and lockfiles in — rather than
// writing minimal ones — keeps the script's two integrity sections green, so
// the parity section is the only thing under test.
// writing minimal ones — keeps the script's integrity section green, so the
// parity section is the only thing under test.
let fixtureRoot;
const FILES = [
'package.json',
'package-lock.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'packages/web-shell/package.json',
@ -56,8 +55,8 @@ function runCheck() {
return { status: result.status, out: result.stdout + result.stderr };
}
// The parity banner is what separates this section's verdict from the two
// integrity sections', so every arm asserts on it rather than on the exit code
// The parity banner is what separates this section's verdict from the
// integrity section's, so every arm asserts on it rather than on the exit code
// alone.
function parityLines(out) {
return out.slice(out.indexOf('Checking Playwright parity...'));
@ -84,12 +83,14 @@ function replaced(text, from, to) {
return patched;
}
function perturbNpmLockfile(mutate) {
const lockfile = JSON.parse(
readFileSync(join(fixtureRoot, 'package-lock.json'), 'utf8'),
);
mutate(lockfile.packages);
writeFixture('package-lock.json', JSON.stringify(lockfile, null, 2));
// A package's own dependencies are recorded only in the snapshots section, so
// these blocks are unique in the file; `replaced` proves each edit landed.
const TEST_SNAPSHOT = ` '@playwright/test@${PINNED}':\n dependencies:\n playwright: ${PINNED}\n`;
const PLAYWRIGHT_SNAPSHOT = ` playwright@${PINNED}:\n dependencies:\n playwright-core: ${PINNED}\n`;
function perturbPnpmSnapshot(from, to) {
const text = readFileSync(join(fixtureRoot, 'pnpm-lock.yaml'), 'utf8');
writeFixture('pnpm-lock.yaml', replaced(text, from, to));
}
// Scoped to the web-shell importer block: the same specifier string appears
@ -184,74 +185,57 @@ describe('check-lockfile Playwright parity', () => {
}
});
it('rejects a lockfile that resolves the hoisted package off the pin', () => {
try {
perturbNpmLockfile((packages) => {
packages['node_modules/playwright'].version = '1.58.2';
});
const { status, out } = runCheck();
expect(parityLines(out)).toContain(
`resolves node_modules/playwright to 1.58.2 instead of ${PINNED}`,
);
expect(status).toBe(1);
} finally {
restore();
}
});
it('rejects a lockfile that lost the hoisted package entirely', () => {
try {
perturbNpmLockfile((packages) => {
delete packages['node_modules/@playwright/test'];
});
const { status, out } = runCheck();
expect(parityLines(out)).toContain(
'has no node_modules/@playwright/test entry',
);
expect(status).toBe(1);
} finally {
restore();
}
});
it('rejects a nested copy at a different revision', () => {
try {
perturbNpmLockfile((packages) => {
packages['node_modules/@playwright/test/node_modules/playwright'] = {
version: '1.62.0',
dev: true,
resolved:
'https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz',
integrity: 'sha512-fixture=',
};
});
perturbPnpmSnapshot(
TEST_SNAPSHOT,
TEST_SNAPSHOT.replace(`playwright: ${PINNED}`, 'playwright: 1.62.0'),
);
const { status, out } = runCheck();
expect(parityLines(out)).toContain('splitting the chromium revision');
expect(parityLines(out)).toContain(
`resolves playwright under @playwright/test@${PINNED} to 1.62.0, splitting the chromium revision`,
);
expect(status).toBe(1);
} finally {
restore();
}
});
it('tolerates a nested copy at the pinned revision', () => {
it('rejects a missing snapshot for a pinned package', () => {
try {
// A same-version duplicate is a redundant install, not a split revision,
// so it must not be reported as one.
perturbNpmLockfile((packages) => {
packages['node_modules/@playwright/test/node_modules/playwright'] = {
version: PINNED,
dev: true,
resolved: `https://registry.npmjs.org/playwright/-/playwright-${PINNED}.tgz`,
integrity: 'sha512-fixture=',
};
perturbPnpmSnapshot(
TEST_SNAPSHOT,
TEST_SNAPSHOT.replace(
` '@playwright/test@${PINNED}':`,
` '@playwright/test-missing@${PINNED}':`,
),
);
const { status, out } = runCheck();
expect(parityLines(out)).toContain(
`pnpm-lock.yaml has no snapshot for @playwright/test@${PINNED}`,
);
expect(status).toBe(1);
} finally {
restore();
}
});
it('tolerates a peer suffix on the pinned revision', () => {
try {
// pnpm appends the peers it resolved to a version; the revision is the
// part before them, so a suffix alone is not a split.
perturbPnpmSnapshot(
TEST_SNAPSHOT,
TEST_SNAPSHOT.replace(
`playwright: ${PINNED}`,
`playwright: ${PINNED}(fsevents@2.3.2)`,
),
);
const { status, out } = runCheck();
expect(parityLines(out)).toContain('Playwright parity check passed.');
@ -400,22 +384,20 @@ describe('check-lockfile Playwright parity', () => {
it('rejects a nested copy in the other direction too', () => {
try {
// The nested candidates are derived from the manifest list, so the pair
// is checked both ways: an @playwright/test nested under the hoisted
// playwright splits the chromium revision exactly as the reverse does.
perturbNpmLockfile((packages) => {
packages['node_modules/playwright/node_modules/@playwright/test'] = {
version: '1.62.0',
dev: true,
resolved:
'https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz',
integrity: 'sha512-fixture=',
};
});
// is checked both ways: a playwright that depends on another
// @playwright/test splits the chromium revision exactly as the reverse does.
perturbPnpmSnapshot(
PLAYWRIGHT_SNAPSHOT,
PLAYWRIGHT_SNAPSHOT.replace(
'dependencies:\n',
"dependencies:\n '@playwright/test': 1.62.0\n",
),
);
const { status, out } = runCheck();
expect(parityLines(out)).toContain(
'nests node_modules/playwright/node_modules/@playwright/test at 1.62.0',
`resolves @playwright/test under playwright@${PINNED} to 1.62.0`,
);
expect(status).toBe(1);
} finally {

View file

@ -469,6 +469,18 @@ describe('Desktop OSS mirror workflow', () => {
});
describe('Desktop release event', () => {
it('installs historical Qwen Code refs with their available lockfile', () => {
const install = getWorkflowStep(
getWorkflowJob(releaseWorkflow, 'build'),
'Install Qwen Code dependencies',
);
expect(install).toContain('if [ -f pnpm-lock.yaml ]');
expect(install).toContain(
'corepack pnpm install --frozen-lockfile --prefer-offline',
);
expect(install).toContain('npm ci --no-audit --progress=false');
});
it('gates automatic publishing like the VS Code release workflow', () => {
expect(releaseWorkflow).toContain("release:\n types: ['published']");
const prepare = getWorkflowJob(releaseWorkflow, 'prepare');

View file

@ -792,6 +792,11 @@ describe('standalone release packaging', () => {
});
it('loads the standalone release packaging helper', () => {
const releaseScript = readScript('scripts/build-standalone-release.js');
expect(releaseScript).toMatch(
/let pnpmLockedKeys;[\s\S]*if \(isMainModule\(\)\)/,
);
const output = execFileSync(
process.execPath,
['scripts/build-standalone-release.js', '--help'],
@ -2612,6 +2617,9 @@ describe('standalone release packaging', () => {
expect(ossWorkflow).toContain(
'npm run verify:installation-release -- --dir dist/standalone',
);
expect(ossWorkflow).toContain('if [ -f pnpm-lock.yaml ]');
expect(ossWorkflow).toContain('corepack pnpm install --frozen-lockfile');
expect(ossWorkflow).toContain('npm ci --no-audit --progress=false');
// The sync workflow can be re-dispatched for any tag, and its steps come
// from the default branch while the checkout and the assets come from that
// tag, so it derives the flavor from the archives the release actually

View file

@ -79,6 +79,17 @@ describe('Live Host OSS mirror workflow', () => {
);
});
it('publishes qwen-live from the committed pnpm graph', () => {
const install = getWorkflowStep(
getWorkflowJob(releaseWorkflow, 'publish'),
'Install dependencies',
);
expect(install).toContain(
'corepack pnpm install --frozen-lockfile --ignore-scripts',
);
expect(install).not.toContain("run: 'npm install");
});
it('uploads and verifies one release without an OSS state machine', () => {
const sync = getWorkflowJob(syncWorkflow, 'sync');
expect(sync).toContain("name: 'production-release'");

View file

@ -1030,19 +1030,12 @@ describe('package asset scripts', () => {
).toBe(true);
});
it('falls back to the hoisted lockfile entry when core has no nested sharp', () => {
it('falls back to the hoisted sharp when core has no nested copy', () => {
const rootDir = createFixtureRoot();
writeFile(
rootDir,
'package-lock.json',
JSON.stringify({
packages: {
'node_modules/sharp': {
version: '0.35.3',
},
},
}),
);
rmSync(path.join(rootDir, 'packages/core/node_modules'), {
recursive: true,
force: true,
});
writeFile(
rootDir,
'packages/core/package.json',
@ -1120,7 +1113,7 @@ describe('package asset scripts', () => {
expect(() =>
preparePackage({ rootDir, requireNativeAudioCapture: false }),
).toThrow(/resolved 0\.35\.4, packages\/core declares \^0\.34\.0/);
).toThrow(/installed 0\.35\.4, packages\/core declares \^0\.34\.0/);
});
it('omits browser MCP install hooks and deps from the prepared dist package', () => {
@ -1461,21 +1454,13 @@ describe('package asset scripts', () => {
writeFile(
rootDir,
'package-lock.json',
JSON.stringify(
{
packages: {
'node_modules/sharp': {
version: '0.35.3',
},
'packages/core/node_modules/sharp': {
version: '0.35.4',
},
},
},
null,
2,
),
'node_modules/sharp/package.json',
JSON.stringify({ name: 'sharp', version: '0.35.3' }),
);
writeFile(
rootDir,
'packages/core/node_modules/sharp/package.json',
JSON.stringify({ name: 'sharp', version: '0.35.4' }),
);
writeFile(

View file

@ -154,7 +154,18 @@ describe('package scripts', () => {
});
});
it('checks both lockfiles for integrity and agreement', () => {
it('preserves the registry retry policy under pnpm', () => {
const workspace = parse(readWorkflow('pnpm-workspace.yaml'));
expect(workspace).toMatchObject({
fetchRetries: 5,
fetchRetryMintimeout: 20000,
fetchRetryMaxtimeout: 120000,
fetchTimeout: 300000,
});
});
it('checks the pnpm lockfile for integrity and Playwright parity', () => {
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/check-lockfile.js')],
@ -162,12 +173,7 @@ describe('package scripts', () => {
);
expect(result.status).toBe(0);
expect(result.stdout).toContain('Lockfile check passed.');
expect(result.stdout).toContain('pnpm lockfile check passed.');
expect(result.stdout).toContain('pnpm lockfile matches package-lock.json.');
expect(result.stdout).toContain(
'pnpm build approvals cover every install script.',
);
// Pins the Playwright parity block's existence and happy path: deleting it,
// or returning before it, goes red here. Its drift arms live in
// check-lockfile.test.js, which runs the script against perturbed fixtures.
@ -186,7 +192,6 @@ describe('package scripts', () => {
// them the run dies on a missing package.json before reaching the
// branch under test.
for (const file of [
'package-lock.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'package.json',
@ -210,13 +215,6 @@ describe('package scripts', () => {
}
}
function mutatePackageLock(fixtureRoot, mutate) {
const file = path.join(fixtureRoot, 'package-lock.json');
const lock = JSON.parse(readFileSync(file, 'utf8'));
mutate(lock);
writeFileSync(file, JSON.stringify(lock));
}
function mutatePnpmLock(fixtureRoot, mutate) {
const file = path.join(fixtureRoot, 'pnpm-lock.yaml');
const lock = parse(readFileSync(file, 'utf8'));
@ -224,102 +222,6 @@ describe('package scripts', () => {
writeFileSync(file, stringify(lock));
}
function mutatePnpmWorkspace(fixtureRoot, mutate) {
const file = path.join(fixtureRoot, 'pnpm-workspace.yaml');
const workspace = parse(readFileSync(file, 'utf8'));
mutate(workspace);
writeFileSync(file, stringify(workspace));
}
it('fails when pnpm resolves a version npm has not locked', () => {
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmLock(fixtureRoot, (lock) => {
lock.packages['bogus-package@9.9.9'] = {
resolution: { integrity: 'sha512-bogus' },
};
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'versions that package-lock.json does not lock',
);
expect(result.stderr).toContain('- bogus-package@9.9.9');
});
it('fails when a knownNpmLockGaps entry no longer describes a gap', () => {
// mime-db@1.52.0 is the committed exception; npm locking it makes the
// exception stale.
const result = runCheckLockfile((fixtureRoot) =>
mutatePackageLock(fixtureRoot, (lock) => {
lock.packages['node_modules/mime-types/node_modules/mime-db'] = {
version: '1.52.0',
resolved: 'https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz',
integrity: 'sha512-bogus',
};
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'remove these entries from knownNpmLockGaps',
);
expect(result.stderr).toContain('- mime-db@1.52.0');
});
it('fails when a knownNpmLockGaps entry leaves the pnpm graph', () => {
// Deleting the pnpm key exercises the `!pnpmVersions.includes(key)`
// arm: the allowlist entry survives with nothing to describe, and the
// gate must say so instead of passing the version agreement.
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmLock(fixtureRoot, (lock) => {
delete lock.packages['mime-db@1.52.0'];
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'remove these entries from knownNpmLockGaps',
);
expect(result.stderr).toContain('- mime-db@1.52.0');
});
it('fails when an install script has no allowBuilds decision', () => {
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmWorkspace(fixtureRoot, (workspace) => {
delete workspace.allowBuilds.esbuild;
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('no allowBuilds entry');
expect(result.stderr).toContain('- esbuild');
});
it('reports an aliased install script under its real package name', () => {
// npmPackageName returns `details.name` for an aliased install
// (`"build-tool-cjs": "npm:build-tool@…"`). The build-approval loop
// must compare the real name, not the alias, so an allowBuilds entry
// under the alias cannot silence the gate.
const result = runCheckLockfile((fixtureRoot) =>
mutatePackageLock(fixtureRoot, (lock) => {
lock.packages['node_modules/build-tool-cjs'] = {
name: 'build-tool',
version: '1.0.0',
resolved:
'https://registry.npmjs.org/build-tool/-/build-tool-1.0.0.tgz',
integrity: 'sha512-bogus',
hasInstallScript: true,
};
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('no allowBuilds entry');
expect(result.stderr).toContain('- build-tool');
expect(result.stderr).not.toContain('- build-tool-cjs');
});
it('fails closed when pnpm-lock.yaml has no packages section', () => {
const result = runCheckLockfile((fixtureRoot) => {
writeFileSync(
@ -334,98 +236,28 @@ describe('package scripts', () => {
it('accepts a git dependency pnpm keys by source instead of version', () => {
const resolved = 'git+https://github.com/example/git-dep.git#7ae66ab2';
const result = runCheckLockfile((fixtureRoot) => {
mutatePackageLock(fixtureRoot, (lock) => {
lock.packages['node_modules/git-dep'] = {
version: '1.2.3',
resolved,
};
});
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmLock(fixtureRoot, (lock) => {
lock.packages[`git-dep@${resolved}`] = {
resolution: { type: 'git' },
};
});
});
expect(result.status).toBe(0);
expect(result.stdout).toContain(
'pnpm lockfile matches package-lock.json.',
);
});
it('accepts an allowBuilds key scoped to the version npm locks', () => {
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmWorkspace(fixtureRoot, (workspace) => {
delete workspace.allowBuilds.keytar;
workspace.allowBuilds['keytar@7.9.0'] = true;
}),
);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
'pnpm build approvals cover every install script.',
);
expect(result.stdout).toContain('pnpm lockfile check passed.');
});
it('rejects an allowBuilds key scoped to another version', () => {
// pnpm matches a version-scoped key exactly, so an approval written for
// one version says nothing about the one npm actually locks.
it('fails when a registry package loses its integrity hash', () => {
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmWorkspace(fixtureRoot, (workspace) => {
delete workspace.allowBuilds.keytar;
workspace.allowBuilds['keytar@7.0.0'] = true;
mutatePnpmLock(fixtureRoot, (lock) => {
lock.packages['left-pad@1.3.0'] = { resolution: {} };
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('no allowBuilds entry');
expect(result.stderr).toContain('- keytar@7.9.0');
});
it('accepts a union of exact versions', () => {
// pnpm writes this shape itself when it merges version-scoped rules:
// the name once, then bare exact versions joined by `||`.
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmWorkspace(fixtureRoot, (workspace) => {
delete workspace.allowBuilds.keytar;
workspace.allowBuilds['keytar@7.0.0 || 7.9.0'] = true;
}),
);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
'pnpm build approvals cover every install script.',
);
});
it('ignores a range-scoped allowBuilds key, which decides nothing', () => {
// A range is not a version scope: pnpm's parseVersionPolicyRule throws
// INVALID_VERSION_UNION ('Use exact versions only') on `esbuild@^0.25.0`,
// so this key decides nothing and the gate stays red for that tree.
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmWorkspace(fixtureRoot, (workspace) => {
delete workspace.allowBuilds.esbuild;
workspace.allowBuilds['esbuild@^0.25.0'] = true;
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('no allowBuilds entry');
expect(result.stderr).toContain('- esbuild@');
});
it('ignores an allowBuilds placeholder that decides nothing', () => {
const result = runCheckLockfile((fixtureRoot) =>
mutatePnpmWorkspace(fixtureRoot, (workspace) => {
// pnpm writes this string for an install whose build it skipped.
workspace.allowBuilds.esbuild = 'set this to true or false';
}),
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('no allowBuilds entry');
expect(result.stderr).toContain('- esbuild');
expect(result.stderr).toContain('missing "resolution.integrity"');
expect(result.stderr).toContain('- left-pad@1.3.0');
});
});
@ -1088,10 +920,8 @@ describe('package scripts', () => {
const workflow = parse(
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
);
// package-lock.json is deliberately absent: no step in this job reads
// it (the install validates pnpm-lock.yaml against the workspace
// manifests), and npm/pnpm version agreement is gated by the 'Check
// lockfile' step in ci.yml.
// The install validates pnpm-lock.yaml against the workspace manifests,
// so these are the inputs that can change its result.
const expectedPaths = [
'.github/workflows/pnpm-worktree-smoke.yml',
'.npmrc',
@ -1641,7 +1471,7 @@ describe('package scripts', () => {
expect(installSteps.length).toBe(3);
for (const installStep of installSteps) {
expect(installStep).toContain(
'npm ci --ignore-scripts --no-audit --progress=false',
'corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only',
);
expect(installStep).toContain('npm run postinstall');
expect(installStep).toContain('npm run generate');
@ -1826,7 +1656,7 @@ describe('package scripts', () => {
expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'");
expect(installStep).toContain(
'npm ci --prefer-offline --no-audit --progress=false',
'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only',
);
if (armsHooks) {
expect(installStep).toContain('git config core.hooksPath .husky');

View file

@ -276,80 +276,6 @@ const hasSha256sum =
stdio: 'ignore',
}).status === 0;
// GitHub Actions expressions return operand VALUES from &&/||, not
// booleans: && yields the first falsy operand (else the last operand), ||
// the first truthy (else the last), '' is falsy, and && binds tighter
// than ||. A ternary can therefore read right and evaluate wrong, which
// text pins cannot see — so the cache choice is also pinned semantically
// with this minimal evaluator.
function evalGhaExpression(expression, facts) {
let pos = 0;
const truthy = (value) =>
value !== false && value !== null && value !== 0 && value !== '';
const skipSpace = () => {
while (/\s/.test(expression[pos] ?? '')) {
pos += 1;
}
};
const parsePrimary = () => {
skipSpace();
if (expression[pos] === "'") {
const end = expression.indexOf("'", pos + 1);
const value = expression.slice(pos + 1, end);
pos = end + 1;
return value;
}
const name = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(expression.slice(pos))[0];
pos += name.length;
if (name === 'true') {
return true;
}
if (name === 'false') {
return false;
}
if (name === 'null') {
return null;
}
return facts[name];
};
const parseComparison = () => {
const left = parsePrimary();
skipSpace();
const op = expression.slice(pos, pos + 2);
if (op !== '==' && op !== '!=') {
return left;
}
pos += 2;
const right = parsePrimary();
return op === '==' ? left === right : left !== right;
};
const parseAnd = () => {
let left = parseComparison();
for (;;) {
skipSpace();
if (expression.slice(pos, pos + 2) !== '&&') {
return left;
}
pos += 2;
const right = parseComparison();
left = truthy(left) ? right : left;
}
};
const parseOr = () => {
let left = parseAnd();
for (;;) {
skipSpace();
if (expression.slice(pos, pos + 2) !== '||') {
return left;
}
pos += 2;
const right = parseAnd();
left = truthy(left) ? left : right;
}
};
return parseOr();
}
function readAutofixSkill() {
return readFileSync('.qwen/skills/autofix/SKILL.md', 'utf8');
}
@ -9322,7 +9248,7 @@ exit 1
);
expect(forkMain.split('\n').pop()).toBe('false 0');
expect(measureBlock).toContain(
"GENERATED_EXCLUDES=(':(exclude,glob)**/package-lock.json' ':(exclude,glob)**/npm-shrinkwrap.json' ':(exclude)packages/vscode-ide-companion/schemas/settings.schema.json')",
"GENERATED_EXCLUDES=(':(exclude,glob)**/package-lock.json' ':(exclude,glob)**/npm-shrinkwrap.json' ':(exclude,glob)**/pnpm-lock.yaml' ':(exclude)packages/vscode-ide-companion/schemas/settings.schema.json')",
);
} finally {
rmSync(dir, { recursive: true, force: true });
@ -10774,7 +10700,7 @@ exit 1
for (const step of installAndBuildSteps) {
expect(step).toContain('for attempt in 1 2 3; do');
expect(step).toContain(
'npm ci --prefer-offline --no-audit --progress=false',
'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only',
);
expect(step).toContain('sleep $((attempt * 15))');
expect(step).toContain('npm run build');
@ -10795,15 +10721,11 @@ exit 1
'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e',
);
expect(step).toContain("node-version: '22.x'");
// The cache is the one input that is NOT the same on both pools — see
// 'does not restore the remote npm cache on the persistent pool'. The
// inputs are still identical across the three steps, which is what
// this test is for.
expect(step).toContain(
`cache: "\${{ runner.environment != 'self-hosted' && 'npm' || '' }}"`,
);
// No setup-node step restores a cache: dependencies install with pnpm,
// and nothing restores the pnpm store either, as pinned by
// 'does not restore a remote cache on the persistent pool'.
expect(step).not.toMatch(/^\s*cache(-dependency-path)?:/m);
expect(step).toContain('package-manager-cache: false');
expect(step).toContain("cache-dependency-path: 'package-lock.json'");
}
});
@ -10900,7 +10822,9 @@ exit 1
// The leg itself never rebuilds the base bundle — that is the entire
// point of the fan-out.
const legInstall = stepOf(reviewAddressJob, 'Install dependencies');
expect(legInstall).toContain('npm ci --prefer-offline');
expect(legInstall).toContain(
'pnpm install --frozen-lockfile --prefer-offline',
);
expect(legInstall).not.toContain('npm run build');
expect(legInstall).not.toContain('npm run bundle');
});
@ -12718,38 +12642,22 @@ exit 1
}
});
it('does not restore the remote npm cache on the persistent pool', () => {
it('does not restore a remote cache on the persistent pool', () => {
// Measured on one review-address leg: `Set up Node.js` took 339s, of
// which Node itself was free (already in the runner tool cache) and
// 2,654,052,865 bytes at ~10 MB/s were the npm cache restore — guarding
// an `npm ci` that took 29s in the very next step. Every leg pays it,
// up to ten per scan, plus build-cli and issue-autofix.
// All three consumers, so a fourth job with a hardcoded cache fails
// here rather than quietly paying 2.65 GB per run — counted by step
// name, so no choice of inputs can dodge the capture.
// up to ten per scan, plus build-cli and issue-autofix. Dependencies now
// install with pnpm, whose store stays on the pool's disk. Nothing
// restores it remotely: the hosted fallback installs cold, because the
// shared pnpm-store-cache action is a local `uses: './...'` step, which
// 'pins the persistent-pool hygiene steps into every heavy job' forbids.
expect(nodeSetupSteps).toHaveLength(3);
for (const step of nodeSetupSteps) {
expect(step).toContain(
`cache: "\${{ runner.environment != 'self-hosted' && 'npm' || '' }}"`,
);
}
// Text pins cannot tell a ternary that works from one that GHA's
// operand-value &&/|| semantics defeat — this PR's first attempt read
// correctly and still restored the cache on BOTH pools. Evaluate the
// pinned expression the way Actions does: '' on the persistent pool,
// 'npm' on the ephemeral hosted fallback.
const cacheExpression =
nodeSetupSteps[0].match(/cache: "\$\{\{ ([^}]+) \}\}"/)?.[1] ?? '';
for (const [environment, expected] of [
['self-hosted', ''],
['github-hosted', 'npm'],
]) {
expect(
evalGhaExpression(cacheExpression, {
'runner.environment': environment,
}),
).toBe(expected);
expect(step).not.toMatch(/^\s*cache:/m);
}
expect(workflow).not.toContain('actions/cache');
expect(workflow).not.toContain('pnpm-store-cache');
});
it('passes model credentials directly to qwen subprocesses', () => {
@ -13862,6 +13770,9 @@ exit 1
'packages/channels/github/tsconfig.json',
'package-lock.json',
'packages/cli/package-lock.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'.pnpmfile.mjs',
'patches/ink+7.0.3.patch',
'.gitattributes',
'packages/core/.gitattributes',
@ -13886,6 +13797,9 @@ exit 1
);
expect(classes).toContain('package-lock.json=supply-chain');
expect(classes).toContain('packages/cli/package-lock.json=supply-chain');
expect(classes).toContain('pnpm-lock.yaml=supply-chain');
expect(classes).toContain('pnpm-workspace.yaml=supply-chain');
expect(classes).toContain('.pnpmfile.mjs=supply-chain');
expect(classes).toContain('patches/ink+7.0.3.patch=supply-chain');
expect(classes).toContain('.gitattributes=measurement-config');
expect(classes).toContain(

View file

@ -237,6 +237,9 @@ describe('repo-hygiene workflow structure', () => {
'write_file(/.git/**)',
'write_file(**/package.json)',
'write_file(**/package-lock.json)',
'write_file(**/pnpm-lock.yaml)',
'write_file(**/pnpm-workspace.yaml)',
'write_file(**/.pnpmfile.mjs)',
'write_file(**/eslint.config.*)',
'write_file(**/vitest.config.*)',
'write_file(**/vite.config.*)',

View file

@ -24,7 +24,7 @@ import { parse } from 'yaml';
const workflow = readFileSync('.github/workflows/qwen-triage.yml', 'utf8');
const cacheProducerWorkflow = readFileSync(
'.github/workflows/npm-cache.yml',
'.github/workflows/pnpm-store.yml',
'utf8',
);
const prSkill = readFileSync(
@ -945,7 +945,7 @@ describe('qwen-triage tmux workflow', () => {
expect(prepareStep).toContain('-u GITHUB_PATH');
expect(prepareStep).toContain('-u GITHUB_STEP_SUMMARY');
expect(prepareStep).toMatch(
new RegExp(`${escapeRegExp(strippedEnv)} \\\\\\s+npm ci`),
new RegExp(`${escapeRegExp(strippedEnv)} \\\\\\s+corepack pnpm install`),
);
expect(prepareStep).toMatch(
new RegExp(`${escapeRegExp(strippedEnv)} \\\\\\s+npm run build`),
@ -2862,9 +2862,35 @@ describe('qwen-triage verify hardening', () => {
}).decision,
).toBe('run');
const pnpmOffReg = run({
trust: 'external',
diff: "+++ b/pnpm-lock.yaml\n+ resolution: {tarball: 'https://evil.example/x.tgz'}\n",
});
expect(pnpmOffReg.decision).toBe('skip');
expect(pnpmOffReg.reason).toContain('registry.npmjs.org');
const pnpmLookalike = run({
trust: 'external',
diff: "+++ b/pnpm-lock.yaml\n+ resolution: {tarball: 'https://evil.example/?u=https://registry.npmjs.org/x.tgz'}\n",
});
expect(pnpmLookalike.decision).toBe('skip');
expect(pnpmLookalike.reason).toContain('registry.npmjs.org');
expect(
run({
trust: 'external',
diff: "+++ b/pnpm-lock.yaml\n+ resolution: {tarball: 'https://registry.npmjs.org/left-pad/-/left-pad-1.0.0.tgz'}\n",
}).decision,
).toBe('run');
// Package-manager config: settings like `script-shell` redirect what
// every later npm invocation executes.
for (const cfg of ['.npmrc', '.yarnrc', '.yarnrc.yml', 'bunfig.toml']) {
for (const cfg of [
'.npmrc',
'.yarnrc',
'.yarnrc.yml',
'bunfig.toml',
'pnpm-workspace.yaml',
'.pnpmfile.mjs',
]) {
const pm = run({
trust: 'external',
diff: `+++ b/${cfg}\n+script-shell=/tmp/evil\n`,
@ -4975,7 +5001,7 @@ describe('qwen-triage verify publish fidelity', () => {
PREPARE_FAILURE_PHASE: 'install',
});
expect(real).toContain('treated as a PR failure');
expect(real).toContain('npm ci');
expect(real).toContain('pnpm install');
// The install is retried, so this sentence is blaming the PR for two
// consecutive failures and has to say which. Without the count a
// reader cannot tell this verdict from the single-shot one that
@ -4999,7 +5025,7 @@ describe('qwen-triage verify publish fidelity', () => {
PREPARE_FAILURE_PHASE: 'build',
});
expect(buildPhase).toContain('npm run build');
expect(buildPhase).not.toContain('`npm ci` failed');
expect(buildPhase).not.toContain('`pnpm install` failed');
// The build is single-shot, so the retry clause must not leak onto it.
expect(buildPhase).not.toContain('twice in a row');
@ -6081,6 +6107,16 @@ describe('qwen-triage verify round-3 hardening', () => {
}
});
it('bootstraps pnpm before the tmux verdict-producing install step', () => {
const tmuxJob = job('tmux-testing');
const bootstrap = stepIn('tmux-testing', 'Bootstrap pnpm');
expect(bootstrap).toContain('runuser -u node -- corepack pnpm --version');
expect(bootstrap).not.toContain('verdict=');
expect(tmuxJob.indexOf("name: 'Bootstrap pnpm'")).toBeLessThan(
tmuxJob.indexOf("name: 'Install and build PR app'"),
);
});
// Run 30319209722 reported `fail` against a PR whose only crime was that
// npm exec'd esbuild's binary before its own write was closed (ETXTBSY),
// so the install is now retried once.
@ -6099,8 +6135,10 @@ describe('qwen-triage verify round-3 hardening', () => {
const dir = mkdtempSync(join(tmpdir(), 'prepare-retry-'));
try {
const work = join(dir, 'work');
mkdirSync(work, { recursive: true });
const calls = join(dir, 'npm-ci-calls');
// Stands in for what a failed attempt leaves behind: the retry must
// clear it, since pnpm install (unlike npm ci) keeps node_modules.
mkdirSync(join(work, 'node_modules', 'stale'), { recursive: true });
const calls = join(dir, 'install-calls');
writeFileSync(calls, '');
writeFileSync(
join(dir, 'runuser'),
@ -6113,17 +6151,14 @@ describe('qwen-triage verify round-3 hardening', () => {
{ mode: 0o755 },
);
writeFileSync(
join(dir, 'npm'),
join(dir, 'corepack'),
[
'#!/usr/bin/env bash',
// Only `ci` is counted/failed: `run build` shares this stub and
// must stay a success, or an install-phase assertion could pass
// because the BUILD failed instead.
'if [ "$1" = ci ]; then',
' printf "ci\\n" >> "$NPM_CI_CALLS"',
' n=$(wc -l < "$NPM_CI_CALLS" | tr -d " ")',
' if [ "$n" -le "$NPM_CI_FAILURES" ]; then',
' echo "npm error ETXTBSY" >&2',
'if [ "$1 $2" = "pnpm install" ]; then',
' printf "install\\n" >> "$INSTALL_CALLS"',
' n=$(wc -l < "$INSTALL_CALLS" | tr -d " ")',
' if [ "$n" -le "$INSTALL_FAILURES" ]; then',
' echo "ERR_PNPM_LIFECYCLE ETXTBSY" >&2',
' exit 1',
' fi',
'fi',
@ -6131,6 +6166,11 @@ describe('qwen-triage verify round-3 hardening', () => {
].join('\n'),
{ mode: 0o755 },
);
// `npm run build` must stay a success, or an install-phase assertion
// could pass because the BUILD failed instead.
writeFileSync(join(dir, 'npm'), '#!/usr/bin/env bash\nexit 0\n', {
mode: 0o755,
});
for (const noop of ['chown', 'curl']) {
writeFileSync(join(dir, noop), '#!/usr/bin/env bash\nexit 0\n', {
mode: 0o755,
@ -6144,8 +6184,8 @@ describe('qwen-triage verify round-3 hardening', () => {
env: {
...process.env,
PATH: `${dir}:${process.env.PATH}`,
NPM_CI_CALLS: calls,
NPM_CI_FAILURES: String(failures),
INSTALL_CALLS: calls,
INSTALL_FAILURES: String(failures),
RUNNER_TEMP: dir,
GITHUB_WORKSPACE: work,
GITHUB_OUTPUT: out,
@ -6160,6 +6200,7 @@ describe('qwen-triage verify round-3 hardening', () => {
? readFileSync(calls, 'utf8').trim().split('\n').length
: 0,
log: readFileSync(join(dir, resultsDir, 'prepare.log'), 'utf8'),
staleLeft: existsSync(join(work, 'node_modules', 'stale')),
};
} finally {
rmSync(dir, { recursive: true, force: true });
@ -6170,7 +6211,7 @@ describe('qwen-triage verify round-3 hardening', () => {
['verify', 'verify-results'],
['tmux-testing', 'tmux-results'],
]) {
it(`retries a transient npm ci once in the ${jobName} lane`, () => {
it(`retries a transient pnpm install once in the ${jobName} lane`, () => {
// One ETXTBSY-style failure then success: the run must continue to
// the build with no verdict at all. This is the arm the bug lives in
// — before the retry it emitted verdict=fail here.
@ -6179,6 +6220,7 @@ describe('qwen-triage verify round-3 hardening', () => {
expect(flaky.output).not.toContain('verdict=');
expect(flaky.log).toContain('retrying once');
expect(flaky.log).toContain('$ npm run build');
expect(flaky.staleLeft).toBe(false);
// A tree that is genuinely broken still fails, and the retry is
// bounded: exactly two attempts, not an unbounded loop.
@ -6195,6 +6237,7 @@ describe('qwen-triage verify round-3 hardening', () => {
expect(clean.attempts).toBe(1);
expect(clean.output).not.toContain('verdict=');
expect(clean.log).not.toContain('retrying once');
expect(clean.staleLeft).toBe(true);
});
}
@ -6222,7 +6265,7 @@ describe('qwen-triage verify round-3 hardening', () => {
['publish-tmux', 'Post tmux result comment'],
]) {
const publish = stepIn(jobName, stepName);
const install = publish.indexOf("PREPARE_COMMAND='npm ci'");
const install = publish.indexOf("PREPARE_COMMAND='pnpm install'");
const build = publish.indexOf("PREPARE_COMMAND='npm run build'");
expect(install).toBeGreaterThan(-1);
expect(build).toBeGreaterThan(install);
@ -6867,9 +6910,9 @@ describe('qwen-triage tmux lane parity', () => {
// post-save hook, so PR lifecycle scripts cannot write to the shared
// cache. Swapping to `actions/cache` would re-enable the save path and
// let a PR poison subsequent runs.
it('pins the npm cache step to restore-only in both lanes', () => {
it('pins the pnpm store step to restore-only in both lanes', () => {
for (const jobName of ['verify', 'tmux-testing']) {
const cacheStep = stepIn(jobName, 'Restore npm cache');
const cacheStep = stepIn(jobName, 'Restore pnpm store');
expect(cacheStep).toContain('actions/cache/restore@');
expect(cacheStep).not.toMatch(/uses:\s*'actions\/cache@/);
// A separate save step would reopen the same hole the
@ -6879,53 +6922,53 @@ describe('qwen-triage tmux lane parity', () => {
}
});
it('points npm ci at the restored cache directory in both lanes', () => {
it('points pnpm install at the restored store in both lanes', () => {
for (const jobName of ['verify', 'tmux-testing']) {
const prepare = stepIn(jobName, 'Install and build PR app');
expect(prepare).toContain('--cache "$RUNNER_TEMP/npm-cache"');
expect(prepare).toContain('--store-dir "$RUNNER_TEMP/pnpm-store"');
expect(prepare).toContain(
'npm ci --prefer-offline --no-audit --progress=false --cache "$RUNNER_TEMP/npm-cache"',
'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only --store-dir "$RUNNER_TEMP/pnpm-store"',
);
expect(prepare).toContain('mkdir -p "$RUNNER_TEMP/npm-cache"');
expect(prepare).toContain('chown -R node:node "$RUNNER_TEMP/npm-cache"');
const cacheStep = stepIn(jobName, 'Restore npm cache');
expect(prepare).toContain('mkdir -p "$RUNNER_TEMP/pnpm-store"');
expect(prepare).toContain('chown -R node:node "$RUNNER_TEMP/pnpm-store"');
const cacheStep = stepIn(jobName, 'Restore pnpm store');
const cachePath = cacheStep.match(
/path:\s*'\$\{\{\s*runner\.temp\s*\}\}\/([^']+)'/,
)?.[1];
expect(cachePath).toBeTruthy();
const npmCaches = [
...prepare.matchAll(/--cache "\$RUNNER_TEMP\/([^"]+)"/g),
const stores = [
...prepare.matchAll(/--store-dir "\$RUNNER_TEMP\/([^"]+)"/g),
].map((m) => m[1]);
expect(npmCaches.length).toBeGreaterThanOrEqual(2);
for (const c of npmCaches) expect(c).toBe(cachePath);
expect(stores.length).toBeGreaterThanOrEqual(2);
for (const c of stores) expect(c).toBe(cachePath);
}
});
it('clears stale npm cache before restore in both lanes', () => {
it('clears stale pnpm store before restore in both lanes', () => {
for (const jobName of ['verify', 'tmux-testing']) {
const clearStep = stepIn(jobName, 'Clear stale npm cache');
const clearStep = stepIn(jobName, 'Clear stale pnpm store');
expect(clearStep, `${jobName} must have a clear step`).toContain(
'rm -rf',
);
const clearIdx = job(jobName).indexOf("'Clear stale npm cache'");
const restoreIdx = job(jobName).indexOf("'Restore npm cache'");
const clearIdx = job(jobName).indexOf("'Clear stale pnpm store'");
const restoreIdx = job(jobName).indexOf("'Restore pnpm store'");
expect(clearIdx).toBeGreaterThan(-1);
expect(restoreIdx).toBeGreaterThan(-1);
expect(clearIdx).toBeLessThan(restoreIdx);
}
});
it('reports the npm cache hit so a permanent miss is visible in both lanes', () => {
it('reports the pnpm store hit so a permanent miss is visible in both lanes', () => {
for (const jobName of ['verify', 'tmux-testing']) {
const cacheStep = stepIn(jobName, 'Restore npm cache');
expect(cacheStep).toContain("id: 'npm-cache'");
const reportStep = stepIn(jobName, 'Report npm cache hit');
expect(reportStep).toContain('steps.npm-cache.outputs.cache-hit');
const cacheStep = stepIn(jobName, 'Restore pnpm store');
expect(cacheStep).toContain("id: 'pnpm-store'");
const reportStep = stepIn(jobName, 'Report pnpm store hit');
expect(reportStep).toContain('steps.pnpm-store.outputs.cache-hit');
expect(reportStep).toContain('GITHUB_STEP_SUMMARY');
}
});
});
describe('qwen-triage npm cache producer', () => {
describe('qwen-triage pnpm store producer', () => {
it('saves with the same key and path the triage lanes restore', () => {
expect(cacheProducerWorkflow).toContain('actions/cache/save@');
// Prettier may choose single or double quotes depending on inner
@ -6942,7 +6985,7 @@ describe('qwen-triage npm cache producer', () => {
cacheProducerWorkflow.match(/key:\s*('(?:[^']|'')+'|"[^"]+")/)?.[1],
);
for (const jobName of ['verify', 'tmux-testing']) {
const restoreStep = stepIn(jobName, 'Restore npm cache');
const restoreStep = stepIn(jobName, 'Restore pnpm store');
const path = yamlScalar(
restoreStep.match(/path:\s*('[^']+'|"[^"]+")/)?.[1],
);

View file

@ -55,7 +55,6 @@ const cuaReleaseWorkflow = readFileSync(
const nodeReplPackage = JSON.parse(
readFileSync('packages/node-repl/package.json', 'utf8'),
);
const rootPackageLock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
const cuaSdkPackage = JSON.parse(
readFileSync('packages/cua-driver/typescript/package.json', 'utf8'),
);
@ -101,16 +100,13 @@ describe('CUA release workflow', () => {
expect(cuaReleaseWorkflow).not.toContain(
'NODE_REPL_VERSION does not match release version',
);
expect(rootPackageLock.packages['packages/node-repl'].version).toBe(
nodeReplPackage.version,
);
expect(cuaSdkPackageLock.version).toBe(cuaSdkPackage.version);
expect(cuaSdkPackageLock.packages[''].version).toBe(cuaSdkPackage.version);
});
it('dry-runs and clean-installs the packed Node REPL MCP server', () => {
expect(cuaReleaseWorkflow).toMatch(
/verify-node-repl-package:[\s\S]*?npm ci --ignore-scripts[\s\S]*?npm run typecheck[\s\S]*?npm test[\s\S]*?npm run smoke:mcp[\s\S]*?npm run smoke:lifecycle[\s\S]*?node packages\/node-repl\/scripts\/verify-package\.mjs[\s\S]*?node-repl-mcp-npm-\$\{\{[\s\S]*?node_repl_version/,
/verify-node-repl-package:[\s\S]*?pnpm install --frozen-lockfile --ignore-scripts[\s\S]*?npm run typecheck[\s\S]*?npm test[\s\S]*?npm run smoke:mcp[\s\S]*?npm run smoke:lifecycle[\s\S]*?node packages\/node-repl\/scripts\/verify-package\.mjs[\s\S]*?node-repl-mcp-npm-\$\{\{[\s\S]*?node_repl_version/,
);
});
@ -2253,7 +2249,9 @@ describe('release workflow', () => {
expect(setupNode?.if, id).toBe(
"${{ runner.environment != 'self-hosted' }}",
);
expect(setupNode?.with.cache, id).toBe('npm');
// Dependencies install with pnpm, so an npm download cache would be
// restored and never read.
expect(setupNode?.with.cache, id).toBeUndefined();
expect(setupNode?.with['package-manager-cache'], id).toBe(false);
const machineNode = steps.find((step) =>
String(step.uses ?? '').includes('.github/actions/self-hosted-node'),
@ -2266,15 +2264,13 @@ describe('release workflow', () => {
const publishSetupNode = releaseYaml.jobs.publish.steps.find((step) =>
String(step.uses ?? '').includes('actions/setup-node'),
);
expect(publishSetupNode?.with.cache).toBe(
"${{ runner.environment != 'self-hosted' && 'npm' || '' }}",
);
expect(publishSetupNode?.with.cache).toBeUndefined();
expect(publishSetupNode?.with['package-manager-cache']).toBe(false);
});
it('stages every integration package manifest after versioning', () => {
expect(releaseStepScript).toContain(
'git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json integrations/*/package.json integrations/*/qwen-extension.json',
'git add package.json pnpm-lock.yaml packages/*/package.json packages/channels/*/package.json integrations/*/package.json integrations/*/qwen-extension.json',
);
});

View file

@ -35,7 +35,7 @@ import {
// renaming the layout there fails the build here instead of silently
// no-op-ing the sweeps on the shared runners — a suffix rename already
// broke a sweeper once (see paths.ts).
// npm-cache.yml and qwen-triage.yml also run on the shared pool but are
// pnpm-store.yml and qwen-triage.yml also run on the shared pool but are
// deliberately not covered here; extending the sweep to them is follow-up
// work.
const probePr = 12345;

View file

@ -20,9 +20,9 @@ import { parse } from 'yaml';
// Executes the Security Checks audit step under the shell GitHub actually gives
// it — security-checks.yml sets `defaults.run.shell: bash`, which on Linux is
// `bash --noprofile --norc -e -o pipefail {0}` — with `npm`, `timeout` and
// `sleep` stubbed. Three properties of this gate cannot be witnessed by the
// shape assertions in security-workflows.test.js:
// `bash --noprofile --norc -e -o pipefail {0}` — with `npm`, `corepack`,
// `timeout` and `sleep` stubbed. Three properties of this gate cannot be
// witnessed by the shape assertions in security-workflows.test.js:
//
// 1. a registry-side failure is retried instead of being reported as a CVE;
// 2. a real high-severity finding is never retried away or swallowed;
@ -42,9 +42,13 @@ describe('security-checks audit step endpoint-error retry', () => {
const script = auditStep.run;
const ENDPOINT_ERROR = 'npm error audit endpoint returned an error';
const NO_VERDICT = '::error::npm audit returned no verdict after 2 attempts';
// pnpm 11's wording for the same registry-side failure.
const PNPM_ENDPOINT_ERROR =
' ERR_PNPM_AUDIT_BAD_RESPONSE The audit endpoint (at https://registry.npmjs.org/-/npm/v1/security/audits/quick) responded with 400: Invalid package tree';
const NO_VERDICT =
'::error::dependency audit returned no verdict after 2 attempts';
// Three audit sites, matching the real tree: the root `npm audit` plus the
// Three audit sites, matching the real tree: the root `pnpm audit` plus the
// two vendored lockfiles the per-package loop does not skip. The loop keeps
// going after a root failure (`|| status=$?` accumulates rather than aborts),
// so a persistent failure is audited and retried at all three.
@ -74,23 +78,26 @@ describe('security-checks audit step endpoint-error retry', () => {
const binDir = join(dir, 'bin');
mkdirSync(binDir);
writeFileSync(
join(binDir, 'npm'),
// The root workspace is audited through `corepack pnpm audit` and the
// vendored lockfiles through `npm audit`. Both stubs share one counter
// and one mode; each prints its own tool's endpoint error, so the retry
// has to recognise both wordings to reach the counts below.
const auditStub = (auditCommand, endpointError) =>
[
'#!/usr/bin/env bash',
'# `npm ci` always succeeds; only the audit is under test.',
'[ "$1" = "audit" ] || exit 0',
'# Installs always succeed; only the audit is under test.',
`${auditCommand} || exit 0`,
bump('AUDIT_CALLS_FILE'),
'case "$AUDIT_MODE" in',
' clean) exit 0 ;;',
' endpoint-error-then-clean)',
' if [ "$n" -lt 2 ]; then',
` printf '%s\\n' '${ENDPOINT_ERROR}'`,
` printf '%s\\n' '${endpointError}'`,
' exit 1',
' fi',
' ;;',
' endpoint-error-always)',
` printf '%s\\n' '${ENDPOINT_ERROR}'`,
` printf '%s\\n' '${endpointError}'`,
' exit 1',
' ;;',
' cve-found)',
@ -100,7 +107,14 @@ describe('security-checks audit step endpoint-error retry', () => {
' ;;',
'esac',
'exit 0',
].join('\n'),
].join('\n');
writeFileSync(
join(binDir, 'npm'),
auditStub('[ "$1" = "audit" ]', ENDPOINT_ERROR),
);
writeFileSync(
join(binDir, 'corepack'),
auditStub('[ "$1 $2" = "pnpm audit" ]', PNPM_ENDPOINT_ERROR),
);
// Stands in for coreutils `timeout`, absent on macOS: it either simulates
// the 124 a killed attempt returns, or drops the duration and execs the
@ -126,7 +140,7 @@ describe('security-checks audit step endpoint-error retry', () => {
join(binDir, 'sleep'),
['#!/usr/bin/env bash', bump('SLEEPS_FILE'), 'exit 0'].join('\n'),
);
for (const stub of ['npm', 'timeout', 'sleep']) {
for (const stub of ['npm', 'corepack', 'timeout', 'sleep']) {
chmodSync(join(binDir, stub), 0o755);
}
@ -188,7 +202,7 @@ describe('security-checks audit step endpoint-error retry', () => {
expect(auditCalls).toBe(4);
expect(sleeps).toBe(1);
expect(output).toContain(
'::warning::npm audit returned no verdict (attempt 1/2), retrying',
'::warning::dependency audit returned no verdict (attempt 1/2), retrying',
);
});

View file

@ -43,7 +43,7 @@ describe('security workflows', () => {
const workflow = readWorkflow('security-checks.yml');
const dependencyJob = getWorkflowJob(workflow, 'dependency-cve');
const dependencyCheckoutStep = getWorkflowStep(dependencyJob, 'Checkout');
const installStep = getWorkflowStep(dependencyJob, 'Install dependencies');
const setupNodeStep = getWorkflowStep(dependencyJob, 'Set up Node');
const auditStep = getWorkflowStep(
dependencyJob,
'Audit production dependencies',
@ -78,9 +78,13 @@ describe('security workflows', () => {
'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e',
);
expect(dependencyCheckoutStep).toContain('persist-credentials: false');
expect(installStep).toContain(
"run: 'npm ci --ignore-scripts --no-audit --progress=false'",
);
// pnpm audit reads pnpm-lock.yaml, so the root tree is never installed.
expect(dependencyJob).not.toContain("- name: 'Install dependencies'");
// And the root package-lock.json is retired, so an npm cache here would
// key off a file that no longer exists and fail the step — on a job no
// pull_request run ever reaches.
expect(setupNodeStep).toContain('package-manager-cache: false');
expect(setupNodeStep).not.toContain("cache: 'npm'");
expect(auditStep).not.toContain('continue-on-error');
expect(auditStep).toContain('status=0');
expect(auditStep).toContain('exit "$status"');
@ -99,8 +103,9 @@ describe('security workflows', () => {
// finding, and must not pass the gate either. Witnessed by bash in
// security-checks-audit-retry.test.js.
expect(auditStep).toContain('audit endpoint returned an error');
expect(auditStep).toContain('ERR_PNPM_AUDIT_BAD_RESPONSE');
expect(auditStep).toContain(
'audit npm audit --omit=dev --audit-level=high || status=$?',
'audit corepack pnpm audit --prod --audit-level high || status=$?',
);
expect(auditStep).toContain(
'audit npm audit --omit=dev --audit-level=high --workspaces=false',

View file

@ -8,6 +8,7 @@ import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { parse } from 'yaml';
import externalContextConfig from '../../integrations/external-context/vitest.config.js';
import externalContextMem0Config from '../../integrations/external-context-mem0/vitest.config.js';
@ -402,26 +403,33 @@ describe('autofix gate load clamps', () => {
// that cap is the shield keeping a 1.x workspace's legs alive under
// the clamps — pin it here so removing it fails the suite instead of
// crashing every gate leg for the workspace.
const lock = JSON.parse(
type ImporterDeps = Record<string, { version?: string }> | undefined;
const lock = parse(
readFileSync(
fileURLToPath(new URL('../../package-lock.json', import.meta.url)),
fileURLToPath(new URL('../../pnpm-lock.yaml', import.meta.url)),
'utf8',
),
) as { packages: Record<string, { version?: string }> };
const hoisted = lock.packages['node_modules/vitest']?.version ?? '';
// Nested lockfile copies under workspace dirs are exactly the
// workspaces whose pinned vitest differs from the hoisted one; if the
// hoisted copy itself were 1.x this filter would go blind, so pin the
// premise.
expect(Number(hoisted.split('.')[0])).toBeGreaterThanOrEqual(2);
const legacyWorkspaces = Object.entries(lock.packages)
.filter(
([path, entry]) =>
path.endsWith('/node_modules/vitest') &&
) as {
importers: Record<
string,
{ dependencies?: ImporterDeps; devDependencies?: ImporterDeps }
>;
};
// Each importer records the vitest it resolves (with a peer suffix
// after the version), so a workspace pinning 1.x shows up directly.
const vitestVersion = (importer: string) =>
lock.importers[importer]?.devDependencies?.['vitest']?.version ??
lock.importers[importer]?.dependencies?.['vitest']?.version ??
'';
// The shield only matters beside a 2.x+ root; if the root itself were
// 1.x the premise of this test would be gone, so pin it.
expect(Number(vitestVersion('.').split('.')[0])).toBeGreaterThanOrEqual(2);
const legacyWorkspaces = Object.keys(lock.importers).filter(
(path) =>
(path.startsWith('packages/') || path.startsWith('integrations/')) &&
Number(entry.version?.split('.')[0] ?? 99) < 2,
)
.map(([path]) => path.slice(0, -'/node_modules/vitest'.length));
vitestVersion(path) !== '' &&
Number(vitestVersion(path).split('.')[0]) < 2,
);
for (const workspace of legacyWorkspaces) {
if (!(workspace in configs)) {
throw new Error(

View file

@ -40,7 +40,13 @@ if (!versionType) {
}
// 2. Bump the version in the root and all workspace package.json files.
run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
// --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.
run(
`npm version ${versionType} --no-git-tag-version --allow-same-version --no-workspaces-update`,
);
// 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
@ -61,7 +67,7 @@ const workspacesToVersion = allWorkspaces.filter(
for (const workspaceName of workspacesToVersion) {
run(
`npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`,
`npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version --no-workspaces-update`,
);
}
@ -122,17 +128,10 @@ for (const entry of readdirSync(channelsDir)) {
}
}
// 9. Refresh node_modules and package-lock.json against the pinned exact
// versions so the adapters resolve channel-base to the workspace link again.
// --ignore-scripts prevents the root `prepare` lifecycle from triggering a
// redundant full build that fails with TS5055 when dist/ already exists from
// the initial `npm ci` install.
run('npm install --ignore-scripts');
// 10. The per-workspace `npm version` reifies above nested a stale registry
// copy of channel-base under each adapter while ranges briefly mismatched.
// The install above cleans both lockfiles but can leave that directory on
// disk, where it shadows the workspace link during tsc. Remove it.
// 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,