feat(installer): verify installation release assets

Adds `npm run verify:installation-release` and wires it into the release
workflow after `Build Standalone Archives`, so a broken release directory
fails CI before publishing.

Local mode (`--dir PATH`) checks:
- All five `qwen-code-{platform}.{ext}` standalone archives exist.
- `SHA256SUMS` covers exactly those five — missing or unexpected entries fail.
- Each archive's actual SHA256 matches its `SHA256SUMS` entry.

Remote mode (`--base-url URL`) checks:
- `SHA256SUMS` is downloadable, parseable, and contains exactly the expected
  archive entries.
- Each archive URL is reachable via HEAD, with a 1-byte ranged GET fallback
  for hosts that disable HEAD.

Hosted installer scripts (`install-qwen.sh` / `install-qwen.bat`) are
intentionally out of scope here — they are served from the hosted endpoint
prepared by `package:hosted-installation` (PR #3853), not from the GitHub
Release surface this verifier targets.
This commit is contained in:
yiliang114 2026-05-07 20:09:30 +08:00
parent 2e4086aa4a
commit 4100b8e239
4 changed files with 500 additions and 0 deletions

View file

@ -384,6 +384,9 @@ jobs:
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone'
- name: 'Verify Installation Release Assets'
run: 'npm run verify:installation-release -- --dir dist/standalone'
- name: 'Publish @qwen-code/qwen-code'
working-directory: 'dist'
run: |-

View file

@ -67,6 +67,7 @@
"package:hosted-installation": "node scripts/build-hosted-installation-assets.js",
"package:standalone": "node scripts/create-standalone-package.js",
"package:standalone:release": "node scripts/build-standalone-release.js",
"verify:installation-release": "node scripts/verify-installation-release.js",
"release:version": "node scripts/version.js",
"telemetry": "node scripts/telemetry.js",
"check:lockfile": "node scripts/check-lockfile.js",

View file

@ -34,6 +34,9 @@ const standalonePackageScriptUrl = pathToFileURL(
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;
@ -239,6 +242,9 @@ describe('standalone release packaging', () => {
expect(packageJson.scripts['package:hosted-installation']).toBe(
'node scripts/build-hosted-installation-assets.js',
);
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.
@ -248,6 +254,7 @@ describe('standalone release packaging', () => {
expect(existsSync('scripts/build-hosted-installation-assets.js')).toBe(
true,
);
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);
@ -312,6 +319,25 @@ describe('standalone release packaging', () => {
expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS');
expect(hostedInstallScript).not.toContain("output: 'install'");
const releaseVerifyScript = readScript(
'scripts/verify-installation-release.js',
);
expect(releaseVerifyScript).toContain('Copyright 2025 Qwen Team');
expect(releaseVerifyScript).toContain('verifyReleaseDirectory');
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');
@ -400,6 +426,59 @@ describe('standalone release packaging', () => {
expect(output).toContain('--out-dir PATH');
});
it('loads the installation release verification helper', () => {
const output = 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');
});
it('rejects invalid installation release verification CLI arguments', () => {
const expectFail = (args, expectedOutput) => {
let caughtError;
try {
execFileSync(process.execPath, args, {
encoding: 'utf8',
stdio: 'pipe',
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeTruthy();
expect(
[
caughtError?.message,
caughtError?.stdout?.toString(),
caughtError?.stderr?.toString(),
].join('\n'),
).toMatch(expectedOutput);
};
expectFail(
['scripts/verify-installation-release.js', '--unknown'],
/Unknown option: --unknown/,
);
expectFail(
['scripts/verify-installation-release.js', '--dir'],
/--dir requires a value/,
);
expectFail(
[
'scripts/verify-installation-release.js',
'--dir',
'/tmp',
'--base-url',
'https://example.com/r/',
],
/Pass --dir or --base-url, not both/,
);
});
it('exposes only standalone archive classification', async () => {
const config = await import(releaseAssetConfigUrl);
@ -643,6 +722,166 @@ describe('standalone release packaging', () => {
}
});
it('verifies release asset directory contents and checksums', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
await import(installationReleaseVerificationScriptUrl);
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
try {
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',
);
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
new RegExp(
`Checksum verification failed for ${EXPECTED_STANDALONE_ARCHIVE_NAMES[0].replace(/\./g, '\\.')}`,
),
);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects missing release archives and unexpected checksum entries', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
await import(installationReleaseVerificationScriptUrl);
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
try {
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]));
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
/Missing release asset: qwen-code-/,
);
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
writeStandaloneReleaseChecksums(tmpDir, [
...EXPECTED_STANDALONE_ARCHIVE_NAMES,
'qwen-code-extra.tar.gz',
]);
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
/Unexpected release asset checksum: qwen-code-extra\.tar\.gz/,
);
} 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 });
}
});
it('verifies release asset URLs from SHA256SUMS', async () => {
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
await import(installationReleaseVerificationScriptUrl);
const checksumContent = standaloneChecksumContent(
EXPECTED_STANDALONE_ARCHIVE_NAMES,
);
const fetchedUrls = [];
await expect(
verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', {
fetchImpl: async (url, options = {}) => {
fetchedUrls.push([url, options.method || 'GET']);
if (url.endsWith('/SHA256SUMS')) {
return new Response(checksumContent);
}
return new Response(null, { status: 200 });
},
}),
).resolves.not.toThrow();
expect(fetchedUrls).toContainEqual([
'https://example.com/qwen-code/v0.0.0/SHA256SUMS',
'GET',
]);
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
expect(fetchedUrls).toContainEqual([
`https://example.com/qwen-code/v0.0.0/${assetName}`,
'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 = standaloneChecksumContent(
EXPECTED_STANDALONE_ARCHIVE_NAMES,
);
const observedMethods = [];
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();
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 = standaloneChecksumContent(
EXPECTED_STANDALONE_ARCHIVE_NAMES,
);
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(/Release asset URL is not available/);
});
it('rejects a release base URL that is not http(s)', async () => {
const { verifyReleaseBaseUrl } = await import(
installationReleaseVerificationScriptUrl
);
await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow(
/--base-url must use http or https/,
);
});
it('rejects a runtime archive without a Node executable', () => {
const restoreDist = ensureMinimalDist();
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
@ -889,6 +1128,15 @@ describe('standalone release packaging', () => {
expect(workflow).not.toContain('download_node()');
expect(workflow).toContain('dist/standalone/qwen-code-*');
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', () => {
@ -1818,3 +2066,39 @@ function writeChecksumFile(outDir, archiveName) {
.digest('hex');
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) {
writeFileSync(path.join(outDir, assetName), `${assetName}\n`);
}
writeStandaloneReleaseChecksums(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);
return `${hash} ${assetName}`;
});
writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
}
function standaloneChecksumContent(archiveNames) {
return `${archiveNames
.map(
(assetName) =>
`${crypto
.createHash('sha256')
.update(`${assetName}\n`)
.digest('hex')} ${assetName}`,
)
.join('\n')}\n`;
}

View file

@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { RELEASE_TARGETS } from './build-standalone-release.js';
import { isStandaloneArchiveName } from './release-asset-config.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, '..');
const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map(
({ qwenTarget }) =>
`qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`,
);
// 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 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 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();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, {
help: false,
dir: undefined,
baseUrl: undefined,
});
if (args.help) {
printUsage();
return;
}
if (args.dir && args.baseUrl) {
fail('Pass --dir or --base-url, not both.');
}
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 or release URL contains the
expected standalone archives and a SHA256SUMS file that covers them with
matching content hashes.
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.
-h, --help Show this help message.
`);
}
async function verifyReleaseDirectory(dir) {
const checksums = readReleaseChecksums(dir);
assertExpectedChecksumEntries(checksums);
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);
if (actual !== checksums.get(assetName)) {
fail(`Checksum verification failed for ${assetName}`);
}
}
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`,
);
}
async function verifyReleaseBaseUrl(baseUrl, options = {}) {
const { fetchImpl = fetch } = options;
const normalizedBaseUrl = normalizeHttpBaseUrl(baseUrl);
const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString();
const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl));
assertExpectedChecksumEntries(checksums);
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
await assertRemoteAssetAvailable(
new URL(assetName, normalizedBaseUrl).toString(),
fetchImpl,
);
}
console.log(
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release asset URLs 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) =>
isStandaloneArchiveName(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(', ')}`);
}
}
async function assertRemoteAssetAvailable(url, fetchImpl) {
let response = await fetchImpl(url, { method: 'HEAD' });
if (response.ok) {
await response.body?.cancel?.();
return;
}
await response.body?.cancel?.();
// Some object-storage hosts disable HEAD; fall back to a 1-byte ranged GET
// so the verifier can still confirm reachability without downloading the
// full archive.
response = await fetchImpl(url, {
headers: {
Range: 'bytes=0-0',
},
});
if (!response.ok) {
fail(`Release asset URL is not available: ${url}`);
}
await response.body?.cancel?.();
}
async function fetchText(url, fetchImpl) {
const response = await fetchImpl(url);
if (!response.ok) {
fail(
`Failed to download ${url}: ${response.status} ${response.statusText}`,
);
}
return response.text();
}
function normalizeHttpBaseUrl(baseUrl) {
let parsed;
try {
parsed = new URL(baseUrl);
} catch {
fail(`--base-url must be a valid URL: ${baseUrl}`);
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
fail(`--base-url must use http or https: ${baseUrl}`);
}
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
}
export {
EXPECTED_RELEASE_ASSET_NAMES,
EXPECTED_STANDALONE_ARCHIVE_NAMES,
verifyReleaseBaseUrl,
verifyReleaseDirectory,
};