Skip to content

Commit 4100b8e

Browse files
committed
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.
1 parent 2e4086a commit 4100b8e

4 files changed

Lines changed: 500 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,9 @@ jobs:
384384
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
385385
run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone'
386386

387+
- name: 'Verify Installation Release Assets'
388+
run: 'npm run verify:installation-release -- --dir dist/standalone'
389+
387390
- name: 'Publish @qwen-code/qwen-code'
388391
working-directory: 'dist'
389392
run: |-

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"package:hosted-installation": "node scripts/build-hosted-installation-assets.js",
6868
"package:standalone": "node scripts/create-standalone-package.js",
6969
"package:standalone:release": "node scripts/build-standalone-release.js",
70+
"verify:installation-release": "node scripts/verify-installation-release.js",
7071
"release:version": "node scripts/version.js",
7172
"telemetry": "node scripts/telemetry.js",
7273
"check:lockfile": "node scripts/check-lockfile.js",

scripts/tests/install-script.test.js

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const standalonePackageScriptUrl = pathToFileURL(
3434
const hostedInstallationScriptUrl = pathToFileURL(
3535
path.resolve('scripts/build-hosted-installation-assets.js'),
3636
).href;
37+
const installationReleaseVerificationScriptUrl = pathToFileURL(
38+
path.resolve('scripts/verify-installation-release.js'),
39+
).href;
3740
const releaseAssetConfigUrl = pathToFileURL(
3841
path.resolve('scripts/release-asset-config.js'),
3942
).href;
@@ -239,6 +242,9 @@ describe('standalone release packaging', () => {
239242
expect(packageJson.scripts['package:hosted-installation']).toBe(
240243
'node scripts/build-hosted-installation-assets.js',
241244
);
245+
expect(packageJson.scripts['verify:installation-release']).toBe(
246+
'node scripts/verify-installation-release.js',
247+
);
242248
// Per-release installer publishing was removed in favor of a stable hosted
243249
// entrypoint with --version pinning, so no package:installation-assets
244250
// script should exist.
@@ -248,6 +254,7 @@ describe('standalone release packaging', () => {
248254
expect(existsSync('scripts/build-hosted-installation-assets.js')).toBe(
249255
true,
250256
);
257+
expect(existsSync('scripts/verify-installation-release.js')).toBe(true);
251258
expect(existsSync('scripts/build-installation-assets.js')).toBe(false);
252259
expect(existsSync('scripts/release-asset-config.js')).toBe(true);
253260
expect(existsSync('scripts/release-script-utils.js')).toBe(true);
@@ -312,6 +319,25 @@ describe('standalone release packaging', () => {
312319
expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS');
313320
expect(hostedInstallScript).not.toContain("output: 'install'");
314321

322+
const releaseVerifyScript = readScript(
323+
'scripts/verify-installation-release.js',
324+
);
325+
expect(releaseVerifyScript).toContain('Copyright 2025 Qwen Team');
326+
expect(releaseVerifyScript).toContain('verifyReleaseDirectory');
327+
expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl');
328+
expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES');
329+
expect(releaseVerifyScript).toContain('EXPECTED_STANDALONE_ARCHIVE_NAMES');
330+
// The verifier targets only standalone archives + SHA256SUMS; hosted
331+
// installer scripts have their own staging path and are intentionally
332+
// not part of the GitHub release surface. Asserting absence of the
333+
// alias / installer-asset *helper functions* is enough — comments may
334+
// legitimately reference the hosted filenames as context.
335+
expect(releaseVerifyScript).not.toContain('INSTALLATION_ASSET_NAMES');
336+
expect(releaseVerifyScript).not.toContain('isReleaseChecksumAsset');
337+
expect(releaseVerifyScript).not.toContain('assertInstallAliasMatches');
338+
expect(releaseVerifyScript).not.toContain('assertInstallAliasBuffersMatch');
339+
expect(releaseVerifyScript).not.toContain('assertUnixInstallersExecutable');
340+
315341
const releaseAssetConfig = readScript('scripts/release-asset-config.js');
316342
expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team');
317343
expect(releaseAssetConfig).toContain('isStandaloneArchiveName');
@@ -400,6 +426,59 @@ describe('standalone release packaging', () => {
400426
expect(output).toContain('--out-dir PATH');
401427
});
402428

429+
it('loads the installation release verification helper', () => {
430+
const output = execFileSync(
431+
process.execPath,
432+
['scripts/verify-installation-release.js', '--help'],
433+
{ encoding: 'utf8' },
434+
);
435+
436+
expect(output).toContain('verify:installation-release');
437+
expect(output).toContain('--dir PATH');
438+
expect(output).toContain('--base-url URL');
439+
});
440+
441+
it('rejects invalid installation release verification CLI arguments', () => {
442+
const expectFail = (args, expectedOutput) => {
443+
let caughtError;
444+
try {
445+
execFileSync(process.execPath, args, {
446+
encoding: 'utf8',
447+
stdio: 'pipe',
448+
});
449+
} catch (error) {
450+
caughtError = error;
451+
}
452+
expect(caughtError).toBeTruthy();
453+
expect(
454+
[
455+
caughtError?.message,
456+
caughtError?.stdout?.toString(),
457+
caughtError?.stderr?.toString(),
458+
].join('\n'),
459+
).toMatch(expectedOutput);
460+
};
461+
462+
expectFail(
463+
['scripts/verify-installation-release.js', '--unknown'],
464+
/Unknown option: --unknown/,
465+
);
466+
expectFail(
467+
['scripts/verify-installation-release.js', '--dir'],
468+
/--dir requires a value/,
469+
);
470+
expectFail(
471+
[
472+
'scripts/verify-installation-release.js',
473+
'--dir',
474+
'/tmp',
475+
'--base-url',
476+
'https://example.com/r/',
477+
],
478+
/Pass --dir or --base-url, not both/,
479+
);
480+
});
481+
403482
it('exposes only standalone archive classification', async () => {
404483
const config = await import(releaseAssetConfigUrl);
405484

@@ -643,6 +722,166 @@ describe('standalone release packaging', () => {
643722
}
644723
});
645724

725+
it('verifies release asset directory contents and checksums', async () => {
726+
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
727+
await import(installationReleaseVerificationScriptUrl);
728+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
729+
730+
try {
731+
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
732+
await expect(verifyReleaseDirectory(tmpDir)).resolves.not.toThrow();
733+
734+
// Tampering an archive must be caught by the per-asset hash check.
735+
appendFileSync(
736+
path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]),
737+
'tamper',
738+
);
739+
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
740+
new RegExp(
741+
`Checksum verification failed for ${EXPECTED_STANDALONE_ARCHIVE_NAMES[0].replace(/\./g, '\\.')}`,
742+
),
743+
);
744+
} finally {
745+
rmSync(tmpDir, { recursive: true, force: true });
746+
}
747+
});
748+
749+
it('rejects missing release archives and unexpected checksum entries', async () => {
750+
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
751+
await import(installationReleaseVerificationScriptUrl);
752+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
753+
754+
try {
755+
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
756+
rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]));
757+
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
758+
/Missing release asset: qwen-code-/,
759+
);
760+
761+
writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES);
762+
writeStandaloneReleaseChecksums(tmpDir, [
763+
...EXPECTED_STANDALONE_ARCHIVE_NAMES,
764+
'qwen-code-extra.tar.gz',
765+
]);
766+
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
767+
/Unexpected release asset checksum: qwen-code-extra\.tar\.gz/,
768+
);
769+
} finally {
770+
rmSync(tmpDir, { recursive: true, force: true });
771+
}
772+
});
773+
774+
it('rejects a release directory without SHA256SUMS', async () => {
775+
const { verifyReleaseDirectory } = await import(
776+
installationReleaseVerificationScriptUrl
777+
);
778+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-'));
779+
780+
try {
781+
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
782+
/SHA256SUMS was not found at /,
783+
);
784+
} finally {
785+
rmSync(tmpDir, { recursive: true, force: true });
786+
}
787+
});
788+
789+
it('verifies release asset URLs from SHA256SUMS', async () => {
790+
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
791+
await import(installationReleaseVerificationScriptUrl);
792+
const checksumContent = standaloneChecksumContent(
793+
EXPECTED_STANDALONE_ARCHIVE_NAMES,
794+
);
795+
const fetchedUrls = [];
796+
797+
await expect(
798+
verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', {
799+
fetchImpl: async (url, options = {}) => {
800+
fetchedUrls.push([url, options.method || 'GET']);
801+
if (url.endsWith('/SHA256SUMS')) {
802+
return new Response(checksumContent);
803+
}
804+
return new Response(null, { status: 200 });
805+
},
806+
}),
807+
).resolves.not.toThrow();
808+
809+
expect(fetchedUrls).toContainEqual([
810+
'https://example.com/qwen-code/v0.0.0/SHA256SUMS',
811+
'GET',
812+
]);
813+
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
814+
expect(fetchedUrls).toContainEqual([
815+
`https://example.com/qwen-code/v0.0.0/${assetName}`,
816+
'HEAD',
817+
]);
818+
}
819+
// Hosted installer scripts must not be fetched: the verifier targets
820+
// GitHub release assets only.
821+
for (const [url] of fetchedUrls) {
822+
expect(url).not.toMatch(/install-qwen\.(sh|bat)$/);
823+
expect(url).not.toMatch(/\/install$/);
824+
}
825+
});
826+
827+
it('falls back to ranged GET when remote HEAD is unavailable', async () => {
828+
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
829+
await import(installationReleaseVerificationScriptUrl);
830+
const checksumContent = standaloneChecksumContent(
831+
EXPECTED_STANDALONE_ARCHIVE_NAMES,
832+
);
833+
const observedMethods = [];
834+
835+
await expect(
836+
verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', {
837+
fetchImpl: async (url, options = {}) => {
838+
if (url.endsWith('/SHA256SUMS')) {
839+
return new Response(checksumContent);
840+
}
841+
const method = options.method || 'GET';
842+
observedMethods.push(method);
843+
if (method === 'HEAD') {
844+
return new Response(null, { status: 405 });
845+
}
846+
// Ranged GET fallback succeeds.
847+
return new Response(null, { status: 206 });
848+
},
849+
}),
850+
).resolves.not.toThrow();
851+
852+
expect(observedMethods).toContain('HEAD');
853+
expect(observedMethods).toContain('GET');
854+
});
855+
856+
it('rejects a release base URL with no archives reachable', async () => {
857+
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } =
858+
await import(installationReleaseVerificationScriptUrl);
859+
const checksumContent = standaloneChecksumContent(
860+
EXPECTED_STANDALONE_ARCHIVE_NAMES,
861+
);
862+
863+
await expect(
864+
verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', {
865+
fetchImpl: async (url) => {
866+
if (url.endsWith('/SHA256SUMS')) {
867+
return new Response(checksumContent);
868+
}
869+
return new Response(null, { status: 404 });
870+
},
871+
}),
872+
).rejects.toThrow(/Release asset URL is not available/);
873+
});
874+
875+
it('rejects a release base URL that is not http(s)', async () => {
876+
const { verifyReleaseBaseUrl } = await import(
877+
installationReleaseVerificationScriptUrl
878+
);
879+
880+
await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow(
881+
/--base-url must use http or https/,
882+
);
883+
});
884+
646885
it('rejects a runtime archive without a Node executable', () => {
647886
const restoreDist = ensureMinimalDist();
648887
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
@@ -889,6 +1128,15 @@ describe('standalone release packaging', () => {
8891128
expect(workflow).not.toContain('download_node()');
8901129
expect(workflow).toContain('dist/standalone/qwen-code-*');
8911130
expect(workflow).toContain('dist/standalone/SHA256SUMS');
1131+
// The verify step must run after the build step so a broken release
1132+
// directory is caught before publishing.
1133+
expect(workflow).toContain(
1134+
'npm run verify:installation-release -- --dir dist/standalone',
1135+
);
1136+
const buildIndex = workflow.indexOf('npm run package:standalone:release');
1137+
const verifyIndex = workflow.indexOf('npm run verify:installation-release');
1138+
expect(buildIndex).toBeGreaterThan(-1);
1139+
expect(verifyIndex).toBeGreaterThan(buildIndex);
8921140
});
8931141

8941142
it('does not whitelist internal planning documents in gitignore', () => {
@@ -1818,3 +2066,39 @@ function writeChecksumFile(outDir, archiveName) {
18182066
.digest('hex');
18192067
writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`);
18202068
}
2069+
2070+
// Writes a synthetic standalone release directory: each archive name in
2071+
// `archiveNames` becomes a small file whose content equals the asset name,
2072+
// and SHA256SUMS is regenerated to match.
2073+
function writeStandaloneReleaseAssets(outDir, archiveNames) {
2074+
mkdirSync(outDir, { recursive: true });
2075+
for (const assetName of archiveNames) {
2076+
writeFileSync(path.join(outDir, assetName), `${assetName}\n`);
2077+
}
2078+
writeStandaloneReleaseChecksums(outDir, archiveNames);
2079+
}
2080+
2081+
function writeStandaloneReleaseChecksums(outDir, archiveNames) {
2082+
const lines = archiveNames.map((assetName) => {
2083+
const filePath = path.join(outDir, assetName);
2084+
// Allow callers to list a not-yet-written archive name (e.g. an
2085+
// "unexpected extra" entry) without requiring the file to exist.
2086+
const hash = existsSync(filePath)
2087+
? crypto.createHash('sha256').update(readFileSync(filePath)).digest('hex')
2088+
: 'a'.repeat(64);
2089+
return `${hash} ${assetName}`;
2090+
});
2091+
writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
2092+
}
2093+
2094+
function standaloneChecksumContent(archiveNames) {
2095+
return `${archiveNames
2096+
.map(
2097+
(assetName) =>
2098+
`${crypto
2099+
.createHash('sha256')
2100+
.update(`${assetName}\n`)
2101+
.digest('hex')} ${assetName}`,
2102+
)
2103+
.join('\n')}\n`;
2104+
}

0 commit comments

Comments
 (0)