@@ -34,6 +34,9 @@ const standalonePackageScriptUrl = pathToFileURL(
3434const 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 ;
3740const 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+ / U n k n o w n o p t i o n : - - u n k n o w n / ,
465+ ) ;
466+ expectFail (
467+ [ 'scripts/verify-installation-release.js' , '--dir' ] ,
468+ / - - d i r r e q u i r e s a v a l u e / ,
469+ ) ;
470+ expectFail (
471+ [
472+ 'scripts/verify-installation-release.js' ,
473+ '--dir' ,
474+ '/tmp' ,
475+ '--base-url' ,
476+ 'https://example.com/r/' ,
477+ ] ,
478+ / P a s s - - d i r o r - - b a s e - u r l , n o t b o t h / ,
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+ / M i s s i n g r e l e a s e a s s e t : q w e n - c o d e - / ,
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+ / U n e x p e c t e d r e l e a s e a s s e t c h e c k s u m : q w e n - c o d e - e x t r a \. t a r \. g z / ,
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+ / S H A 2 5 6 S U M S w a s n o t f o u n d a t / ,
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 ( / i n s t a l l - q w e n \. ( s h | b a t ) $ / ) ;
823+ expect ( url ) . not . toMatch ( / \/ i n s t a l l $ / ) ;
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 ( / R e l e a s e a s s e t U R L i s n o t a v a i l a b l e / ) ;
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+ / - - b a s e - u r l m u s t u s e h t t p o r h t t p s / ,
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