qwen-code/scripts/tests/package-scripts.test.js
易良 a074d3b042
chore(ci): Disable install scripts in release CI and guard security-checks workflow (#9577)
* chore(ci): Disable install scripts in release CI and guard security-checks workflow

* fix(ci): complete release install hardening

* test(ci): pin release install step count

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ci): scope release PAT to push step

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ci): export GH_TOKEN so the release-branch push uses CI_BOT_PAT

* fix(ci): export GH_TOKEN so the credential helper sees it at push time

An inline GH_TOKEN prefix only covers the gh auth setup-git call itself;
the helper re-resolves the token when git push invokes it, so the push
would fall back to the job token with persist-credentials disabled.

* fix(test): anchor setup-git ordering check after the export line

A comment in the push step mentions gh auth setup-git before the export,
so indexOf found the comment first and the ordering assertion inverted.

* style(test): wrap long line to satisfy prettier

* fix(ci): address review findings on PAT handling and install comments

- Pin gh auth setup-git before the git push it authenticates in both
  release and finalize workflow tests, so moving credential setup after
  the push no longer passes.
- Correct the replay comment: npm run generate is not a lifecycle
  script and workspace lifecycle scripts stay disabled.
- Drop the overstated push-boundary claim and record why the push
  needs the bot PAT rather than the job token.

* test(ci): pin CI_BOT_PAT out of install steps and the publish job header

* style(test): apply prettier's exact re-wrap for the two flagged calls

* test(ci): pin CI_BOT_PAT out of the workflow-level headers too

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-21 03:48:03 +00:00

634 lines
21 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '../..');
function readPackageJson() {
return JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
}
function readWorkflow(relativePath) {
return readFileSync(path.join(root, relativePath), 'utf8');
}
describe('package scripts', () => {
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['test:ci']).not.toContain(
'npm run check:serve-fast-path-bundle',
);
expect(packageJson.scripts.preflight).toContain(
'npm run check:serve-fast-path-bundle',
);
});
it('limits SDK integration tests through the forks pool', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['test:integration:sdk:sandbox:none']).toContain(
'--poolOptions.forks.maxForks 2',
);
expect(
packageJson.scripts['test:integration:sdk:sandbox:docker'],
).toContain('--poolOptions.forks.maxForks 2');
});
it('cleans package build artifacts before checking the serve fast path bundle', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['check:serve-fast-path-bundle']).toBe(
[
'node scripts/clean-package-build-artifacts.js',
'&& npm run build -- --cli-only',
'&& cross-env DEV=true npm run bundle',
'&& node scripts/check-serve-fast-path-bundle.js',
].join(' '),
);
expect(packageJson.scripts['check:serve-fast-path-bundle']).not.toContain(
'npm run clean',
);
});
it('defines a release test script that disables workspace coverage', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts['test:release']).toBe(
[
'cross-env NODE_OPTIONS="--max-old-space-size=3072"',
'npm run test:ci --workspaces --if-present --parallel -- --coverage.enabled=false',
'&& npm run test:scripts',
].join(' '),
);
const vscodePackageJson = JSON.parse(
readFileSync(
path.join(root, 'packages/vscode-ide-companion/package.json'),
'utf8',
),
);
expect(vscodePackageJson.scripts['test:ci']).toContain('--coverage');
});
it('skips build/bundle/husky but still generates git-commit info when CI builds explicitly', () => {
const packageJson = readPackageJson();
expect(packageJson.scripts.prepare).toBe('node scripts/prepare.js');
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-skip-'));
const logFile = path.join(binDir, 'commands.log');
writeFileSync(logFile, '');
try {
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'husky.cmd'),
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
);
writeFileSync(
path.join(binDir, 'npm.cmd'),
'@echo(npm %*>>"%PREPARE_LOG_FILE%"\r\n',
);
} else {
writeFileSync(
path.join(binDir, 'husky'),
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
);
writeFileSync(
path.join(binDir, 'npm'),
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '1',
},
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain('Skipping prepare');
// git-commit info is still generated so a later per-workspace build or
// typecheck (e.g. the review tooling's) doesn't fail on the missing
// module; the heavy build/bundle/husky are skipped.
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
'npm run generate',
]);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it('runs prepare steps in order when CI does not skip prepare', () => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-bin-'));
const logFile = path.join(binDir, 'commands.log');
try {
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'husky.cmd'),
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
);
writeFileSync(
path.join(binDir, 'npm.cmd'),
'@echo(npm %*>>"%PREPARE_LOG_FILE%"\r\n',
);
} else {
writeFileSync(
path.join(binDir, 'husky'),
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
);
writeFileSync(
path.join(binDir, 'npm'),
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(0);
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
'husky',
'npm run build',
'npm run bundle',
]);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it('exits when a prepare step fails', () => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-fail-'));
const logFile = path.join(binDir, 'commands.log');
writeFileSync(logFile, '');
try {
if (process.platform === 'win32') {
writeFileSync(path.join(binDir, 'husky.cmd'), '@exit /b 7\r\n');
writeFileSync(
path.join(binDir, 'npm.cmd'),
'@echo npm %* >> "%PREPARE_LOG_FILE%"\r\n',
);
} else {
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nexit 7\n');
writeFileSync(
path.join(binDir, 'npm'),
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(7);
expect(result.stderr).toContain('prepare: husky exited with status 7');
expect(readFileSync(logFile, 'utf8')).toBe('');
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it('reports the failing prepare step after earlier steps succeed', () => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-late-fail-'));
const logFile = path.join(binDir, 'commands.log');
writeFileSync(logFile, '');
try {
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'husky.cmd'),
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
);
writeFileSync(
path.join(binDir, 'npm.cmd'),
[
'@echo(npm %*>>"%PREPARE_LOG_FILE%"',
'@if "%1 %2"=="run build" exit /b 7',
'@exit /b 0',
'',
].join('\r\n'),
);
} else {
writeFileSync(
path.join(binDir, 'husky'),
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
);
writeFileSync(
path.join(binDir, 'npm'),
[
'#!/bin/sh',
'echo "npm $*" >> "$PREPARE_LOG_FILE"',
'if [ "$1 $2" = "run build" ]; then exit 7; fi',
'',
].join('\n'),
);
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
}
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
PREPARE_LOG_FILE: logFile,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(7);
expect(result.stderr).toContain(
'prepare: npm run build exited with status 7',
);
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
'husky',
'npm run build',
]);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
});
it.skipIf(process.platform === 'win32')(
'reports when a prepare command is killed by a signal',
() => {
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-signal-'));
try {
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nkill -TERM $$\n');
writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\nexit 0\n');
chmodSync(path.join(binDir, 'husky'), 0o755);
chmodSync(path.join(binDir, 'npm'), 0o755);
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'prepare: husky killed by signal SIGTERM',
);
} finally {
rmSync(binDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === 'win32')(
'reports when a prepare command cannot be spawned',
() => {
const missingBinDir = mkdtempSync(
path.join(tmpdir(), 'qwen-prepare-missing-bin-'),
);
try {
const result = spawnSync(
process.execPath,
[path.join(root, 'scripts/prepare.js')],
{
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: missingBinDir,
QWEN_SKIP_PREPARE: '',
},
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain('prepare: husky failed:');
} finally {
rmSync(missingBinDir, { recursive: true, force: true });
}
},
);
it('wires release quality checks to fast explicit validation steps', () => {
const workflow = readWorkflow('.github/workflows/release.yml');
const qualityJob = getWorkflowJob(workflow, 'quality');
const buildStep = getWorkflowStep(qualityJob, 'Build Project');
const serveFastPathStep = getWorkflowStep(
qualityJob,
'Check Serve Fast Path Bundle',
);
const workspaceTestStep = getWorkflowStep(
qualityJob,
'Run Workspace Tests',
);
expect(qualityJob).toContain("name: 'Check Serve Fast Path Bundle'");
expect(qualityJob).toContain('npm run check:serve-fast-path-bundle');
expect(qualityJob.indexOf(serveFastPathStep)).toBeLessThan(
qualityJob.indexOf(buildStep),
);
expect(workspaceTestStep).toContain('npm run test:release');
expect(workspaceTestStep).not.toContain('npm run test:ci');
});
it('skips release install-time prepare and builds before publish bundling', () => {
const workflow = readWorkflow('.github/workflows/release.yml');
expect(workflow.slice(0, workflow.indexOf('jobs:'))).not.toContain(
'CI_BOT_PAT',
);
const installSteps =
workflow.match(
/ {6}- name: 'Install Dependencies'[\s\S]*?(?=\n {6}- name: '|\n {4}[A-Za-z0-9_-]+:|$)/g,
) || [];
expect(installSteps.length).toBe(5);
for (const installStep of installSteps) {
expect(installStep).toContain(
'npm ci --ignore-scripts --no-audit --progress=false',
);
expect(installStep).toContain('npm run postinstall');
expect(installStep).toContain('npm run generate');
expect(installStep).not.toContain('QWEN_SKIP_PREPARE');
expect(installStep).not.toContain('CI_BOT_PAT');
}
for (const jobName of ['integration_none', 'integration_docker']) {
const integrationJob = getWorkflowJob(workflow, jobName);
const buildStep = getWorkflowStep(integrationJob, 'Build Bundle');
expect(buildStep).toContain('npm run build\n npm run bundle');
}
const publishJob = getWorkflowJob(workflow, 'publish');
expect(publishJob.slice(0, publishJob.indexOf('steps:'))).not.toContain(
'CI_BOT_PAT',
);
const checkoutStep = getWorkflowStep(publishJob, 'Checkout');
const gitConfigStep = getWorkflowStep(publishJob, 'Configure Git User');
const commitStep = getWorkflowStep(
publishJob,
'Commit and Conditionally Push package versions',
);
const buildStep = getWorkflowStep(
publishJob,
'Build Bundle and Prepare Package',
);
expect(checkoutStep).toContain('persist-credentials: false');
expect(gitConfigStep).toContain('git config core.hooksPath .husky');
expect(publishJob.indexOf(gitConfigStep)).toBeLessThan(
publishJob.indexOf(commitStep),
);
expect(commitStep).toContain("CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'");
expect(commitStep).toContain('export GH_TOKEN="${CI_BOT_PAT}"');
expect(commitStep).toContain('gh auth setup-git');
const exportTokenIdx = commitStep.indexOf(
'export GH_TOKEN="${CI_BOT_PAT}"',
);
const setupGitIdx = commitStep.indexOf('gh auth setup-git', exportTokenIdx);
expect(setupGitIdx).toBeGreaterThan(exportTokenIdx);
expect(setupGitIdx).toBeLessThan(
commitStep.indexOf('git push --force --set-upstream'),
);
expect(buildStep).toContain('npm run build\n npm run bundle');
});
it('skips npm packages whose release version is already published', () => {
const workflow = readWorkflow('.github/workflows/release.yml');
const publishJob = getWorkflowJob(workflow, 'publish');
for (const stepName of [
'Publish @qwen-code/audio-capture',
'Publish @qwen-code/qwen-code',
'Publish @qwen-code/channel-base',
'Publish remaining channel packages',
]) {
const publishStep = getWorkflowStep(publishJob, stepName);
expect(publishStep).toContain(
"RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'",
);
expect(publishStep).toContain(
'PACKAGE_NAME="$(node -p "require(\'./package.json\').name")"',
);
expect(publishStep).toContain('PUBLISH_ARGS+=(--dry-run)');
expect(publishStep).toContain(
'npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version',
);
expect(publishStep).toContain('already published; skipping');
expect(publishStep).toContain('exit 0');
expect(publishStep).toContain(
'npm publish --provenance "${PUBLISH_ARGS[@]}"',
);
}
// The channel loop must wrap each iteration in a subshell so that
// `exit 0` skips only the current channel, not the entire step.
const channelStep = getWorkflowStep(
publishJob,
'Publish remaining channel packages',
);
expect(channelStep).toContain('(\n');
expect(channelStep).toContain(')');
// A fully-skipped publish must be visible, not silently green.
expect(channelStep).toContain(
'Every channel package was already published; nothing shipped',
);
});
it('meets npm trusted publishing requirements', () => {
for (const [workflowPath, jobName, publishStepName] of [
[
'.github/workflows/release.yml',
'publish',
'Publish @qwen-code/audio-capture',
],
[
'.github/workflows/release-sdk.yml',
'release-sdk',
'Publish @qwen-code/sdk',
],
['.github/workflows/cd-mobile-mcp.yml', 'build-and-publish', 'Publish'],
]) {
const publishJob = getWorkflowJob(readWorkflow(workflowPath), jobName);
const installStep = getWorkflowStep(publishJob, 'Install npm 11');
expect(installStep).toContain('npm install --global npm@11.19.0');
expect(publishJob.indexOf(installStep)).toBeLessThan(
publishJob.indexOf(getWorkflowStep(publishJob, publishStepName)),
);
}
for (const packageDirectory of [
'packages/audio-capture',
'packages/cli',
'packages/channels/base',
'packages/channels/dingtalk',
'packages/channels/feishu',
'packages/channels/github',
'packages/channels/qqbot',
'packages/channels/telegram',
'packages/channels/wecom',
'packages/channels/weixin',
'packages/mobile-mcp',
'packages/sdk-typescript',
]) {
const packageJson = JSON.parse(
readFileSync(path.join(root, packageDirectory, 'package.json'), 'utf8'),
);
expect(packageJson.repository?.url?.replace(/^git\+/, '')).toBe(
'https://github.com/QwenLM/qwen-code.git',
);
}
});
it('fast-tracks trusted autofix issue triggers before LLM assessment', () => {
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
const issueJob = getWorkflowJob(workflow, 'issue-autofix');
const scanStep = getWorkflowStep(issueJob, 'Find candidate issues');
const fastTrackStep = getWorkflowStep(issueJob, 'Fast-track decision');
const assessStep = getWorkflowStep(issueJob, 'Assess candidates');
expect(issueJob.indexOf(scanStep)).toBeLessThan(
issueJob.indexOf(fastTrackStep),
);
expect(issueJob.indexOf(fastTrackStep)).toBeLessThan(
issueJob.indexOf(assessStep),
);
expect(fastTrackStep).toContain("id: 'fasttrack'");
expect(fastTrackStep).toContain('FAST_TRACK=false');
expect(fastTrackStep).toContain('FAST_TRACK=true');
expect(fastTrackStep).toContain('fast_tracked=false');
expect(fastTrackStep).toContain('-n "${FORCED_ISSUE}"');
expect(fastTrackStep).toContain(
'Fast-tracked: trusted trigger bypasses LLM assessment.',
);
expect(assessStep).toContain(
"steps.fasttrack.outputs.fast_tracked != 'true'",
);
});
it('skips autofix install-time prepare without disabling dependency scripts', () => {
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
// review-address restores the shared build-cli bundle instead of
// compiling, so its install step is npm ci only; the other two jobs
// still build from sources. Husky hooks are re-armed after the
// prepare-skip only where git commits happen (build-cli never commits).
for (const [jobName, stepName, armsHooks] of [
['issue-autofix', 'Install dependencies and build', true],
['build-cli', 'Install dependencies and build', false],
['review-address', 'Install dependencies', true],
]) {
const job = getWorkflowJob(workflow, jobName);
const installStep = getWorkflowStep(job, stepName);
expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'");
expect(installStep).toContain(
'npm ci --prefer-offline --no-audit --progress=false',
);
if (armsHooks) {
expect(installStep).toContain('git config core.hooksPath .husky');
}
expect(installStep).not.toContain('--ignore-scripts');
}
});
it('runs changed autofix tests instead of full touched-package suites', () => {
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
const reviewVerificationRunner = readWorkflow(
'.github/scripts/run-autofix-review-verification.sh',
);
const reviewJob = getWorkflowJob(workflow, 'review-address');
for (const verificationBody of [
getWorkflowStep(
getWorkflowJob(workflow, 'issue-autofix'),
'Verification gate',
),
reviewVerificationRunner,
]) {
expect(verificationBody).toContain(
'npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests',
);
expect(verificationBody).toContain(
'bash "${RUNNER_TEMP}/resolve-owning-packages.sh"',
);
expect(verificationBody).toContain('pkg.scripts?.test');
expect(verificationBody).toContain('!= *vitest*');
expect(verificationBody).not.toContain(
'npm run test --workspace "${p}" --if-present\n',
);
}
expect(getWorkflowStep(reviewJob, 'Verification gate')).toContain(
'bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"',
);
});
});