diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 403d6c48c..b30805729 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -6,16 +6,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -55,12 +50,6 @@ const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ 'SHA256SUMS', ]); -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--out-dir': { name: 'outDir' }, -}; - if (isMainModule(import.meta.url)) { try { await main(); @@ -71,10 +60,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - outDir: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); return; @@ -97,6 +83,31 @@ Options: `); } +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + async function buildHostedInstallationAssets(outDir, options = {}) { const root = options.root || rootDir; fs.mkdirSync(outDir, { recursive: true }); @@ -189,11 +200,49 @@ async function assertHostedInstallationAssetChecksums(outDir) { } } +function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const [index, line] of content.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + export { HOSTED_INSTALLATION_ASSETS, HOSTED_INSTALLATION_ASSET_NAMES, - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS, - HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, }; diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 60d84aed8..b0bf2bd17 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -7,28 +7,19 @@ */ import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { TARGETS, writeSha256Sums } from './create-standalone-package.js'; -import { isStandaloneArchiveName } from './release-asset-config.js'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; +import { writeSha256Sums } from './create-standalone-package.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -// RELEASE_TARGETS must stay in sync with TARGETS in create-standalone-package.js; -// every release qwenTarget should map to a package target and output extension. const RELEASE_TARGETS = [ { qwenTarget: 'darwin-arm64', @@ -53,16 +44,8 @@ const RELEASE_TARGETS = [ { qwenTarget: 'win-x64', nodeTarget: 'win-x64', nodeArchiveExtension: 'zip' }, ]; const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--node-version': { name: 'nodeVersion' }, - '--out-dir': { name: 'outDir' }, - '--runtime-dir': { name: 'runtimeDir' }, - '--version': { name: 'version' }, -}; -if (isMainModule(import.meta.url)) { +if (isMainModule()) { try { await main(); } catch (error) { @@ -72,21 +55,13 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - nodeVersion: undefined, - outDir: undefined, - runtimeDir: undefined, - version: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); return; } - const nodeVersion = normalizeNodeVersion( - args.nodeVersion || process.versions.node, - ); + const nodeVersion = args.nodeVersion || process.versions.node; const outDir = path.resolve( args.outDir || path.join(rootDir, 'dist', 'standalone'), ); @@ -100,37 +75,21 @@ async function main() { const nodeDistUrl = `https://nodejs.org/dist/v${nodeVersion}`; try { - cleanOutputDirectory(outDir); + fs.mkdirSync(outDir, { recursive: true }); const checksumsPath = path.join(runtimeDir, 'SHASUMS256.txt'); await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); - const targetResults = await Promise.allSettled( - RELEASE_TARGETS.map(async (target) => { - await packageTarget({ - ...target, - nodeDistUrl, - nodeVersion, - outDir, - releaseVersion: args.version, - runtimeDir, - checksums, - }); - return target.qwenTarget; - }), - ); - const failures = targetResults.flatMap((result, index) => - result.status === 'rejected' - ? [ - `${RELEASE_TARGETS[index].qwenTarget}: ${formatErrorReason( - result.reason, - )}`, - ] - : [], - ); - - if (failures.length > 0) { - fail(`Failed to package standalone target(s): ${failures.join('; ')}`); + for (const target of RELEASE_TARGETS) { + await packageTarget({ + ...target, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion: args.version, + runtimeDir, + checksums, + }); } await writeSha256Sums(outDir); @@ -140,8 +99,8 @@ async function main() { } } -function normalizeNodeVersion(version) { - return version.replace(/^v/i, ''); +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; } async function packageTarget({ @@ -181,18 +140,6 @@ async function packageTarget({ }); } -function formatErrorReason(reason) { - if (reason instanceof Error) { - return reason.message; - } - return String(reason); -} - -function cleanOutputDirectory(outDir) { - fs.rmSync(outDir, { recursive: true, force: true }); - fs.mkdirSync(outDir, { recursive: true }); -} - async function downloadFile(url, destination) { console.log(`Downloading ${url}`); const response = await fetch(url); @@ -211,7 +158,14 @@ async function downloadFile(url, destination) { } function parseChecksums(content) { - return parseSha256Sums(content); + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const [hash, fileName] = line.trim().split(/\s+/, 2); + if (hash && fileName) { + checksums.set(fileName.replace(/^\*/, ''), hash); + } + } + return checksums; } async function verifyNodeArchive(archivePath, archiveName, checksums) { @@ -228,19 +182,28 @@ async function verifyNodeArchive(archivePath, archiveName, checksums) { console.log(`Verified Node.js runtime checksum for ${archiveName}`); } +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + function assertStandaloneOutput(outDir) { const checksumPath = path.join(outDir, 'SHA256SUMS'); if (!fs.existsSync(checksumPath)) { fail(`Standalone SHA256SUMS was not created at ${checksumPath}`); } - const archiveNames = Array.from( - parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')).keys(), - ) - .filter(isStandaloneArchiveName) + const archiveNames = fs + .readFileSync(checksumPath, 'utf8') + .split(/\r?\n/) + .filter((line) => /^[0-9a-f]{64}\s+/.test(line)) + .map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, '')) + .filter(Boolean) .sort(); - const expectedArchiveNames = RELEASE_TARGETS.map(({ qwenTarget }) => - standaloneArchiveName(qwenTarget), + const expectedArchiveNames = RELEASE_TARGETS.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, ).sort(); const missing = expectedArchiveNames.filter( (archiveName) => !archiveNames.includes(archiveName), @@ -269,12 +232,52 @@ function assertStandaloneOutput(outDir) { console.log(`Verified ${archiveNames.length} standalone release checksums.`); } -function standaloneArchiveName(qwenTarget) { - const targetConfig = TARGETS.get(qwenTarget); - if (!targetConfig) { - fail(`No standalone package target config found for ${qwenTarget}`); +function parseArgs(argv) { + const args = { + help: false, + nodeVersion: undefined, + outDir: undefined, + runtimeDir: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--node-version': + args.nodeVersion = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--runtime-dir': + args.runtimeDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } } - return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; } function printUsage() { @@ -287,17 +290,11 @@ Options: --out-dir PATH Output directory. Defaults to dist/standalone. --runtime-dir PATH Temporary Node.js runtime download directory. --node-version VERSION Node.js version to download. Defaults to current Node. - -Host requirements: - Linux Node.js runtimes are downloaded as tar.xz archives, so the host - needs xz support (Ubuntu/Debian: xz-utils; Alpine: xz; macOS/Windows: built-in). `); } -export { - assertStandaloneOutput, - normalizeNodeVersion, - parseChecksums, - RELEASE_TARGETS, - standaloneArchiveName, -}; +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index e600cffaa..968d6da45 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -7,25 +7,18 @@ */ import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { isStandaloneArchiveName } from './release-asset-config.js'; -import { - fail, - isMainModule, - parseCliArgs, - sha256File, -} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); const distDir = path.join(rootDir, 'dist'); -// TARGETS must stay in sync with RELEASE_TARGETS in build-standalone-release.js; -// every release target should have a package target and output extension here. const TARGETS = new Map([ [ 'darwin-arm64', @@ -57,19 +50,9 @@ const DIST_ALLOWED_ENTRIES = new Set([ const DIST_ALLOWED_ENTRY_PATTERNS = [ /^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/, ]; -const DIST_IGNORED_ENTRIES = new Set(['.DS_Store', 'esbuild.json']); const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--target': { name: 'target' }, - '--node-archive': { name: 'nodeArchive' }, - '--out-dir': { name: 'outDir' }, - '--version': { name: 'version' }, - '--skip-checksums': { name: 'skipChecksums', type: 'boolean' }, -}; -if (isMainModule(import.meta.url)) { +if (isMainModule()) { try { await main(); } catch (error) { @@ -79,14 +62,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - nodeArchive: undefined, - outDir: undefined, - skipChecksums: false, - target: undefined, - version: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); @@ -155,6 +131,62 @@ async function main() { } } +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + nodeArchive: undefined, + skipChecksums: false, + target: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--target': + args.target = readOptionValue(argv, index, arg); + index += 1; + break; + case '--node-archive': + args.nodeArchive = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + case '--skip-checksums': + args.skipChecksums = true; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + function printUsage() { console.log(`Qwen Code standalone package builder @@ -202,7 +234,7 @@ function copyRuntimeAssets(packageRoot, outDir) { fs.mkdirSync(libDir, { recursive: true }); for (const entry of fs.readdirSync(distDir)) { - if (entry === skippedDistEntry || DIST_IGNORED_ENTRIES.has(entry)) { + if (entry === skippedDistEntry || entry === '.DS_Store') { continue; } if (!isAllowedDistEntry(entry)) { @@ -223,10 +255,10 @@ function copyRuntimeAssets(packageRoot, outDir) { ); } - const packageJsonPath = fs.existsSync(path.join(distDir, 'package.json')) - ? path.join(distDir, 'package.json') - : path.join(rootDir, 'package.json'); - fs.copyFileSync(packageJsonPath, path.join(packageRoot, 'package.json')); + fs.copyFileSync( + path.join(rootDir, 'package.json'), + path.join(packageRoot, 'package.json'), + ); } function topLevelDistEntryForPath(candidatePath) { @@ -491,57 +523,11 @@ function createArchive(outputExtension, outputPath, cwd) { return; } - // On macOS Sequoia+, every file inherits an immovable `com.apple.provenance` - // xattr that bsdtar embeds into pax extended headers. Linux GNU tar then - // emits one `Ignoring unknown extended header keyword` warning per file at - // extract time. bsdtar's `--no-mac-metadata` is silently ignored in older - // libarchive (3.5.x), and `xattr -d com.apple.provenance` is rejected by - // SIP. The reliable fix is to use GNU tar, which does not write xattrs - // unless `--xattrs` is passed. - const tarBin = pickTarBinary(); - run(tarBin, ['-czf', outputPath, '-C', cwd, 'qwen-code']); -} - -function pickTarBinary() { - if (process.platform !== 'darwin') return 'tar'; - // Try common gtar paths (homebrew arm/intel + gnubin shim). - const candidates = [ - '/opt/homebrew/bin/gtar', - '/usr/local/bin/gtar', - '/opt/homebrew/opt/gnu-tar/libexec/gnubin/tar', - ]; - for (const candidate of candidates) { - try { - if (fs.statSync(candidate).isFile()) return candidate; - } catch { - // continue - } - } - // PATH lookup via /bin/sh -c "command -v gtar". - try { - const out = execFileSync('/bin/sh', ['-c', 'command -v gtar'], { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - }).trim(); - if (out) return out; - } catch { - // not found - } - console.warn( - 'WARNING: GNU tar (gtar) not found on macOS. Falling back to bsdtar; ' + - 'archives will include com.apple.provenance pax headers that emit ' + - 'noisy warnings on Linux extract. Install with: brew install gnu-tar', - ); - return 'tar'; + run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']); } function createZipArchive(outputPath, cwd) { if (process.platform === 'win32') { - // Use [IO.Compression.ZipFile]::CreateFromDirectory rather than - // Compress-Archive: the latter writes Windows-style backslash - // separators into ZIP entry names, which then trip the .bat - // installer's path-traversal guard against backslashes. - // CreateFromDirectory writes spec-compliant forward slashes. run( 'powershell', [ @@ -549,7 +535,7 @@ function createZipArchive(outputPath, cwd) { '-ExecutionPolicy', 'Bypass', '-Command', - 'Add-Type -AssemblyName System.IO.Compression.FileSystem; if (Test-Path -LiteralPath $env:QWEN_OUTPUT_PATH) { Remove-Item -LiteralPath $env:QWEN_OUTPUT_PATH -Force }; [IO.Compression.ZipFile]::CreateFromDirectory($env:QWEN_PACKAGE_ROOT, $env:QWEN_OUTPUT_PATH, [IO.Compression.CompressionLevel]::Optimal, $true)', + 'Compress-Archive -LiteralPath $env:QWEN_PACKAGE_ROOT -DestinationPath $env:QWEN_OUTPUT_PATH -Force', ], { env: { @@ -565,31 +551,38 @@ function createZipArchive(outputPath, cwd) { run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); } -/** - * Rebuild SHA256SUMS from scratch by scanning outDir for standalone release - * archives. This overwrites any existing SHA256SUMS, so callers must ensure - * all desired archives are present in outDir before calling. - */ async function writeSha256Sums(outDir) { - const entries = fs.readdirSync(outDir).filter(isStandaloneArchiveName).sort(); + const entries = fs + .readdirSync(outDir) + .filter( + (entry) => + entry.startsWith('qwen-code-') && + (entry.endsWith('.tar.gz') || entry.endsWith('.zip')), + ) + .sort(); if (entries.length === 0) { fail( - `No standalone archive files found in ${outDir}; refusing to write empty SHA256SUMS.`, + `No qwen-code archives found in ${outDir}; refusing to write empty SHA256SUMS.`, ); } - const lines = await Promise.all( - entries.map(async (entry) => { - const filePath = path.join(outDir, entry); - const hash = await sha256File(filePath); - return `${hash} ${entry}`; - }), - ); + const lines = []; + for (const entry of entries) { + const filePath = path.join(outDir, entry); + const hash = await sha256File(filePath); + lines.push(`${hash} ${entry}`); + } fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + function run(command, args, options = {}) { try { execFileSync(command, args, { @@ -605,4 +598,8 @@ function run(command, args, options = {}) { } } -export { TARGETS, writeSha256Sums }; +function fail(message) { + throw new Error(`Error: ${message}`); +} + +export { writeSha256Sums }; diff --git a/scripts/release-asset-config.js b/scripts/release-asset-config.js deleted file mode 100644 index 86615a24e..000000000 --- a/scripts/release-asset-config.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -const STANDALONE_ARCHIVE_PREFIX = 'qwen-code-'; -// Keep this extension allowlist in sync with the standalone packager target -// output extensions and the release workflow upload globs. -const STANDALONE_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip']; - -function isStandaloneArchiveName(fileName) { - return ( - fileName.startsWith(STANDALONE_ARCHIVE_PREFIX) && - STANDALONE_ARCHIVE_EXTENSIONS.some((extension) => - fileName.endsWith(extension), - ) - ); -} - -export { isStandaloneArchiveName }; diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js deleted file mode 100644 index bf7d1027b..000000000 --- a/scripts/release-script-utils.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; -import { fileURLToPath } from 'node:url'; - -function isMainModule(importMetaUrl) { - const filename = fileURLToPath(importMetaUrl); - return process.argv[1] && path.resolve(process.argv[1]) === filename; -} - -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - -function parseCliArgs(argv, options, defaults = {}) { - const args = { ...defaults }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - - let key = arg; - let inlineValue; - if (arg.startsWith('--')) { - const equalsIndex = arg.indexOf('='); - if (equalsIndex > -1) { - key = arg.slice(0, equalsIndex); - inlineValue = arg.slice(equalsIndex + 1); - } - } - - const option = options[key]; - if (!option) { - fail(`Unknown option: ${arg}`); - } - - if (option.type === 'boolean') { - if (inlineValue !== undefined) { - fail(`${key} does not accept a value`); - } - args[option.name] = true; - continue; - } - - let value; - if (inlineValue !== undefined) { - if (inlineValue === '') { - fail(`${key} requires a value`); - } - value = inlineValue; - } else { - value = readOptionValue(argv, index, key); - index += 1; - } - if (option.validate) { - option.validate(value); - } - args[option.name] = value; - } - - return args; -} - -function parseSha256Sums(content) { - const checksums = new Map(); - for (const [index, line] of content.split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (!match) { - fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); - } - checksums.set(match[2], match[1].toLowerCase()); - } - return checksums; -} - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - -export { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - readOptionValue, - sha256File, -}; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 2867b4b03..72973f033 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -14,7 +14,6 @@ const { mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, symlinkSync, writeFileSync, @@ -28,27 +27,16 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.js'), ).href; -const standalonePackageScriptUrl = pathToFileURL( - path.resolve('scripts/create-standalone-package.js'), -).href; const hostedInstallationScriptUrl = pathToFileURL( path.resolve('scripts/build-hosted-installation-assets.js'), ).href; const installationReleaseVerificationScriptUrl = pathToFileURL( path.resolve('scripts/verify-installation-release.js'), ).href; -const releaseAssetConfigUrl = pathToFileURL( - path.resolve('scripts/release-asset-config.js'), -).href; -const releaseScriptUtilsUrl = pathToFileURL( - path.resolve('scripts/release-script-utils.js'), -).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; const itOnWindows = process.platform === 'win32' ? it : it.skip; -// Windows CI can spend several seconds inside PowerShell zip operations. -const WINDOWS_INSTALLER_TEST_TIMEOUT = 15_000; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { @@ -94,9 +82,6 @@ describe('installation scripts', () => { ); expect(script).toContain('validate_archive_contents()'); expect(script).toContain('Archive contains unsafe path'); - expect(script).toContain( - 'Archive contains unsafe path with control character', - ); expect(script).toContain('qwen-code-${target}'); expect(script).toContain('*.tar.xz)'); expect(script).toContain('METHOD="${METHOD:-detect}"'); @@ -116,14 +101,6 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code/node/bin/node'); expect(script).toContain('Archive contains symlinks; refusing to install'); expect(script).toContain('not a Qwen Code standalone install'); - expect(script).toContain('is_qwen_standalone_install_dir()'); - expect(script).toContain('Manifest format is produced by writeManifest'); - expect(script).toContain( - '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"', - ); - expect(script).toContain( - '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"', - ); expect(script).toContain( 'Return 2 only when a standalone archive is unavailable', ); @@ -145,13 +122,6 @@ describe('installation scripts', () => { expect(script).not.toContain('InstallNodeJSDirectly'); expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); - // Invoke-WebRequest is now used in :DownloadFile so the user sees a - // progress bar while the standalone tarball downloads. Net.WebClient is - // silent and was previously preferred, but the UX hit (the user thinks - // the install is hung on slow GitHub release CDN) outweighed the small - // PS5 startup overhead. The :UrlExists / :RaceMirrorHead helpers still - // use Net.WebRequest for HEAD probes since those are sub-second and - // benefit from the leaner cold-start path. expect(script).toContain('Invoke-WebRequest'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); @@ -188,17 +158,6 @@ describe('installation scripts', () => { expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); expect(script).toContain('qwen-code-win-x64.zip'); - expect(script).toContain(':ValidateArchiveContents'); - expect(script).toContain('Archive contains unsafe path entries'); - expect(script).toContain( - 'Archive could not be inspected before extraction', - ); - expect(script).toContain('if %PS_STATUS% EQU 1'); - expect(script).toContain('if %PS_STATUS% EQU 2'); - expect(script).toContain('System.IO.Compression.FileSystem'); - expect(script).toContain('[IO.Compression.ZipFile]::OpenRead'); - expect(script).toContain('[IO.Path]::GetRandomFileName()'); - expect(script).not.toContain('qwen-code-install-%RANDOM%%RANDOM%'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); @@ -215,13 +174,6 @@ describe('installation scripts', () => { expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); expect(script).toContain(':ValidateVersion'); - expect(script).not.toContain('^v*'); - expect(script).toContain('/C:"^v[0-9]'); - expect(script).toContain(':EnsureDir'); - expect(script).toContain('Failed to create directory'); - expect(script).toContain('ConvertFrom-Json'); - expect(script).toContain("$data.name -ne '@qwen-code/qwen-code'"); - expect(script).toContain("$data.target -notmatch '^win-(x64|arm64)$'"); expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); @@ -239,12 +191,8 @@ describe('installation scripts', () => { expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); - expect(script).toContain('ERROR: Unknown option.'); - expect(script).not.toContain('ERROR: Unknown option: %~1'); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); - expect(script).toContain('WARNING: Failed to restore previous install'); - expect(script).toContain('WARNING: Failed to remove failed install'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); }); @@ -266,9 +214,6 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['verify:installation-release']).toBe( 'node scripts/verify-installation-release.js', ); - // Per-release installer publishing was removed in favor of a stable hosted - // entrypoint with --version pinning, so no package:installation-assets - // script should exist. expect(packageJson.scripts['package:installation-assets']).toBeUndefined(); expect(existsSync('scripts/create-standalone-package.js')).toBe(true); expect(existsSync('scripts/build-standalone-release.js')).toBe(true); @@ -277,8 +222,6 @@ describe('standalone release packaging', () => { ); expect(existsSync('scripts/verify-installation-release.js')).toBe(true); expect(existsSync('scripts/build-installation-assets.js')).toBe(false); - expect(existsSync('scripts/release-asset-config.js')).toBe(true); - expect(existsSync('scripts/release-script-utils.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -286,36 +229,19 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('DIST_ALLOWED_ENTRIES'); expect(packageScript).toContain('Unexpected dist asset'); expect(packageScript).toContain('topLevelDistEntryForPath(outDir)'); - expect(packageScript).toContain("path.join(distDir, 'package.json')"); - expect(packageScript).toContain( - "fs.copyFileSync(packageJsonPath, path.join(packageRoot, 'package.json'))", - ); + expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); expect(packageScript).toContain('validateNodeRuntime'); expect(packageScript).toContain('copyNodeRuntimeEntry'); - expect(packageScript).toContain('TARGETS must stay in sync with'); expect(packageScript).toContain('symlink cycle'); expect(packageScript).toContain('refusing to write empty SHA256SUMS'); expect(packageScript).toContain('--skip-checksums'); expect(packageScript).toContain('dereference: true'); + expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); - expect(packageScript).toContain('Rebuild SHA256SUMS from scratch'); - expect(packageScript).toContain('Promise.all('); - expect(packageScript).toContain( - "import { isStandaloneArchiveName } from './release-asset-config.js';", - ); - expect(packageScript).toContain( - "import {\n fail,\n isMainModule,\n parseCliArgs,\n sha256File,\n} from './release-script-utils.js';", - ); - expect(packageScript).toContain( - 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', - ); - expect(packageScript).not.toContain('function parseArgs'); const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); - expect(releaseScript).toContain('normalizeNodeVersion('); - expect(releaseScript).toContain("version.replace(/^v/i, '')"); expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}'); expect(releaseScript).toContain('SHASUMS256.txt'); expect(releaseScript).toContain('verifyNodeArchive'); @@ -323,24 +249,12 @@ describe('standalone release packaging', () => { 'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length', ); expect(releaseScript).toContain('nodeArchiveExtension'); + expect(releaseScript).toContain('fs.createReadStream'); expect(releaseScript).toContain('expectedArchiveNames'); - expect(releaseScript).toContain('standaloneArchiveName(qwenTarget)'); - expect(releaseScript).toContain('TARGETS.get(qwenTarget)'); - expect(releaseScript).toContain( - 'RELEASE_TARGETS must stay in sync with TARGETS', - ); - expect(releaseScript).toContain('cleanOutputDirectory(outDir)'); + expect(releaseScript).toContain('qwen-code-${qwenTarget}'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); - expect(releaseScript).toContain('Promise.allSettled('); - expect(releaseScript).toContain( - "import { isStandaloneArchiveName } from './release-asset-config.js';", - ); - expect(releaseScript).toContain( - 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', - ); - expect(releaseScript).not.toContain('function parseArgs'); const hostedInstallScript = readScript( 'scripts/build-hosted-installation-assets.js', @@ -348,6 +262,8 @@ describe('standalone release packaging', () => { expect(hostedInstallScript).toContain('Copyright 2025 Qwen Team'); expect(hostedInstallScript).toContain('buildHostedInstallationAssets'); expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); + expect(hostedInstallScript).toContain("output: 'install-qwen.sh'"); + expect(hostedInstallScript).toContain("output: 'install-qwen.bat'"); expect(hostedInstallScript).not.toContain("output: 'install'"); const releaseVerifyScript = readScript( @@ -358,81 +274,8 @@ describe('standalone release packaging', () => { expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl'); expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES'); expect(releaseVerifyScript).toContain('EXPECTED_STANDALONE_ARCHIVE_NAMES'); - // The verifier targets only standalone archives + SHA256SUMS; hosted - // installer scripts have their own staging path and are intentionally - // not part of the GitHub release surface. Asserting absence of the - // alias / installer-asset *helper functions* is enough — comments may - // legitimately reference the hosted filenames as context. expect(releaseVerifyScript).not.toContain('INSTALLATION_ASSET_NAMES'); - expect(releaseVerifyScript).not.toContain('isReleaseChecksumAsset'); expect(releaseVerifyScript).not.toContain('assertInstallAliasMatches'); - expect(releaseVerifyScript).not.toContain('assertInstallAliasBuffersMatch'); - expect(releaseVerifyScript).not.toContain('assertUnixInstallersExecutable'); - - const releaseAssetConfig = readScript('scripts/release-asset-config.js'); - expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); - expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); - // Per-release installer publishing was removed; the config no longer - // exports installer-asset helpers. - expect(releaseAssetConfig).not.toContain('INSTALLATION_ASSETS'); - expect(releaseAssetConfig).not.toContain('isInstallationAssetName'); - expect(releaseAssetConfig).not.toContain('isReleaseChecksumAsset'); - - const releaseScriptUtils = readScript('scripts/release-script-utils.js'); - expect(releaseScriptUtils).toContain('Copyright 2025 Qwen Team'); - expect(releaseScriptUtils).toContain('function parseCliArgs'); - expect(releaseScriptUtils).toContain('function parseSha256Sums'); - expect(releaseScriptUtils).toContain('async function sha256File'); - expect(releaseScriptUtils).toContain('function readOptionValue'); - expect(releaseScriptUtils).toContain('function isMainModule'); - }); - - it('parses release script CLI options through the shared helper', async () => { - const { parseCliArgs } = await import(releaseScriptUtilsUrl); - - const args = parseCliArgs( - ['--name', 'qwen', '--flag', '-h'], - { - '--name': { name: 'name' }, - '--flag': { name: 'flag', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - }, - { flag: false, help: false, name: undefined }, - ); - - expect(args).toEqual({ - flag: true, - help: true, - name: 'qwen', - }); - expect(() => parseCliArgs(['--unknown'], {}, {})).toThrow( - /Unknown option: --unknown/, - ); - expect(() => - parseCliArgs(['--name'], { '--name': { name: 'name' } }, {}), - ).toThrow(/--name requires a value/); - - const equalsArgs = parseCliArgs( - ['--name=qwen', '--flag'], - { - '--name': { name: 'name' }, - '--flag': { name: 'flag', type: 'boolean' }, - }, - { flag: false, name: undefined }, - ); - expect(equalsArgs).toEqual({ flag: true, name: 'qwen' }); - - expect(() => - parseCliArgs( - ['--flag=true'], - { '--flag': { name: 'flag', type: 'boolean' } }, - {}, - ), - ).toThrow(/--flag does not accept a value/); - - expect(() => - parseCliArgs(['--name='], { '--name': { name: 'name' } }, {}), - ).toThrow(/--name requires a value/); }); it('loads the standalone release packaging helper', () => { @@ -446,34 +289,23 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); - it('normalizes Node.js versions passed to the release helper', async () => { - const { normalizeNodeVersion } = await import(standaloneReleaseScriptUrl); - - expect(normalizeNodeVersion('v22.0.0')).toBe('22.0.0'); - expect(normalizeNodeVersion('22.0.0')).toBe('22.0.0'); - }); - - it('loads the hosted installation asset staging helper', () => { - const output = execFileSync( + it('loads the hosted installation release helpers', () => { + const hostedOutput = execFileSync( process.execPath, ['scripts/build-hosted-installation-assets.js', '--help'], { encoding: 'utf8' }, ); - - expect(output).toContain('package:hosted-installation'); - expect(output).toContain('--out-dir PATH'); - }); - - it('loads the installation release verification helper', () => { - const output = execFileSync( + const verifierOutput = execFileSync( process.execPath, ['scripts/verify-installation-release.js', '--help'], { encoding: 'utf8' }, ); - expect(output).toContain('verify:installation-release'); - expect(output).toContain('--dir PATH'); - expect(output).toContain('--base-url URL'); + expect(hostedOutput).toContain('package:hosted-installation'); + expect(hostedOutput).toContain('--out-dir PATH'); + expect(verifierOutput).toContain('verify:installation-release'); + expect(verifierOutput).toContain('--dir PATH'); + expect(verifierOutput).toContain('--base-url URL'); }); it('rejects invalid installation release verification CLI arguments', () => { @@ -487,6 +319,7 @@ describe('standalone release packaging', () => { } catch (error) { caughtError = error; } + expect(caughtError).toBeTruthy(); expect( [ @@ -517,20 +350,6 @@ describe('standalone release packaging', () => { ); }); - it('exposes only standalone archive classification', async () => { - const config = await import(releaseAssetConfigUrl); - - expect(config.isStandaloneArchiveName('qwen-code-linux-x64.tar.gz')).toBe( - true, - ); - expect(config.isStandaloneArchiveName('qwen-code-win-x64.zip')).toBe(true); - expect(config.isStandaloneArchiveName('install-qwen.sh')).toBe(false); - // Per-release installer publishing helpers must no longer be exported. - expect(config.INSTALLATION_ASSET_NAMES).toBeUndefined(); - expect(config.isInstallationAssetName).toBeUndefined(); - expect(config.isReleaseChecksumAsset).toBeUndefined(); - }); - it('parses Node.js SHASUMS entries', async () => { const { parseChecksums } = await import(standaloneReleaseScriptUrl); @@ -546,30 +365,16 @@ describe('standalone release packaging', () => { expect(checksums.get('node-v22.0.0-win-x64.zip')).toBe('b'.repeat(64)); }); - it('rejects malformed SHA256SUMS entries', async () => { - const { parseSha256Sums } = await import(releaseScriptUtilsUrl); - - expect(() => - parseSha256Sums( - [ - `${'a'.repeat(64)} qwen-code-linux-x64.tar.gz`, - `${'b'.repeat(63)} qwen-code-win-x64.zip`, - ].join('\n'), - ), - ).toThrow(/Malformed SHA256SUMS line 2/); - }); - it('validates standalone release checksum output', async () => { const { assertStandaloneOutput, RELEASE_TARGETS } = await import( standaloneReleaseScriptUrl ); - const { TARGETS } = await import(standalonePackageScriptUrl); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-')); try { const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { - const extension = TARGETS.get(qwenTarget).outputExtension; - return `${'A'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; + const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; + return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); @@ -586,11 +391,6 @@ describe('standalone release packaging', () => { }); it('installer scripts honor --version for hosted entrypoints', () => { - // The hosted entrypoint flow relies on the installer scripts accepting a - // --version flag (and QWEN_INSTALL_VERSION env var) so that - // curl URL | bash -s -- --version vX.Y.Z - // and the equivalent Windows incantation can pin a specific standalone - // release without per-release installer assets. const installShellSource = readScript( 'scripts/installation/install-qwen.sh', ); @@ -615,8 +415,6 @@ describe('standalone release packaging', () => { const { HOSTED_INSTALLATION_ASSET_NAMES, HOSTED_INSTALLATION_ASSETS, - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS, - HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, } = await import(hostedInstallationScriptUrl); @@ -637,32 +435,6 @@ describe('standalone release packaging', () => { expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, ); - expect(HOSTED_INSTALLER_REQUIRED_FRAGMENTS).toEqual([ - '--version', - 'QWEN_INSTALL_VERSION', - ]); - // The default-version regex pins `latest` semantically rather than as a - // loose substring, so a stray `latest` in a comment cannot satisfy it. - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.sh'].test( - 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', - ), - ).toBe(true); - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.sh'].test( - '# defaults to latest', - ), - ).toBe(false); - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.bat'].test( - 'set "VERSION=latest"', - ), - ).toBe(true); - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.bat'].test( - 'rem defaults to latest', - ), - ).toBe(false); expect(readScript(installSh)).toBe( readScript('scripts/installation/install-qwen.sh'), ); @@ -674,12 +446,12 @@ describe('standalone release packaging', () => { ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); expect(existsSync(path.join(tmpDir, 'install-qwen.ps1'))).toBe(false); - const checksumNames = checksumLines.map((line) => line.split(' ')[1]); - expect(checksumNames).toEqual([...checksumNames].sort()); + expect(checksumLines.map((line) => line.split(' ')[1])).toEqual([ + 'install-qwen.bat', + 'install-qwen.sh', + ]); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); - expect(checksums).not.toMatch(/ {2}install-qwen\.ps1$/m); - expect(checksums).not.toMatch(/ {2}install$/m); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); } @@ -693,7 +465,7 @@ describe('standalone release packaging', () => { } }); - it('rejects hosted installer sources missing pinned install behavior', async () => { + it('rejects hosted installer sources without pinned hosted behavior', async () => { const { buildHostedInstallationAssets } = await import( hostedInstallationScriptUrl ); @@ -703,49 +475,15 @@ describe('standalone release packaging', () => { try { mkdirSync(sourceDir, { recursive: true }); - writeFileSync( - path.join(sourceDir, 'install-qwen.sh'), - '#!/usr/bin/env bash\nVERSION="${QWEN_INSTALL_VERSION:-latest}"\n', - ); - writeFileSync( - path.join(sourceDir, 'install-qwen.bat'), - '@echo off\r\nset "VERSION=latest"\r\n', - ); - - await expect( - buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), - ).rejects.toThrow( - /install-qwen\.sh is missing hosted installer behavior: --version/, - ); - } finally { - rmSync(tmpRoot, { recursive: true, force: true }); - rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('rejects hosted installer sources whose default version is not latest', async () => { - const { buildHostedInstallationAssets } = await import( - hostedInstallationScriptUrl - ); - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); - const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); - - try { - mkdirSync(sourceDir, { recursive: true }); - // Both fragments are present, but the default version was changed to - // something other than `latest`. The default-version pattern guard - // catches this, even though loose substring matching would not. writeFileSync( path.join(sourceDir, 'install-qwen.sh'), '#!/usr/bin/env bash\n' + - '# Defaults to latest unless --version is passed.\n' + 'VERSION="${QWEN_INSTALL_VERSION:-stable}"\n' + 'case "$1" in --version) shift; VERSION="$1" ;; esac\n', ); writeFileSync( path.join(sourceDir, 'install-qwen.bat'), - '@echo off\r\nset "VERSION=stable"\r\n', + '@echo off\r\nset "VERSION=latest"\r\n', ); await expect( @@ -785,7 +523,6 @@ describe('standalone release packaging', () => { writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); await expect(verifyReleaseDirectory(tmpDir)).resolves.not.toThrow(); - // Tampering an archive must be caught by the per-asset hash check. appendFileSync( path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]), 'tamper', @@ -820,44 +557,6 @@ describe('standalone release packaging', () => { await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, ); - - writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); - writeFileSync(path.join(tmpDir, 'qwen-code-stale.tar.gz'), 'stale'); - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /Unexpected release asset: qwen-code-stale\.tar\.gz/, - ); - rmSync(path.join(tmpDir, 'qwen-code-stale.tar.gz'), { force: true }); - - writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); - writeFileSync(path.join(tmpDir, 'payload.bin'), 'unexpected'); - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /Unexpected release asset: payload\.bin/, - ); - rmSync(path.join(tmpDir, 'payload.bin'), { force: true }); - - writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); - appendFileSync( - path.join(tmpDir, 'SHA256SUMS'), - `${'c'.repeat(64)} payload.bin\n`, - ); - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /Unexpected release asset checksum: payload\.bin/, - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('rejects a release directory without SHA256SUMS', async () => { - const { verifyReleaseDirectory } = await import( - installationReleaseVerificationScriptUrl - ); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); - - try { - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /SHA256SUMS was not found at /, - ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -884,9 +583,6 @@ describe('standalone release packaging', () => { }, }), ).resolves.not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('checks URL reachability only'), - ); } finally { warnSpy.mockRestore(); } @@ -901,130 +597,63 @@ describe('standalone release packaging', () => { 'HEAD', ]); } - // Hosted installer scripts must not be fetched: the verifier targets - // GitHub release assets only. for (const [url] of fetchedUrls) { expect(url).not.toMatch(/install-qwen\.(sh|bat)$/); expect(url).not.toMatch(/\/install$/); } }); - it('falls back to ranged GET when remote HEAD is unavailable', async () => { - const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = - await import(installationReleaseVerificationScriptUrl); - const checksumContent = placeholderChecksumContent( - EXPECTED_STANDALONE_ARCHIVE_NAMES, - ); - const observedMethods = []; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url, options = {}) => { - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - const method = options.method || 'GET'; - observedMethods.push(method); - if (method === 'HEAD') { - return new Response(null, { status: 405 }); - } - // Ranged GET fallback succeeds. - return new Response(null, { status: 206 }); - }, - }), - ).resolves.not.toThrow(); - } finally { - warnSpy.mockRestore(); - } - - expect(observedMethods).toContain('HEAD'); - expect(observedMethods).toContain('GET'); - }); - - it('rejects a release base URL with no archives reachable', async () => { - const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = - await import(installationReleaseVerificationScriptUrl); - const checksumContent = placeholderChecksumContent( - EXPECTED_STANDALONE_ARCHIVE_NAMES, - ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url) => { - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - return new Response(null, { status: 404 }); - }, - }), - ).rejects.toThrow(/All 5 release asset URLs are unavailable/); - } finally { - warnSpy.mockRestore(); - } - }); - it('rejects a release base URL that is not https', async () => { const { verifyReleaseBaseUrl } = await import( installationReleaseVerificationScriptUrl ); - // file:// must be rejected as a URL the verifier cannot reach safely. await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( /--base-url must use https/, ); - - // Plain http must also be rejected even though it is technically a valid - // URL — release URLs are always HTTPS, and accepting http would let an - // operator silently target a stale or attacker-controlled mirror. await expect( verifyReleaseBaseUrl('http://example.com/release/'), ).rejects.toThrow(/--base-url must use https/); }); - it( - 'rejects a runtime archive without a Node executable', - () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + it('rejects a runtime archive without a Node executable', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); - try { - const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; - const fakeRuntimeArchive = - process.platform === 'win32' - ? createBadWindowsNodeArchive(tmpDir) - : createBadUnixNodeArchive(tmpDir); + try { + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createBadWindowsNodeArchive(tmpDir) + : createBadUnixNodeArchive(tmpDir); - expect(() => - execFileSync( - 'node', - [ - 'scripts/create-standalone-package.js', - '--target', - target, - '--node-archive', - fakeRuntimeArchive, - '--out-dir', - path.join(tmpDir, 'out'), - '--version', - '0.0.0-test', - ], - { stdio: 'pipe' }, - ), - ).toThrow(/Node\.js runtime for .* must contain/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + target, + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Node\.js runtime for .* must contain/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); + } + }); it('packages a win-x64 standalone archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1057,24 +686,19 @@ describe('standalone release packaging', () => { expect( existsSync(path.join(extractDir, 'qwen-code', 'node', 'node.exe')), ).toBe(true); - const packagedPackageJson = JSON.parse( - readScript(path.join(extractDir, 'qwen-code', 'package.json')), - ); - expect(packagedPackageJson).toEqual({ - name: '@qwen-code/qwen-code', - version: '0.0.0', - }); expect(readScript(path.join(outDir, 'SHA256SUMS'))).toContain( 'qwen-code-win-x64.zip', ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }, 30_000); itOnUnix('dereferences safe Node.js runtime symlinks', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1096,12 +720,14 @@ describe('standalone release packaging', () => { expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects Node.js runtime symlinks that escape the archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1126,12 +752,14 @@ describe('standalone release packaging', () => { ).toThrow(/symlink escapes the archive/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects Node.js runtime symlink cycles', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1156,12 +784,14 @@ describe('standalone release packaging', () => { ).toThrow(/symlink cycle/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); it('rejects unexpected dist assets', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1186,55 +816,18 @@ describe('standalone release packaging', () => { ).toThrow(/Unexpected dist asset/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } else { + rmSync('dist/debug-cache.tmp', { force: true }); + } } }); - it('ignores non-runtime esbuild metadata in dist', () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); - - try { - const outDir = path.join(tmpDir, 'out'); - writeFileSync('dist/esbuild.json', '{}\n'); - - execFileSync( - 'node', - [ - 'scripts/create-standalone-package.js', - '--target', - 'win-x64', - '--node-archive', - createFakeWindowsNodeArchive(tmpDir), - '--out-dir', - outDir, - '--version', - '0.0.0-test', - ], - { stdio: 'pipe' }, - ); - - const archive = path.join(outDir, 'qwen-code-win-x64.zip'); - const extractDir = path.join(tmpDir, 'extract'); - mkdirSync(extractDir, { recursive: true }); - extractZipForTest(archive, extractDir); - - expect( - existsSync(path.join(extractDir, 'qwen-code', 'lib', 'esbuild.json')), - ).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); - } - }, 30_000); - it('uploads standalone archives during release', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); - // Per-release installer publishing was removed in favor of a stable hosted - // entrypoint, so the release workflow no longer builds or uploads installer - // scripts as release assets. expect(workflow).not.toContain('package:installation-assets'); expect(workflow).not.toContain('install-qwen.sh'); expect(workflow).not.toContain('install-qwen.bat'); @@ -1243,15 +836,9 @@ describe('standalone release packaging', () => { expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); expect(workflow).toContain('dist/standalone/qwen-code-*.zip'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); - // The verify step must run after the build step so a broken release - // directory is caught before publishing. expect(workflow).toContain( 'npm run verify:installation-release -- --dir dist/standalone', ); - const buildIndex = workflow.indexOf('npm run package:standalone:release'); - const verifyIndex = workflow.indexOf('npm run verify:installation-release'); - expect(buildIndex).toBeGreaterThan(-1); - expect(verifyIndex).toBeGreaterThan(buildIndex); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -1269,9 +856,6 @@ describe('standalone release packaging', () => { expect(guide).toContain('installation/install-qwen.sh'); expect(guide).toContain('installation/install-qwen.bat'); expect(guide).toContain('release operators must sync these staged files'); - // The hosted-endpoint status callout must keep flagging the transition - // window so users do not assume the documented --version flow works - // before the next OSS sync. expect(guide).toContain('Hosted endpoint status'); expect(guide).toContain('legacy NVM-based installer'); expect(guide).toContain('node-pty'); @@ -1283,7 +867,7 @@ describe('Linux/macOS installer end-to-end', () => { itOnUnix( 'installs a local standalone archive with checksum verification', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1310,13 +894,15 @@ describe('Linux/macOS installer end-to-end', () => { expect(version).toBe('0.0.0-smoke'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }, ); itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1346,12 +932,14 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects a tampered local archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1367,12 +955,14 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/Checksum verification failed/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects a local archive when SHA256SUMS is missing', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1388,7 +978,9 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/SHA256SUMS not found/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); @@ -1432,7 +1024,7 @@ describe('Linux/macOS installer end-to-end', () => { ); itOnUnix('refuses to overwrite a non-managed install directory', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1450,42 +1042,14 @@ describe('Linux/macOS installer end-to-end', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); - itOnUnix( - 'refuses to overwrite a directory with an unrelated manifest', - () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = packageFakeStandalone(tmpDir); - const installRoot = path.join(tmpDir, 'install'); - const installDir = path.join(installRoot, 'lib', 'qwen-code'); - mkdirSync(installDir, { recursive: true }); - writeFileSync( - path.join(installDir, 'manifest.json'), - JSON.stringify({ name: 'other-app', target: 'linux-x64' }), - ); - writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); - - expect(() => - runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), - ).toThrow(/not a Qwen Code standalone install/); - expect(readScript(path.join(installDir, 'important.txt'))).toBe( - 'keep me\n', - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); - } - }, - ); - itOnUnix('does not fall back to npm when detect finds a bad archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1509,7 +1073,9 @@ describe('Linux/macOS installer end-to-end', () => { expect(failureMessage).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); @@ -1680,208 +1246,55 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }, - WINDOWS_INSTALLER_TEST_TIMEOUT, ); - itOnWindows( - 'rejects a tampered local archive', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows('rejects a tampered local archive', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - appendFileSync(archive, 'tamper'); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + appendFileSync(archive, 'tamper'); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/Checksum verification failed/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); - itOnWindows( - 'rejects a local archive when SHA256SUMS is missing', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows('rejects unsafe environment-derived install paths', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const marker = path.join(tmpDir, 'pwned.txt'); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/SHA256SUMS not found; cannot verify archive/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects a local archive missing required entries', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir, { - includeNode: false, - }); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/qwen-code\\node\\node.exe/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects standalone archives containing path traversal entries', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createWindowsTraversalStandaloneArchive(tmpDir); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/Archive contains unsafe path/); - expect(existsSync(path.join(tmpDir, 'qwen-slip'))).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'refuses to overwrite a directory with an unrelated manifest', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - const installRoot = path.join(tmpDir, 'install'); - const installDir = path.join(installRoot, 'qwen-code'); - mkdirSync(installDir, { recursive: true }); - writeFileSync( - path.join(installDir, 'manifest.json'), - JSON.stringify({ name: 'other-app', target: 'win-x64' }), - ); - writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); - - expect(() => - runWindowsInstaller(archive, installRoot, path.join(tmpDir, 'home')), - ).toThrow(/not a Qwen Code standalone install/); - expect(readScript(path.join(installDir, 'important.txt'))).toBe( - 'keep me\n', - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects unsafe environment-derived install paths', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - const marker = path.join(tmpDir, 'pwned.txt'); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - 'standalone', - { - QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, - }, - ), - ).toThrow(/unsafe command characters/); - expect(existsSync(marker)).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'standalone', + { + QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, + }, + ), + ).toThrow(/unsafe command characters/); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); -// Tracks pending dist/ backups so a crashed test cannot leave the working tree -// without dist/. process.on('exit') runs synchronous handlers, which is enough -// for renameSync; SIGINT/SIGTERM force re-entry through 'exit'. -const pendingDistBackups = new Set(); -let distBackupHandlersRegistered = false; - -function registerDistBackupSafetyNet() { - if (distBackupHandlersRegistered) { - return; - } - distBackupHandlersRegistered = true; - - const drain = () => { - for (const restore of pendingDistBackups) { - try { - restore(); - } catch { - // best-effort restore; nothing we can do at exit - } - } - pendingDistBackups.clear(); - }; - - process.on('exit', drain); - for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { - process.on(signal, () => { - drain(); - process.exit(1); - }); - } -} - function ensureMinimalDist() { - registerDistBackupSafetyNet(); - - const distPath = path.resolve('dist'); - // Backup root must live on the same volume as dist/ so that renameSync - // is atomic. On Windows GitHub runners the workspace lives on D: while - // os.tmpdir() returns a path on C:; renaming across drives raises - // EXDEV. Keeping the backup as a sibling of dist/ avoids that. - const backupRoot = mkdtempSync( - path.join(path.dirname(distPath), '.qwen-dist-backup-'), - ); - const backupDist = path.join(backupRoot, 'dist'); - const hadExistingDist = existsSync(distPath); - - if (hadExistingDist) { - renameSync(distPath, backupDist); + if (existsSync('dist')) { + return false; } mkdirSync('dist/vendor', { recursive: true }); @@ -1891,23 +1304,7 @@ function ensureMinimalDist() { 'dist/package.json', JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - - let restored = false; - const restore = () => { - if (restored) { - return; - } - restored = true; - pendingDistBackups.delete(restore); - rmSync(distPath, { recursive: true, force: true }); - if (hadExistingDist) { - renameSync(backupDist, distPath); - } - rmSync(backupRoot, { recursive: true, force: true }); - }; - - pendingDistBackups.add(restore); - return restore; + return true; } function createFakeNodeArchive(tmpDir, options = {}) { @@ -1980,11 +1377,7 @@ function createFakeWindowsNodeArchive(tmpDir) { return archive; } -function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { - const { - includeNode = true, - manifest = { name: '@qwen-code/qwen-code', target: 'win-x64' }, - } = options; +function createFakeWindowsStandaloneArchive(tmpDir) { const packageRoot = path.join(tmpDir, 'qwen-code'); const outDir = path.join(tmpDir, 'out'); mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); @@ -1995,15 +1388,10 @@ function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { path.join(packageRoot, 'bin', 'qwen.cmd'), ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), ); - if (includeNode) { - writeFileSync( - path.join(packageRoot, 'node', 'node.exe'), - 'fake node.exe\n', - ); - } + writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n'); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify(manifest), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'win-x64' }), ); const archive = path.join(outDir, 'qwen-code-win-x64.zip'); @@ -2012,62 +1400,8 @@ function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { return archive; } -function createWindowsTraversalStandaloneArchive(tmpDir) { - const outDir = path.join(tmpDir, 'out'); - mkdirSync(outDir, { recursive: true }); - - const archive = path.join(outDir, 'qwen-code-win-x64.zip'); - // PowerShell's `-Command` parser is fragile for multi-line scripts that - // include function definitions and quoted entry names. Joining with - // `; ` produces lines like `function f() {; ...; }; }` that older - // PowerShell versions reject. Write the script to a .ps1 file and run - // `-File` instead, which uses the same parser as a real script. - const scriptPath = path.join(tmpDir, 'create-traversal-archive.ps1'); - writeFileSync( - scriptPath, - [ - "$ErrorActionPreference = 'Stop'", - 'Add-Type -AssemblyName System.IO.Compression', - 'Add-Type -AssemblyName System.IO.Compression.FileSystem', - 'function Add-ZipEntry($zip, $name, $content) {', - ' $entry = $zip.CreateEntry($name)', - ' $writer = [System.IO.StreamWriter]::new($entry.Open())', - ' try { $writer.Write($content) } finally { $writer.Dispose() }', - '}', - 'if (Test-Path -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE) { Remove-Item -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -Force }', - '$zip = [System.IO.Compression.ZipFile]::Open($env:QWEN_TEST_ZIP_ARCHIVE, [System.IO.Compression.ZipArchiveMode]::Create)', - 'try {', - " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", - ' Add-ZipEntry $zip \'qwen-code/bin/qwen.cmd\' "@echo off`r`necho 0.0.0-smoke`r`n"', - " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", - ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code","target":"win-x64"}\'', - '} finally { $zip.Dispose() }', - '', - ].join('\r\n'), - ); - - execFileSync( - 'powershell', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], - { - env: { - ...process.env, - QWEN_TEST_ZIP_ARCHIVE: archive, - }, - stdio: 'pipe', - }, - ); - writeChecksumFile(outDir, path.basename(archive)); - return archive; -} - function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { - // Mirror create-standalone-package.js: use CreateFromDirectory so - // entry names use forward slashes and match what the production - // builder ships. Compress-Archive would write backslashes, which - // the .bat installer's ValidateArchiveContents normalizes but the - // production archive shouldn't depend on that leniency. execFileSync( 'powershell', [ @@ -2075,7 +1409,7 @@ function createZipForTest(archive, cwd, entry) { '-ExecutionPolicy', 'Bypass', '-Command', - 'Add-Type -AssemblyName System.IO.Compression.FileSystem; if (Test-Path -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE) { Remove-Item -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -Force }; [IO.Compression.ZipFile]::CreateFromDirectory($env:QWEN_TEST_ZIP_ENTRY, $env:QWEN_TEST_ZIP_ARCHIVE, [IO.Compression.CompressionLevel]::Optimal, $true)', + 'Compress-Archive -LiteralPath $env:QWEN_TEST_ZIP_ENTRY -DestinationPath $env:QWEN_TEST_ZIP_ARCHIVE -Force', ], { env: { @@ -2247,7 +1581,7 @@ function createSymlinkStandaloneArchive(tmpDir) { chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), ); const outDir = path.join(tmpDir, 'out'); @@ -2282,7 +1616,7 @@ function createTraversalStandaloneArchive(tmpDir) { chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), ); writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n'); @@ -2306,9 +1640,6 @@ function writeChecksumFile(outDir, archiveName) { writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); } -// Writes a synthetic standalone release directory: each archive name in -// `archiveNames` becomes a small file whose content equals the asset name, -// and SHA256SUMS is regenerated to match. function writeStandaloneReleaseAssets(outDir, archiveNames) { mkdirSync(outDir, { recursive: true }); for (const assetName of archiveNames) { @@ -2320,8 +1651,6 @@ function writeStandaloneReleaseAssets(outDir, archiveNames) { function writeStandaloneReleaseChecksums(outDir, archiveNames) { const lines = archiveNames.map((assetName) => { const filePath = path.join(outDir, assetName); - // Allow callers to list a not-yet-written archive name (e.g. an - // "unexpected extra" entry) without requiring the file to exist. const hash = existsSync(filePath) ? crypto.createHash('sha256').update(readFileSync(filePath)).digest('hex') : 'a'.repeat(64); @@ -2330,10 +1659,6 @@ function writeStandaloneReleaseChecksums(outDir, archiveNames) { writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } -// Generates a SHA256SUMS-formatted string for the given archive names. The -// hash values are placeholders — the remote verifier (verifyReleaseBaseUrl) -// only checks that SHA256SUMS lists the expected entries and that each -// archive URL is reachable; it does not download archives or compare hashes. function placeholderChecksumContent(archiveNames) { return `${archiveNames .map( diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index f3536a8f7..e84f75ca5 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -6,30 +6,23 @@ * SPDX-License-Identifier: Apache-2.0 */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { - RELEASE_TARGETS, - standaloneArchiveName, -} from './build-standalone-release.js'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -// This import-time computation intentionally asserts release/package target -// consistency via standaloneArchiveName(); keep RELEASE_TARGETS backed by TARGETS. -const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( - ({ qwenTarget }) => standaloneArchiveName(qwenTarget), -); +const EXPECTED_STANDALONE_ARCHIVE_NAMES = [ + 'qwen-code-darwin-arm64.tar.gz', + 'qwen-code-darwin-x64.tar.gz', + 'qwen-code-linux-arm64.tar.gz', + 'qwen-code-linux-x64.tar.gz', + 'qwen-code-win-x64.zip', +]; // Release artifacts that the installer chain expects in a GitHub Release. // Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served // from a separate hosted endpoint and are @@ -40,13 +33,6 @@ const EXPECTED_RELEASE_ASSET_NAMES = [ 'SHA256SUMS', ]; -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--dir': { name: 'dir' }, - '--base-url': { name: 'baseUrl' }, -}; - if (isMainModule(import.meta.url)) { try { await main(); @@ -57,11 +43,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - dir: undefined, - baseUrl: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); return; @@ -94,6 +76,36 @@ Options: `); } +function parseArgs(argv) { + const args = { + help: false, + dir: undefined, + baseUrl: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--dir': + args.dir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--base-url': + args.baseUrl = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + async function verifyReleaseDirectory(dir) { const checksums = readReleaseChecksums(dir); assertExpectedChecksumEntries(checksums); @@ -172,25 +184,20 @@ function assertExpectedArchiveFiles(dir) { } async function assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl) { - const results = await Promise.allSettled( - EXPECTED_STANDALONE_ARCHIVE_NAMES.map(async (assetName) => { + const failures = []; + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + try { await assertRemoteAssetAvailable( new URL(assetName, normalizedBaseUrl).toString(), fetchImpl, ); - return assetName; - }), - ); - const failures = results.flatMap((result, index) => - result.status === 'rejected' - ? [ - { - assetName: EXPECTED_STANDALONE_ARCHIVE_NAMES[index], - reason: formatErrorReason(result.reason), - }, - ] - : [], - ); + } catch (reason) { + failures.push({ + assetName, + reason: formatErrorReason(reason), + }); + } + } if (failures.length === 0) { return; @@ -265,6 +272,46 @@ function normalizeHttpsBaseUrl(baseUrl) { return parsed.toString(); } +function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const [index, line] of content.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + export { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl,