qwen-code/scripts/verify-installation-release.js
yiliang114 89b272dc15 test(installer): add parseUploadArgs unit tests and align verify derivation
- scripts/tests/upload-aliyun-oss-assets.test.js: cover --help short-circuit,
  required-option validation (--bucket/--config/--prefix/empty assets),
  unknown options, missing option values, and trailing-slash prefix
  normalization.
- scripts/verify-installation-release.js: switch the win-only zip branch
  from `startsWith('win-')` to the strict `=== 'win-x64'` check used by
  build-standalone-release.js, and add a comment recording that the two
  derivations must stay aligned. Without this the helpers would diverge
  the moment a non-x64 win target gets added.
2026-05-17 23:44:20 +08:00

304 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
/**
* @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 { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { RELEASE_TARGETS } from './build-standalone-release.js';
import {
fail,
isMainModule,
parseArgs,
parseSha256Sums,
sha256File,
} from './release-script-utils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const EXPECTED_STANDALONE_ARCHIVE_NAMES =
standaloneArchiveNamesFromReleaseTargets(RELEASE_TARGETS);
// Release artifacts that the installer chain expects in a GitHub Release.
// Hosted installer scripts are served from a separate endpoint and are
// intentionally not part of this set; they have their own staging path in
// `package:hosted-installation`.
const EXPECTED_RELEASE_ASSET_NAMES = [
...EXPECTED_STANDALONE_ARCHIVE_NAMES,
'SHA256SUMS',
];
const REMOTE_FETCH_TIMEOUT_MS = 30_000;
// Mirrors `build-standalone-release.js`'s archive-name derivation. The two
// must stay aligned: any new platform/extension landing in RELEASE_TARGETS
// has to be reflected here (and there) before a new target ships, otherwise
// the verify and the build will disagree on expected filenames.
function standaloneArchiveNamesFromReleaseTargets(releaseTargets) {
return releaseTargets.map(
({ qwenTarget }) =>
`qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`,
);
}
const ARG_DEFS = {
'--dir': { key: 'dir', type: 'value' },
'--base-url': { key: 'baseUrl', type: 'value' },
'--list-release-asset-paths': {
key: 'listReleaseAssetPaths',
type: 'flag',
},
};
if (isMainModule(import.meta.url)) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), ARG_DEFS);
if (args.help) {
printUsage();
return;
}
if (args.dir && args.baseUrl) {
fail('Pass --dir or --base-url, not both.');
}
if (args.listReleaseAssetPaths && args.baseUrl) {
fail('Pass --list-release-asset-paths with --dir, not --base-url.');
}
if (args.listReleaseAssetPaths) {
const dir = path.resolve(
args.dir || path.join(rootDir, 'dist', 'standalone'),
);
await verifyReleaseDirectory(dir, { silent: true });
for (const assetPath of releaseAssetPaths(dir)) {
console.log(assetPath);
}
return;
}
if (args.baseUrl) {
await verifyReleaseBaseUrl(args.baseUrl);
return;
}
await verifyReleaseDirectory(
path.resolve(args.dir || path.join(rootDir, 'dist', 'standalone')),
);
}
function printUsage() {
console.log(`Usage: npm run verify:installation-release -- [options]
Verifies that an installation release directory contains the expected standalone
archives with matching SHA256SUMS entries. For a release URL, downloads
SHA256SUMS and the expected archives, then verifies each archive hash.
Options:
--dir PATH Verify a local release directory. Defaults to dist/standalone.
--base-url URL Verify a remote release URL (e.g. a GitHub release download
prefix). Cannot be combined with --dir.
--list-release-asset-paths
Verify --dir, then print explicit asset paths for upload.
-h, --help Show this help message.
`);
}
async function verifyReleaseDirectory(dir, options = {}) {
const { silent = false } = options;
const checksums = readReleaseChecksums(dir);
assertExpectedChecksumEntries(checksums);
assertExpectedArchiveFiles(dir);
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
const assetPath = path.join(dir, assetName);
if (!fs.existsSync(assetPath)) {
fail(`Missing release asset: ${assetName}`);
}
const actual = await sha256File(assetPath);
const expected = checksums.get(assetName);
if (actual !== expected) {
fail(
`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
}
if (!silent) {
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`,
);
}
}
async function verifyReleaseBaseUrl(baseUrl, options = {}) {
const { fetchImpl = fetch } = options;
const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl);
const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString();
const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl));
assertExpectedChecksumEntries(checksums);
await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl);
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${baseUrl}`,
);
}
function readReleaseChecksums(dir) {
const checksumPath = path.join(dir, 'SHA256SUMS');
if (!fs.existsSync(checksumPath)) {
fail(`SHA256SUMS was not found at ${checksumPath}`);
}
return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8'));
}
function assertExpectedChecksumEntries(checksums) {
const expected = new Set(EXPECTED_STANDALONE_ARCHIVE_NAMES);
const missing = EXPECTED_STANDALONE_ARCHIVE_NAMES.filter(
(assetName) => !checksums.has(assetName),
);
const extra = Array.from(checksums.keys()).filter(
(assetName) => !expected.has(assetName),
);
if (missing.length > 0) {
fail(`Missing release asset checksum: ${missing.join(', ')}`);
}
if (extra.length > 0) {
fail(`Unexpected release asset checksum: ${extra.join(', ')}`);
}
}
function assertExpectedArchiveFiles(dir) {
const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES);
const extra = fs
.readdirSync(dir)
.filter((assetName) => !expected.has(assetName))
.sort();
if (extra.length > 0) {
fail(`Unexpected release asset: ${extra.join(', ')}`);
}
}
function releaseAssetPaths(dir) {
return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) =>
path.join(dir, assetName),
);
}
async function assertRemoteAssetChecksums(
normalizedBaseUrl,
checksums,
fetchImpl,
) {
const failures = [];
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
try {
const assetUrl = new URL(assetName, normalizedBaseUrl).toString();
const actual = await fetchSha256(assetUrl, fetchImpl);
const expected = checksums.get(assetName);
if (actual !== expected) {
fail(
`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
} catch (reason) {
failures.push({
assetName,
reason: formatErrorReason(reason),
});
}
}
if (failures.length === 0) {
return;
}
if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) {
fail(
`All ${failures.length} release asset URLs are unavailable; check --base-url: ${normalizedBaseUrl}`,
);
}
fail(
`Unavailable or invalid release asset(s): ${failures
.map(({ assetName, reason }) => `${assetName} (${reason})`)
.join('; ')}`,
);
}
async function fetchSha256(url, fetchImpl) {
const response = await fetchWithTimeout(fetchImpl, url);
if (!response.ok) {
fail(
`Failed to download ${url}: ${response.status} ${response.statusText}`,
);
}
if (!response.body) {
fail(`Downloaded response has no body: ${url}`);
}
const hash = crypto.createHash('sha256');
await pipeline(Readable.fromWeb(response.body), hash);
return hash.digest('hex');
}
function formatErrorReason(reason) {
if (reason instanceof Error) {
return reason.message.replace(/^ERROR:\s*/, '');
}
return String(reason);
}
async function fetchText(url, fetchImpl) {
const response = await fetchWithTimeout(fetchImpl, url);
if (!response.ok) {
fail(
`Failed to download ${url}: ${response.status} ${response.statusText}`,
);
}
return response.text();
}
function fetchWithTimeout(fetchImpl, url, options = {}) {
return fetchImpl(url, {
...options,
signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS),
});
}
function normalizeHttpsBaseUrl(baseUrl) {
let parsed;
try {
parsed = new URL(baseUrl);
} catch {
fail(`--base-url must be a valid URL: ${baseUrl}`);
}
if (parsed.protocol !== 'https:') {
fail(`--base-url must use https: ${baseUrl}`);
}
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
}
export {
EXPECTED_STANDALONE_ARCHIVE_NAMES,
EXPECTED_RELEASE_ASSET_NAMES,
releaseAssetPaths,
verifyReleaseBaseUrl,
verifyReleaseDirectory,
};