mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-18 23:42:43 +00:00
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:
parent
2e4086aa4a
commit
4100b8e239
4 changed files with 500 additions and 0 deletions
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -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: |-
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
212
scripts/verify-installation-release.js
Normal file
212
scripts/verify-installation-release.js
Normal 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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue