mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-21 15:04:15 +00:00
revert: remove local PR verification gate (#7031)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Reverts QwenLM/qwen-code#6873 and QwenLM/qwen-code#7025.
This commit is contained in:
parent
1fd2a6cca4
commit
3d4601489e
12 changed files with 33 additions and 3038 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -128,15 +128,15 @@ cd integration-tests && \
|
|||
**Gotcha:** In interactive tests, always call `session.idle()` between sends —
|
||||
ANSI output streams asynchronously.
|
||||
|
||||
### Linting, Formatting & Verification
|
||||
### Linting & Formatting
|
||||
|
||||
```bash
|
||||
npm run lint # ESLint check
|
||||
npm run lint:fix # Auto-fix lint issues
|
||||
npm run format # Prettier formatting
|
||||
npm run typecheck # TypeScript type checking
|
||||
npm run preflight # Legacy broad check; writes formatting and omits some PR CI checks
|
||||
npm run verify:pr # Optional clean PR verification after committing
|
||||
npm run preflight # Full check: clean → install → format → lint → build
|
||||
# → typecheck → test
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
|
@ -211,10 +211,6 @@ npm run verify:pr # Optional clean PR verification after committing
|
|||
Here, `/review` means the Codex code-review workflow, not Qwen Review or
|
||||
the `qwen-review` plugin. Do not invoke Qwen Review unless the user
|
||||
explicitly requests it by name.
|
||||
6. **Optional PR verification** — after the final changes are committed and the
|
||||
working tree is clean, ask whether to run `npm run verify:pr`. Run it only
|
||||
when requested; it is not a required local step before pushing, and remote CI
|
||||
remains authoritative.
|
||||
|
||||
### Feature development
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ If you'd like to get early feedback on your work, please use GitHub's **Draft Pu
|
|||
|
||||
#### 4. Ensure All Checks Pass
|
||||
|
||||
Before submitting your PR, run `npm run verify:pr` from a clean Node 22 checkout. This is the final local PR gate; `npm run preflight` remains useful during development but is not equivalent to CI.
|
||||
Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks.
|
||||
|
||||
#### 5. Update Documentation
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ To execute the unit test suite for the project:
|
|||
npm run test
|
||||
```
|
||||
|
||||
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure focused tests pass while developing, then run `npm run verify:pr` before submitting the PR.
|
||||
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`.
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
|
|
@ -166,21 +166,15 @@ npm run test:e2e
|
|||
|
||||
For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/developers/development/integration-tests.md).
|
||||
|
||||
### Linting and PR Verification
|
||||
### Linting and Preflight Checks
|
||||
|
||||
During development, the legacy preflight command provides a broad check and may rewrite formatting:
|
||||
To ensure code quality and formatting consistency, run the preflight check:
|
||||
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
|
||||
Before pushing a PR, run the read-only final local gate from a clean worktree:
|
||||
|
||||
```bash
|
||||
npm run verify:pr
|
||||
```
|
||||
|
||||
The final gate covers the deterministic PR CI checks that `preflight` omits. See [`docs/design/local-pr-verification.md`](./docs/design/local-pr-verification.md) for profiles, supported hosts, and remote-only boundaries.
|
||||
This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`.
|
||||
|
||||
_ProTip_
|
||||
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
# Local PR verification
|
||||
|
||||
## Motivation
|
||||
|
||||
The pre-commit hook is intentionally fast: it formats and lints only staged
|
||||
files. The legacy `npm run preflight` command is broader, but it runs Prettier
|
||||
in write mode and omits several PR CI checks. Neither is the final local PR
|
||||
gate.
|
||||
|
||||
Use three levels of feedback:
|
||||
|
||||
1. During development, run focused tests from the affected package.
|
||||
2. Keep the existing pre-commit hook for fast staged-file feedback.
|
||||
3. After committing and before the first PR push or an update push, run
|
||||
`npm run verify:pr`.
|
||||
|
||||
There is no repository pre-push hook. The full gate includes network access
|
||||
and the full workspace test suite, so it remains an explicit developer action.
|
||||
|
||||
## Interface and guards
|
||||
|
||||
```bash
|
||||
npm run verify:pr
|
||||
npm run verify:pr -- --base origin/release/<branch>
|
||||
npm run verify:pr -- --profile auto
|
||||
```
|
||||
|
||||
The default base is `origin/main`, and the default profile is `full`. Full is
|
||||
intentionally conservative. `--profile auto` is an opt-in optimization that
|
||||
reuses the CI changed-file classifier.
|
||||
|
||||
Before validation starts, the command requires Node 22.x on macOS x64/ARM64 or
|
||||
Linux x64. It also requires a caller working tree with no staged, unstaged, or
|
||||
untracked files, a resolvable base commit and merge base, and at least one
|
||||
committed change between that merge base and `HEAD`.
|
||||
|
||||
For profiles that run commands, the gate creates an owned detached temporary
|
||||
worktree at the exact SHA captured during caller inspection, disables checkout
|
||||
hooks, runs validation there, and removes it afterward.
|
||||
Commands that install, build, or generate files therefore do not change the
|
||||
caller's tracked checkout. Developers do not need to create a separate worktree
|
||||
themselves. A docs-only auto profile finishes after the same caller guards
|
||||
without creating the temporary worktree.
|
||||
|
||||
On POSIX systems, the owned container is created below the canonical `/tmp`
|
||||
path. This avoids both macOS `/var` aliases and excessively long per-user
|
||||
temporary paths. Build outputs, dependency caches, browser binaries, homes, and
|
||||
verifier-owned temporary files stay inside the container. Git temporarily
|
||||
registers the detached worktree in repository metadata until cleanup.
|
||||
|
||||
Installation forces lifecycle scripts to run so the build and bundle cannot
|
||||
be skipped by caller npm settings, while disabling Husky setup to avoid
|
||||
changing the linked worktrees' shared hook configuration.
|
||||
|
||||
## Profiles
|
||||
|
||||
- `full` runs the complete deterministic local gate and is always selected
|
||||
unless `auto` is requested.
|
||||
- `docs_only` is selected by `auto` only when every changed path is classified
|
||||
as documentation. It performs the caller guards and skips validation
|
||||
commands.
|
||||
- `github_ci_only` is selected by `auto` only when every changed path belongs
|
||||
to the CI safety-helper set. It sets up linters, runs actionlint and yamllint,
|
||||
and runs the associated Node helper tests.
|
||||
|
||||
Mixed or unrecognized changes select `full`.
|
||||
|
||||
## Full validation
|
||||
|
||||
The full profile runs these categories in fail-fast order:
|
||||
|
||||
- clean installation in the fresh detached checkout: `npm ci` invokes the
|
||||
repository `prepare` script, which runs the build and bundle;
|
||||
- critical runtime dependency audit, lockfile validation, and desktop
|
||||
workspace isolation;
|
||||
- ESLint, actionlint, shellcheck, yamllint, and a read-only Prettier check of
|
||||
regular files changed by the PR that still exist at `HEAD`;
|
||||
- the GitHub CI profile-classifier and safety-helper tests;
|
||||
- i18n validation, read-only settings-schema freshness plus a check that the
|
||||
build left the committed schema unchanged, type checking, and the serve
|
||||
fast-path bundle-closure check;
|
||||
- all workspace unit tests plus script tests, with workspaces and test files
|
||||
scheduled serially to keep the repository-wide run stable under local load;
|
||||
the test contents and coverage settings stay unchanged;
|
||||
- no-AK integration tests, an isolated Chromium installation, and the web-shell
|
||||
Playwright smoke test on a dynamically allocated localhost port.
|
||||
|
||||
Every validation subprocess receives an isolated temporary home and a small
|
||||
allowlist of transport-related caller variables such as `PATH`, proxy settings,
|
||||
and certificate paths. Caller credentials, npm/pip modes, Qwen state paths,
|
||||
test overrides, and Playwright redirects are not inherited. Python and npm use
|
||||
owned configuration and cache paths.
|
||||
|
||||
When the PR changes `packages/sdk-python/` or its CI workflow, the full profile
|
||||
also requires `uv` and creates isolated Python 3.10, 3.11, and 3.12
|
||||
environments. Each version installs the SDK development dependencies and runs
|
||||
Ruff lint and format checks, Mypy, and Pytest.
|
||||
|
||||
The existing sensitive-keyword no-op is intentionally not part of this gate.
|
||||
|
||||
## Failures and remote boundaries
|
||||
|
||||
The gate stops at the first failed step. Output includes the step and a safely
|
||||
escaped command before execution; validation failures also report the exact
|
||||
`HEAD`, selected base and profile, and direct developers to rerun the gate.
|
||||
It preserves a child's numeric exit code. `SIGINT` and `SIGTERM` received by
|
||||
the verifier are relayed after cleanup; other child-only signals use the
|
||||
conventional `128 + signal` exit status. The gate
|
||||
always attempts to remove the temporary worktree on success or failure.
|
||||
`SIGINT` and `SIGTERM` received while a validation command is running are
|
||||
forwarded to its process group; a second signal force-terminates that group,
|
||||
and the gate cleans up before relaying the original signal.
|
||||
Cleanup failures are reported, including separately when a validation failure
|
||||
already exists.
|
||||
|
||||
This gate does not replace remote CI. Unsupported hosts, including Windows and
|
||||
Linux ARM64, fail before repository inspection. The gate cannot reproduce ECS
|
||||
runner load or cache behavior, Windows and macOS merge-queue jobs, integrations
|
||||
that use real secrets, GitHub permissions and artifact handling, review bots,
|
||||
or every residual test flake. Remote checks remain authoritative for those
|
||||
surfaces.
|
||||
|
|
@ -41,7 +41,7 @@ If you'd like to get early feedback on your work, please use GitHub's **Draft Pu
|
|||
|
||||
#### 4. Ensure All Checks Pass
|
||||
|
||||
Before submitting your PR, run `npm run verify:pr` from a clean Node 22 checkout. This is the final local PR gate; `npm run preflight` remains useful during development but is not equivalent to CI.
|
||||
Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks.
|
||||
|
||||
#### 5. Update Documentation
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ To execute the unit test suite for the project:
|
|||
npm run test
|
||||
```
|
||||
|
||||
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure focused tests pass while developing, then run `npm run verify:pr` before submitting the PR.
|
||||
This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`.
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
|
|
@ -140,21 +140,15 @@ npm run test:e2e
|
|||
|
||||
For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./development/integration-tests.md).
|
||||
|
||||
### Linting and PR Verification
|
||||
### Linting and Preflight Checks
|
||||
|
||||
During development, the legacy preflight command provides a broad check and may rewrite formatting:
|
||||
To ensure code quality and formatting consistency, run the preflight check:
|
||||
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
|
||||
Before pushing a PR, run the read-only final local gate from a clean worktree:
|
||||
|
||||
```bash
|
||||
npm run verify:pr
|
||||
```
|
||||
|
||||
The final gate covers the deterministic PR CI checks that `preflight` omits. See [`local-pr-verification.md`](../design/local-pr-verification.md) for profiles, supported hosts, and remote-only boundaries.
|
||||
This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`.
|
||||
|
||||
_ProTip_
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@
|
|||
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
||||
"generate": "node scripts/generate-git-commit-info.js",
|
||||
"generate:settings-schema": "node --import tsx/esm scripts/generate-settings-schema.ts",
|
||||
"verify:pr": "node scripts/verify-pr.js",
|
||||
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
|
|
|
|||
|
|
@ -255,49 +255,16 @@ function generateJsonSchema(
|
|||
return jsonSchema;
|
||||
}
|
||||
|
||||
const schema = getSettingsSchema();
|
||||
const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema);
|
||||
|
||||
const outputDir = path.resolve(
|
||||
__dirname,
|
||||
'../packages/vscode-ide-companion/schemas',
|
||||
);
|
||||
const outputPath = path.join(outputDir, 'settings.schema.json');
|
||||
|
||||
export function runGenerateSettingsSchema(
|
||||
args: string[],
|
||||
schemaPath = outputPath,
|
||||
): number {
|
||||
const checkMode = args.length === 1 && args[0] === '--check';
|
||||
if (args.length > 0 && !checkMode) {
|
||||
console.error(
|
||||
`Unknown argument${args.length === 1 ? '' : 's'}: ${args.join(' ')}. Usage: npm run generate:settings-schema -- [--check]`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2) + '\n');
|
||||
|
||||
const schema = getSettingsSchema();
|
||||
const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema);
|
||||
const serializedSchema = JSON.stringify(jsonSchema, null, 2) + '\n';
|
||||
|
||||
if (checkMode) {
|
||||
if (
|
||||
!fs.existsSync(schemaPath) ||
|
||||
fs.readFileSync(schemaPath, 'utf8') !== serializedSchema
|
||||
) {
|
||||
console.error(
|
||||
'Settings JSON Schema is stale. Run "npm run generate:settings-schema" and commit the updated schema.',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
console.log(`Settings JSON Schema is current: ${schemaPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
||||
fs.writeFileSync(schemaPath, serializedSchema);
|
||||
|
||||
console.log(`Generated settings JSON Schema at: ${schemaPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
process.exitCode = runGenerateSettingsSchema(process.argv.slice(2));
|
||||
}
|
||||
console.log(`Generated settings JSON Schema at: ${outputPath}`);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { execSync } from 'node:child_process';
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ACTIONLINT_VERSION = '1.7.12';
|
||||
const SHELLCHECK_VERSION = '0.11.0';
|
||||
|
|
@ -87,7 +87,7 @@ const platformArch = getPlatformArch();
|
|||
*/
|
||||
const LINTERS = {
|
||||
actionlint: {
|
||||
check: `test "$(actionlint -version 2>/dev/null)" = "${ACTIONLINT_VERSION}"`,
|
||||
check: 'command -v actionlint',
|
||||
installer: `
|
||||
mkdir -p "${TEMP_DIR}/actionlint"
|
||||
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
|
||||
|
|
@ -106,7 +106,7 @@ const LINTERS = {
|
|||
`,
|
||||
},
|
||||
shellcheck: {
|
||||
check: `test "$(shellcheck --version 2>/dev/null | awk '/^version:/ { print $2 }')" = "${SHELLCHECK_VERSION}"`,
|
||||
check: 'command -v shellcheck',
|
||||
installer: `
|
||||
mkdir -p "${TEMP_DIR}/shellcheck"
|
||||
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
|
||||
|
|
@ -125,42 +125,23 @@ const LINTERS = {
|
|||
`,
|
||||
},
|
||||
yamllint: {
|
||||
check: `test "$(yamllint --version 2>/dev/null)" = "yamllint ${YAMLLINT_VERSION}"`,
|
||||
installer: `python3 -m pip install --target "${TEMP_DIR}/yamllint" "yamllint==${YAMLLINT_VERSION}"`,
|
||||
check: 'command -v yamllint',
|
||||
installer: `pip3 install --user "yamllint==${YAMLLINT_VERSION}"`,
|
||||
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
|
||||
},
|
||||
};
|
||||
|
||||
export function createLinterEnvironment({
|
||||
cwd = process.cwd(),
|
||||
env = process.env,
|
||||
tempDir = TEMP_DIR,
|
||||
} = {}) {
|
||||
const yamllintTarget = join(tempDir, 'yamllint');
|
||||
return {
|
||||
...env,
|
||||
PIP_CONFIG_FILE: '/dev/null',
|
||||
PIP_REQUIRE_VIRTUALENV: 'false',
|
||||
PIP_USER: 'false',
|
||||
PATH: [
|
||||
join(cwd, 'node_modules', '.bin'),
|
||||
join(tempDir, 'actionlint'),
|
||||
join(tempDir, 'shellcheck'),
|
||||
join(yamllintTarget, 'bin'),
|
||||
env.PATH,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(delimiter),
|
||||
PYTHONPATH: [yamllintTarget, env.PYTHONPATH]
|
||||
.filter(Boolean)
|
||||
.join(delimiter),
|
||||
PYTHONNOUSERSITE: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function runCommand(command, stdio = 'inherit') {
|
||||
try {
|
||||
execSync(command, { stdio, env: createLinterEnvironment() });
|
||||
const env = { ...process.env };
|
||||
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
|
||||
env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`;
|
||||
if (process.platform === 'darwin') {
|
||||
env.PATH = `${env.PATH}:${process.env.HOME}/Library/Python/3.12/bin`;
|
||||
} else if (process.platform === 'linux') {
|
||||
env.PATH = `${env.PATH}:${process.env.HOME}/.local/bin`;
|
||||
}
|
||||
execSync(command, { stdio, env });
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdtempSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runGenerateSettingsSchema } from '../generate-settings-schema.js';
|
||||
|
||||
vi.unmock('node:fs');
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeSchemaPath(): string {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'qwen-settings-schema-'));
|
||||
tempDirs.push(root);
|
||||
return path.join(root, 'schemas', 'settings.schema.json');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('runGenerateSettingsSchema', () => {
|
||||
it('runs the real entry point with the default path and exit status', () => {
|
||||
const script = path.resolve('scripts/generate-settings-schema.ts');
|
||||
const current = spawnSync('npx', ['tsx', script, '--check'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const invalid = spawnSync('npx', ['tsx', script, '--unknown'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
expect(current.status, current.stderr).toBe(0);
|
||||
expect(current.stdout).toContain('Settings JSON Schema is current');
|
||||
expect(invalid.status).toBe(1);
|
||||
expect(invalid.stderr).toContain('Unknown argument: --unknown');
|
||||
});
|
||||
|
||||
it('returns success without rewriting a current schema', () => {
|
||||
const schemaPath = makeSchemaPath();
|
||||
expect(runGenerateSettingsSchema([], schemaPath)).toBe(0);
|
||||
|
||||
const content = readFileSync(schemaPath, 'utf8');
|
||||
utimesSync(schemaPath, new Date(1_000_000_000), new Date(1_000_000_000));
|
||||
const mtimeMs = statSync(schemaPath).mtimeMs;
|
||||
|
||||
expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(0);
|
||||
expect(readFileSync(schemaPath, 'utf8')).toBe(content);
|
||||
expect(statSync(schemaPath).mtimeMs).toBe(mtimeMs);
|
||||
});
|
||||
|
||||
it('reports a stale schema without rewriting it', () => {
|
||||
const schemaPath = makeSchemaPath();
|
||||
mkdirSync(path.dirname(schemaPath), { recursive: true });
|
||||
writeFileSync(schemaPath, 'stale schema\n');
|
||||
utimesSync(schemaPath, new Date(1_000_000_000), new Date(1_000_000_000));
|
||||
const mtimeMs = statSync(schemaPath).mtimeMs;
|
||||
|
||||
expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(1);
|
||||
expect(readFileSync(schemaPath, 'utf8')).toBe('stale schema\n');
|
||||
expect(statSync(schemaPath).mtimeMs).toBe(mtimeMs);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm run generate:settings-schema'),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a missing schema without creating it', () => {
|
||||
const schemaPath = makeSchemaPath();
|
||||
|
||||
expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(1);
|
||||
expect(existsSync(schemaPath)).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm run generate:settings-schema'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown arguments without writing a schema', () => {
|
||||
const schemaPath = makeSchemaPath();
|
||||
|
||||
expect(runGenerateSettingsSchema(['--unknown'], schemaPath)).toBe(1);
|
||||
expect(existsSync(schemaPath)).toBe(false);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unknown argument: --unknown'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,21 +5,9 @@
|
|||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('getLinterTempDir', () => {
|
||||
const originalArgv = process.argv;
|
||||
const tempDirs = [];
|
||||
|
||||
beforeEach(() => {
|
||||
process.argv = ['node', 'scripts/lint.js', '--test-import'];
|
||||
|
|
@ -27,9 +15,6 @@ describe('getLinterTempDir', () => {
|
|||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
for (const directory of tempDirs.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('isolates GitHub Actions linter installs by run and job', async () => {
|
||||
|
|
@ -79,91 +64,4 @@ describe('getLinterTempDir', () => {
|
|||
expect(second).toMatch(/\/qwen-code-linters\/local-[a-f0-9]{16}$/);
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it('uses the owned yamllint target without a version-specific user path', async () => {
|
||||
const { createLinterEnvironment } = await import('../lint.js');
|
||||
|
||||
const environment = createLinterEnvironment({
|
||||
cwd: '/workspace',
|
||||
env: {
|
||||
HOME: '/caller/home',
|
||||
PATH: '/usr/bin',
|
||||
PIP_CONFIG_FILE: '/caller/pip.conf',
|
||||
PIP_REQUIRE_VIRTUALENV: 'true',
|
||||
PIP_USER: 'true',
|
||||
PYTHONPATH: '/caller/python',
|
||||
},
|
||||
tempDir: '/owned/linters',
|
||||
});
|
||||
|
||||
expect(environment.PATH.split(path.delimiter)).toEqual([
|
||||
'/workspace/node_modules/.bin',
|
||||
'/owned/linters/actionlint',
|
||||
'/owned/linters/shellcheck',
|
||||
'/owned/linters/yamllint/bin',
|
||||
'/usr/bin',
|
||||
]);
|
||||
expect(environment.PYTHONPATH).toBe(
|
||||
['/owned/linters/yamllint', '/caller/python'].join(path.delimiter),
|
||||
);
|
||||
expect(environment).toMatchObject({
|
||||
PIP_CONFIG_FILE: '/dev/null',
|
||||
PIP_REQUIRE_VIRTUALENV: 'false',
|
||||
PIP_USER: 'false',
|
||||
PYTHONNOUSERSITE: '1',
|
||||
});
|
||||
expect(Object.values(environment).join(path.delimiter)).not.toContain(
|
||||
'Python/3.12',
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'recognizes the pinned linter version output',
|
||||
() => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'lint-version-test-'));
|
||||
tempDirs.push(root);
|
||||
const bin = path.join(root, 'bin');
|
||||
mkdirSync(bin);
|
||||
const log = path.join(root, 'versions.log');
|
||||
const executables = {
|
||||
actionlint:
|
||||
'#!/bin/sh\necho actionlint >> "$LINTER_VERSION_LOG"\necho 1.7.12\n',
|
||||
curl: '#!/bin/sh\nexit 99\n',
|
||||
python3: '#!/bin/sh\nexit 99\n',
|
||||
shellcheck:
|
||||
'#!/bin/sh\necho shellcheck >> "$LINTER_VERSION_LOG"\necho "ShellCheck - shell script analysis tool"\necho "version: 0.11.0"\n',
|
||||
tar: '#!/bin/sh\nexit 99\n',
|
||||
yamllint:
|
||||
'#!/bin/sh\necho yamllint >> "$LINTER_VERSION_LOG"\necho "yamllint 1.35.1"\n',
|
||||
};
|
||||
for (const [name, contents] of Object.entries(executables)) {
|
||||
const executable = path.join(bin, name);
|
||||
writeFileSync(executable, contents);
|
||||
chmodSync(executable, 0o755);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/lint.js', '--setup'],
|
||||
{
|
||||
cwd: path.resolve('.'),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
LINTER_VERSION_LOG: log,
|
||||
PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter),
|
||||
RUNNER_TEMP: path.join(root, 'runner'),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain('Installing ');
|
||||
expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual([
|
||||
'actionlint',
|
||||
'shellcheck',
|
||||
'yamllint',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,12 +57,6 @@ function getWorkflowStep(job, stepName) {
|
|||
}
|
||||
|
||||
describe('package scripts', () => {
|
||||
it('exposes the local PR verification runner', () => {
|
||||
expect(readPackageJson().scripts['verify:pr']).toBe(
|
||||
'node scripts/verify-pr.js',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
|
||||
const packageJson = readPackageJson();
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,982 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { constants as osConstants, tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { classifyChangedFiles } from '../.github/scripts/ci/classify-profile.mjs';
|
||||
|
||||
const SETTINGS_SCHEMA_PATH =
|
||||
'packages/vscode-ide-companion/schemas/settings.schema.json';
|
||||
const SUPPORTED_HOSTS = new Set(['darwin/arm64', 'darwin/x64', 'linux/x64']);
|
||||
const PASSTHROUGH_ENV_KEYS = new Set([
|
||||
'ALL_PROXY',
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'NO_PROXY',
|
||||
'PATH',
|
||||
'QWEN_CI_REAL_GIT',
|
||||
'RUNNER_TEMP',
|
||||
'SSL_CERT_DIR',
|
||||
'SSL_CERT_FILE',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'all_proxy',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy',
|
||||
]);
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
base: 'origin/main',
|
||||
help: false,
|
||||
profile: 'full',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const option = argv[index];
|
||||
if (option === '--help') {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (option !== '--base' && option !== '--profile') {
|
||||
throw new Error(`Unknown option or positional argument: ${option}`);
|
||||
}
|
||||
|
||||
const value = argv[++index];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`Missing value for ${option}`);
|
||||
}
|
||||
|
||||
if (option === '--base') options.base = value;
|
||||
if (option === '--profile') {
|
||||
if (value !== 'full' && value !== 'auto') {
|
||||
throw new Error(`Invalid profile: ${value}`);
|
||||
}
|
||||
options.profile = value;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function assertNode22(version) {
|
||||
if (Number.parseInt(version, 10) !== 22) {
|
||||
throw new Error(`Node 22 is required; found ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSupportedHost(
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
) {
|
||||
if (!SUPPORTED_HOSTS.has(`${platform}/${arch}`)) {
|
||||
throw new Error(
|
||||
`npm run verify:pr supports macOS x64/ARM64 and Linux x64; found ${platform}/${arch}. Use GitHub CI for unsupported hosts.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createBaseEnvironment(baseEnv) {
|
||||
const env = {};
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (PASSTHROUGH_ENV_KEYS.has(key) && value !== undefined) env[key] = value;
|
||||
}
|
||||
return { ...env, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' };
|
||||
}
|
||||
|
||||
export function createGitEnvironment(baseEnv, { home, hooksPath } = {}) {
|
||||
const env = createBaseEnvironment(baseEnv);
|
||||
Object.assign(env, {
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_SYSTEM: '/dev/null',
|
||||
});
|
||||
if (home) {
|
||||
env.HOME = home;
|
||||
env.USERPROFILE = home;
|
||||
}
|
||||
if (hooksPath) {
|
||||
Object.assign(env, {
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
||||
GIT_CONFIG_VALUE_0: hooksPath,
|
||||
});
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function runGit(cwd, args, baseEnv) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: createGitEnvironment(baseEnv),
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || `git ${args.join(' ')} exited with failure`,
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export function inspectRepository({ base, cwd, env = process.env }) {
|
||||
const status = runGit(
|
||||
cwd,
|
||||
['status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
||||
env,
|
||||
);
|
||||
if (status) {
|
||||
throw new Error(
|
||||
'The caller working tree must have no staged, unstaged, or untracked changes.',
|
||||
);
|
||||
}
|
||||
|
||||
const head = runGit(cwd, ['rev-parse', 'HEAD'], env).trim();
|
||||
let baseSha;
|
||||
try {
|
||||
baseSha = runGit(
|
||||
cwd,
|
||||
['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`],
|
||||
env,
|
||||
).trim();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to resolve base ref ${base}: ${message}`);
|
||||
}
|
||||
const mergeBase = runGit(cwd, ['merge-base', baseSha, head], env).trim();
|
||||
const changedFiles = runGit(
|
||||
cwd,
|
||||
['diff', '--name-only', '--no-renames', '-z', mergeBase, head, '--'],
|
||||
env,
|
||||
)
|
||||
.split('\0')
|
||||
.filter(Boolean);
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
throw new Error(`No committed changes found against ${base}.`);
|
||||
}
|
||||
|
||||
return { baseSha, changedFiles, head, mergeBase };
|
||||
}
|
||||
|
||||
export function selectProfile({
|
||||
changedFiles,
|
||||
classify = classifyChangedFiles,
|
||||
requestedProfile,
|
||||
}) {
|
||||
return requestedProfile === 'auto' ? classify(changedFiles) : 'full';
|
||||
}
|
||||
|
||||
function step(name, ...command) {
|
||||
return { command, name };
|
||||
}
|
||||
|
||||
const GITHUB_HELPER_TEST = [
|
||||
'Run GitHub CI helper tests',
|
||||
'node',
|
||||
'--test',
|
||||
'.github/scripts/pr-safety-precheck.test.mjs',
|
||||
'.github/scripts/ci/classify-profile.test.mjs',
|
||||
'.github/scripts/resolve-sandbox-image.test.mjs',
|
||||
];
|
||||
|
||||
function isRegularWorktreeFile(worktree, file) {
|
||||
let currentPath = worktree;
|
||||
let stats;
|
||||
const segments = file.split('/');
|
||||
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
currentPath = join(currentPath, segment);
|
||||
stats = lstatSync(currentPath, { throwIfNoEntry: false });
|
||||
if (!stats || stats.isSymbolicLink()) return false;
|
||||
if (index < segments.length - 1 && !stats.isDirectory()) return false;
|
||||
}
|
||||
|
||||
return stats?.isFile() === true;
|
||||
}
|
||||
|
||||
export function createValidationSteps({ prettierFiles = [], profile }) {
|
||||
if (profile === 'docs_only') return [];
|
||||
const commands =
|
||||
profile === 'github_ci_only'
|
||||
? [
|
||||
['Set up linters', 'node', 'scripts/lint.js', '--setup'],
|
||||
['Run actionlint', 'node', 'scripts/lint.js', '--actionlint'],
|
||||
['Run yamllint', 'node', 'scripts/lint.js', '--yamllint'],
|
||||
GITHUB_HELPER_TEST,
|
||||
]
|
||||
: [
|
||||
[
|
||||
'Install dependencies',
|
||||
'npm',
|
||||
'ci',
|
||||
'--prefer-offline',
|
||||
'--no-audit',
|
||||
'--progress=false',
|
||||
'--ignore-scripts=false',
|
||||
],
|
||||
[
|
||||
'Audit critical runtime dependencies',
|
||||
'npm',
|
||||
'run',
|
||||
'audit:runtime:critical',
|
||||
],
|
||||
['Check lockfile', 'npm', 'run', 'check:lockfile'],
|
||||
[
|
||||
'Check desktop workspace isolation',
|
||||
'npm',
|
||||
'run',
|
||||
'check:desktop-isolation',
|
||||
],
|
||||
['Set up linters', 'node', 'scripts/lint.js', '--setup'],
|
||||
['Run ESLint', 'node', 'scripts/lint.js', '--eslint'],
|
||||
['Run actionlint', 'node', 'scripts/lint.js', '--actionlint'],
|
||||
['Run shellcheck', 'node', 'scripts/lint.js', '--shellcheck'],
|
||||
['Run yamllint', 'node', 'scripts/lint.js', '--yamllint'],
|
||||
GITHUB_HELPER_TEST,
|
||||
...(prettierFiles.length > 0
|
||||
? [
|
||||
[
|
||||
'Run Prettier',
|
||||
'npx',
|
||||
'prettier',
|
||||
'--experimental-cli',
|
||||
'--check',
|
||||
'--ignore-unknown',
|
||||
'--',
|
||||
...prettierFiles,
|
||||
],
|
||||
]
|
||||
: []),
|
||||
['Run i18n check', 'npm', 'run', 'check-i18n'],
|
||||
[
|
||||
'Check settings schema',
|
||||
'npm',
|
||||
'run',
|
||||
'generate:settings-schema',
|
||||
'--',
|
||||
'--check',
|
||||
],
|
||||
[
|
||||
'Ensure settings schema is committed',
|
||||
'git',
|
||||
'cat-file',
|
||||
'-e',
|
||||
`HEAD:${SETTINGS_SCHEMA_PATH}`,
|
||||
],
|
||||
[
|
||||
'Check committed settings schema',
|
||||
'git',
|
||||
'diff',
|
||||
'--exit-code',
|
||||
'HEAD',
|
||||
'--',
|
||||
SETTINGS_SCHEMA_PATH,
|
||||
],
|
||||
['Run typecheck', 'npm', 'run', 'typecheck'],
|
||||
[
|
||||
'Check serve fast-path bundle closure',
|
||||
'npm',
|
||||
'run',
|
||||
'check:serve-fast-path-bundle',
|
||||
],
|
||||
[
|
||||
'Run unit tests',
|
||||
'npx',
|
||||
'cross-env',
|
||||
'NODE_OPTIONS=--max-old-space-size=3072',
|
||||
'npm',
|
||||
'run',
|
||||
'test:ci',
|
||||
'--workspaces',
|
||||
'--if-present',
|
||||
'--',
|
||||
'--no-file-parallelism',
|
||||
],
|
||||
[
|
||||
'Run script tests',
|
||||
'npm',
|
||||
'run',
|
||||
'test:scripts',
|
||||
'--',
|
||||
'--no-file-parallelism',
|
||||
],
|
||||
[
|
||||
'Run no-AK integration tests',
|
||||
'npm',
|
||||
'run',
|
||||
'test:integration:no-ak:sandbox:none',
|
||||
],
|
||||
[
|
||||
'Install Playwright Chromium',
|
||||
'npx',
|
||||
'playwright',
|
||||
'install',
|
||||
'chromium',
|
||||
],
|
||||
[
|
||||
'Run web shell smoke tests',
|
||||
'npm',
|
||||
'run',
|
||||
'test:e2e:smoke',
|
||||
'--workspace=packages/web-shell',
|
||||
],
|
||||
];
|
||||
const steps = commands.map(([name, ...command]) => step(name, ...command));
|
||||
if (profile === 'full') {
|
||||
steps.find(
|
||||
({ name }) => name === 'Install dependencies',
|
||||
).installEnvironment = true;
|
||||
for (const name of [
|
||||
'Install Playwright Chromium',
|
||||
'Run web shell smoke tests',
|
||||
]) {
|
||||
steps.find((candidate) => candidate.name === name).playwrightEnvironment =
|
||||
true;
|
||||
}
|
||||
steps.find(({ name }) => name === 'Run web shell smoke tests').playwright =
|
||||
true;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function needsPythonChecks(changedFiles) {
|
||||
return changedFiles.some((file) => {
|
||||
const normalized = file.replace(/\\/g, '/');
|
||||
return (
|
||||
normalized.startsWith('packages/sdk-python/') ||
|
||||
normalized === '.github/workflows/sdk-python.yml'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getVenvPythonPath(venv, platform = process.platform) {
|
||||
return platform === 'win32'
|
||||
? join(venv, 'Scripts', 'python.exe')
|
||||
: join(venv, 'bin', 'python');
|
||||
}
|
||||
|
||||
export function createPythonSteps({ platform = process.platform, pythonRoot }) {
|
||||
const steps = [
|
||||
{
|
||||
...step('Require uv', 'uv', '--version'),
|
||||
uvRequirement: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const version of ['3.10', '3.11', '3.12']) {
|
||||
const venv = join(pythonRoot, version);
|
||||
const python = getVenvPythonPath(venv, platform);
|
||||
const commands = [
|
||||
['Create virtualenv', 'uv', 'venv', '--python', version, '--seed', venv],
|
||||
['Upgrade pip', python, '-m', 'pip', 'install', '--upgrade', 'pip'],
|
||||
[
|
||||
'Install SDK test dependencies',
|
||||
python,
|
||||
'-m',
|
||||
'pip',
|
||||
'install',
|
||||
'-e',
|
||||
'packages/sdk-python[dev]',
|
||||
],
|
||||
[
|
||||
'Run Ruff',
|
||||
python,
|
||||
'-m',
|
||||
'ruff',
|
||||
'check',
|
||||
'--config',
|
||||
'packages/sdk-python/pyproject.toml',
|
||||
'packages/sdk-python',
|
||||
],
|
||||
[
|
||||
'Run Ruff format',
|
||||
python,
|
||||
'-m',
|
||||
'ruff',
|
||||
'format',
|
||||
'--check',
|
||||
'--config',
|
||||
'packages/sdk-python/pyproject.toml',
|
||||
'packages/sdk-python',
|
||||
],
|
||||
[
|
||||
'Run Mypy',
|
||||
python,
|
||||
'-m',
|
||||
'mypy',
|
||||
'--config-file',
|
||||
'packages/sdk-python/pyproject.toml',
|
||||
'packages/sdk-python/src',
|
||||
],
|
||||
[
|
||||
'Run Pytest',
|
||||
python,
|
||||
'-m',
|
||||
'pytest',
|
||||
'-c',
|
||||
'packages/sdk-python/pyproject.toml',
|
||||
'packages/sdk-python/tests',
|
||||
'-q',
|
||||
],
|
||||
];
|
||||
steps.push(
|
||||
...commands.map(([name, ...command]) =>
|
||||
step(`${name} (Python ${version})`, ...command),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const pythonStep of steps) {
|
||||
pythonStep.pythonEnvironment = true;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function createStepEnvironment({
|
||||
baseEnv,
|
||||
home,
|
||||
playwrightPort,
|
||||
step: currentStep,
|
||||
}) {
|
||||
const env = createBaseEnvironment(baseEnv);
|
||||
Object.assign(env, {
|
||||
CI: 'true',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_SYSTEM: '/dev/null',
|
||||
HOME: home,
|
||||
NPM_CONFIG_CACHE: join(home, '.npm'),
|
||||
NPM_CONFIG_GLOBAL: 'false',
|
||||
NPM_CONFIG_GLOBALCONFIG: '/dev/null',
|
||||
NPM_CONFIG_PREFIX: join(home, '.npm-prefix'),
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: 'false',
|
||||
NPM_CONFIG_USERCONFIG: join(home, '.npmrc'),
|
||||
NO_COLOR: 'true',
|
||||
USERPROFILE: home,
|
||||
});
|
||||
|
||||
if (currentStep.installEnvironment) {
|
||||
Object.assign(env, {
|
||||
HUSKY: '0',
|
||||
NPM_CONFIG_FETCH_RETRIES: '5',
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '120000',
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '20000',
|
||||
NPM_CONFIG_FETCH_TIMEOUT: '300000',
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: 'false',
|
||||
});
|
||||
}
|
||||
|
||||
if (currentStep.pythonEnvironment) {
|
||||
Object.assign(env, {
|
||||
PIP_CONFIG_FILE: '/dev/null',
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: '1',
|
||||
PIP_NO_INPUT: '1',
|
||||
PIP_REQUIRE_VIRTUALENV: 'false',
|
||||
PIP_USER: 'false',
|
||||
PYTHONNOUSERSITE: '1',
|
||||
PYTHONUTF8: '1',
|
||||
UV_NO_CONFIG: '1',
|
||||
});
|
||||
}
|
||||
|
||||
if (currentStep.playwrightEnvironment) {
|
||||
env.PLAYWRIGHT_BROWSERS_PATH = join(home, 'playwright');
|
||||
}
|
||||
|
||||
if (currentStep.playwright) env.PLAYWRIGHT_PORT = String(playwrightPort);
|
||||
return env;
|
||||
}
|
||||
|
||||
function quoteArgument(argument) {
|
||||
const display = argument.replace(
|
||||
/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu,
|
||||
(character) => `\\u{${character.codePointAt(0).toString(16)}}`,
|
||||
);
|
||||
return /^[A-Za-z0-9_./:=@%+,-]+$/.test(display)
|
||||
? display
|
||||
: `'${display.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function formatCommand(command) {
|
||||
return command.map(quoteArgument).join(' ');
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function executeChild({ command, cwd, env }) {
|
||||
return new Promise((resolveResult) => {
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
let forwardedSignal;
|
||||
let settled = false;
|
||||
const signalHandlers = new Map();
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
resolveResult(result);
|
||||
};
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
const handler = () => {
|
||||
if (forwardedSignal) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
return;
|
||||
}
|
||||
forwardedSignal = signal;
|
||||
signalChild(child, signal);
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
child.once('error', (error) => {
|
||||
finish({
|
||||
error,
|
||||
relaySignal: forwardedSignal,
|
||||
status: null,
|
||||
});
|
||||
});
|
||||
child.once('exit', (status, signal) => {
|
||||
finish({
|
||||
relaySignal: forwardedSignal,
|
||||
signal,
|
||||
status,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class ValidationFailure extends Error {
|
||||
constructor({ command, detail, exitCode, relaySignal, signal, stage }) {
|
||||
super(`${stage}: ${detail}`);
|
||||
this.command = command;
|
||||
this.detail = detail;
|
||||
this.exitCode = exitCode;
|
||||
this.relaySignal = relaySignal;
|
||||
this.signal = signal;
|
||||
this.stage = stage;
|
||||
}
|
||||
}
|
||||
|
||||
function commandFailure(stage, command, result) {
|
||||
if (
|
||||
!result.error &&
|
||||
!result.relaySignal &&
|
||||
!result.signal &&
|
||||
result.status === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const failureSignal = result.signal ?? result.relaySignal;
|
||||
const signalNumber = failureSignal
|
||||
? osConstants.signals[failureSignal]
|
||||
: undefined;
|
||||
const exitCode =
|
||||
typeof result.status === 'number' && result.status !== 0
|
||||
? result.status
|
||||
: typeof signalNumber === 'number'
|
||||
? 128 + signalNumber
|
||||
: 1;
|
||||
const detail = result.error
|
||||
? `spawn error: ${result.error.message}`
|
||||
: result.signal
|
||||
? `terminated by signal ${result.signal}`
|
||||
: result.relaySignal
|
||||
? `interrupted after forwarding signal ${result.relaySignal}`
|
||||
: `exited with status ${exitCode}`;
|
||||
return new ValidationFailure({
|
||||
command,
|
||||
detail,
|
||||
exitCode,
|
||||
relaySignal: result.relaySignal,
|
||||
signal: result.signal ?? undefined,
|
||||
stage,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSteps({
|
||||
allocatePort,
|
||||
baseEnv,
|
||||
cwd,
|
||||
execute = executeChild,
|
||||
home,
|
||||
log = console.log,
|
||||
now = Date.now,
|
||||
steps,
|
||||
}) {
|
||||
for (const [index, currentStep] of steps.entries()) {
|
||||
log(`[${index + 1}/${steps.length}] ${currentStep.name}`);
|
||||
log(`Command: ${formatCommand(currentStep.command)}`);
|
||||
const startedAt = now();
|
||||
let playwrightPort;
|
||||
if (currentStep.playwright) {
|
||||
try {
|
||||
playwrightPort = await allocatePort();
|
||||
} catch (error) {
|
||||
throw new ValidationFailure({
|
||||
command: currentStep.command,
|
||||
detail: `unable to allocate Playwright port: ${error instanceof Error ? error.message : String(error)}`,
|
||||
exitCode: 1,
|
||||
stage: currentStep.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await execute({
|
||||
command: currentStep.command,
|
||||
cwd,
|
||||
env: createStepEnvironment({
|
||||
baseEnv,
|
||||
home,
|
||||
playwrightPort,
|
||||
step: currentStep,
|
||||
}),
|
||||
});
|
||||
log(`Elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`);
|
||||
|
||||
const failure = commandFailure(
|
||||
currentStep.name,
|
||||
currentStep.command,
|
||||
result,
|
||||
);
|
||||
if (!failure) continue;
|
||||
if (currentStep.uvRequirement) {
|
||||
failure.detail = `uv is required for Python SDK verification. Install uv and ensure it is on PATH. ${failure.detail}`;
|
||||
failure.message = `${failure.stage}: ${failure.detail}`;
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
function runWorktreeGit({ args, cwd, env }) {
|
||||
return spawnSync('git', args, { cwd, env, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
export async function withTemporaryWorktree({
|
||||
baseEnv = process.env,
|
||||
cwd,
|
||||
gitCommand = runWorktreeGit,
|
||||
head,
|
||||
makeContainer = () =>
|
||||
mkdtempSync(
|
||||
join(
|
||||
realpathSync(process.platform === 'win32' ? tmpdir() : '/tmp'),
|
||||
'qwen-verify-pr-',
|
||||
),
|
||||
),
|
||||
removeContainer = (container) =>
|
||||
rmSync(container, { recursive: true, force: true }),
|
||||
reportCleanup = console.error,
|
||||
validate,
|
||||
}) {
|
||||
if (!head) throw new Error('Temporary worktree requires an inspected HEAD.');
|
||||
const createdContainer = makeContainer();
|
||||
let container = createdContainer;
|
||||
let gitBaseEnv;
|
||||
let paths;
|
||||
let added = false;
|
||||
const cleanupFailures = [];
|
||||
let primaryError;
|
||||
let validationResult;
|
||||
|
||||
try {
|
||||
container = realpathSync(createdContainer);
|
||||
paths = {
|
||||
container,
|
||||
home: join(container, 'home'),
|
||||
hooks: join(container, 'hooks'),
|
||||
pythonRoot: join(container, 'python'),
|
||||
temp: join(container, 'tmp'),
|
||||
worktree: join(container, 'worktree'),
|
||||
};
|
||||
for (const directory of [
|
||||
paths.home,
|
||||
paths.hooks,
|
||||
paths.pythonRoot,
|
||||
paths.temp,
|
||||
]) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
gitBaseEnv = {
|
||||
...baseEnv,
|
||||
RUNNER_TEMP: container,
|
||||
TEMP: paths.temp,
|
||||
TMP: paths.temp,
|
||||
TMPDIR: paths.temp,
|
||||
};
|
||||
const gitEnv = createGitEnvironment(gitBaseEnv, {
|
||||
home: paths.home,
|
||||
hooksPath: paths.hooks,
|
||||
});
|
||||
const addCommand = ['worktree', 'add', '--detach', paths.worktree, head];
|
||||
const addFailure = commandFailure(
|
||||
'Create temporary worktree',
|
||||
['git', ...addCommand],
|
||||
gitCommand({ args: addCommand, cwd, env: gitEnv }),
|
||||
);
|
||||
if (addFailure) throw addFailure;
|
||||
added = true;
|
||||
validationResult = await validate(paths);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
if (added && paths) {
|
||||
const removeCommand = [
|
||||
'worktree',
|
||||
'remove',
|
||||
'--force',
|
||||
'--force',
|
||||
paths.worktree,
|
||||
];
|
||||
const cleanupFailure = commandFailure(
|
||||
'Clean up temporary worktree',
|
||||
['git', ...removeCommand],
|
||||
gitCommand({
|
||||
args: removeCommand,
|
||||
cwd,
|
||||
env: createGitEnvironment(gitBaseEnv, {
|
||||
home: paths.home,
|
||||
hooksPath: paths.hooks,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (cleanupFailure) cleanupFailures.push(cleanupFailure);
|
||||
}
|
||||
try {
|
||||
removeContainer(container);
|
||||
} catch (error) {
|
||||
cleanupFailures.push(
|
||||
new ValidationFailure({
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
exitCode: 1,
|
||||
stage: 'Clean up temporary container',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryError) {
|
||||
for (const failure of cleanupFailures) {
|
||||
reportCleanup(`Cleanup also failed: ${failure.message}`);
|
||||
}
|
||||
throw primaryError;
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
for (const failure of cleanupFailures.slice(1)) {
|
||||
reportCleanup(`Cleanup also failed: ${failure.message}`);
|
||||
}
|
||||
throw cleanupFailures[0];
|
||||
}
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
function allocateFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else if (typeof address === 'object' && address) resolve(address.port);
|
||||
else reject(new Error('Unable to allocate a localhost port.'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPullRequest(
|
||||
{ base, cwd, requestedProfile },
|
||||
{
|
||||
allocatePort = allocateFreePort,
|
||||
arch = process.arch,
|
||||
baseEnv = process.env,
|
||||
error = console.error,
|
||||
inspect = inspectRepository,
|
||||
log = console.log,
|
||||
nodeVersion = process.versions.node,
|
||||
now = Date.now,
|
||||
platform = process.platform,
|
||||
runValidationSteps = runSteps,
|
||||
temporaryWorktree = withTemporaryWorktree,
|
||||
} = {},
|
||||
) {
|
||||
const startedAt = now();
|
||||
assertNode22(nodeVersion);
|
||||
assertSupportedHost(platform, arch);
|
||||
const repository = inspect({ base, cwd, env: baseEnv });
|
||||
const profile = selectProfile({
|
||||
changedFiles: repository.changedFiles,
|
||||
requestedProfile,
|
||||
});
|
||||
|
||||
log(`Base: ${base}`);
|
||||
log(`HEAD: ${repository.head}`);
|
||||
log(`Changed files: ${repository.changedFiles.length}`);
|
||||
log(`Profile: ${profile}`);
|
||||
|
||||
if (profile === 'docs_only') {
|
||||
log('Docs-only change; full CI skipped.');
|
||||
log(`Total elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`);
|
||||
return { ...repository, profile };
|
||||
}
|
||||
|
||||
try {
|
||||
await temporaryWorktree({
|
||||
baseEnv,
|
||||
cwd,
|
||||
head: repository.head,
|
||||
reportCleanup: error,
|
||||
validate: async ({ container, home, pythonRoot, temp, worktree }) => {
|
||||
const prettierFiles =
|
||||
profile === 'full'
|
||||
? repository.changedFiles.filter((file) =>
|
||||
isRegularWorktreeFile(worktree, file),
|
||||
)
|
||||
: [];
|
||||
const steps = createValidationSteps({ prettierFiles, profile });
|
||||
if (profile === 'full' && needsPythonChecks(repository.changedFiles)) {
|
||||
steps.push(...createPythonSteps({ pythonRoot }));
|
||||
}
|
||||
await runValidationSteps({
|
||||
allocatePort,
|
||||
baseEnv: {
|
||||
...baseEnv,
|
||||
RUNNER_TEMP: container,
|
||||
TEMP: temp,
|
||||
TMP: temp,
|
||||
TMPDIR: temp,
|
||||
},
|
||||
cwd: worktree,
|
||||
home,
|
||||
log,
|
||||
now,
|
||||
steps,
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (failure) {
|
||||
if (failure && typeof failure === 'object') {
|
||||
failure.base = base;
|
||||
failure.head = repository.head;
|
||||
failure.profile = profile;
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
|
||||
log(`Total elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`);
|
||||
return { ...repository, profile };
|
||||
}
|
||||
|
||||
const USAGE = `Usage: npm run verify:pr -- [options]
|
||||
|
||||
Options:
|
||||
--base <ref> Base ref (default: origin/main)
|
||||
--profile <full|auto> Validation profile (default: full)
|
||||
--help Show this help`;
|
||||
|
||||
export async function runCli(
|
||||
argv,
|
||||
{
|
||||
cwd = process.cwd(),
|
||||
error = console.error,
|
||||
log = console.log,
|
||||
relaySignal = (signal) => process.kill(process.pid, signal),
|
||||
verify = verifyPullRequest,
|
||||
} = {},
|
||||
) {
|
||||
let options;
|
||||
try {
|
||||
options = parseArgs(argv);
|
||||
} catch (parseError) {
|
||||
error(
|
||||
parseError instanceof Error ? parseError.message : String(parseError),
|
||||
);
|
||||
error(USAGE);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (options.help) {
|
||||
log(USAGE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await verify({
|
||||
base: options.base,
|
||||
cwd,
|
||||
requestedProfile: options.profile,
|
||||
});
|
||||
return 0;
|
||||
} catch (failure) {
|
||||
const context = failure && typeof failure === 'object' ? failure : {};
|
||||
const command = Array.isArray(context.command)
|
||||
? formatCommand(context.command)
|
||||
: 'not started';
|
||||
error('PR verification failed.');
|
||||
error(`Stage: ${context.stage ?? 'Caller guards'}`);
|
||||
error(`Command: ${command}`);
|
||||
error(`HEAD: ${context.head ?? 'unresolved'}`);
|
||||
error(`Base: ${context.base ?? options.base}`);
|
||||
error(`Profile: ${context.profile ?? options.profile}`);
|
||||
error(`Error: ${context.detail ?? context.message ?? String(failure)}`);
|
||||
error(
|
||||
`Rerun: npm run verify:pr -- --base ${quoteArgument(options.base)} --profile ${options.profile}`,
|
||||
);
|
||||
if (typeof context.relaySignal === 'string') {
|
||||
relaySignal(context.relaySignal);
|
||||
const signalNumber = osConstants.signals[context.relaySignal];
|
||||
return typeof signalNumber === 'number' ? 128 + signalNumber : 1;
|
||||
}
|
||||
return Number.isInteger(context.exitCode) && context.exitCode > 0
|
||||
? context.exitCode
|
||||
: 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
|
||||
) {
|
||||
process.exitCode = await runCli(process.argv.slice(2));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue