aboutsummaryrefslogtreecommitdiffstats
path: root/build/release/verify.js
diff options
context:
space:
mode:
Diffstat (limited to 'build/release/verify.js')
-rw-r--r--build/release/verify.js243
1 files changed, 243 insertions, 0 deletions
diff --git a/build/release/verify.js b/build/release/verify.js
new file mode 100644
index 000000000..423a63c8c
--- /dev/null
+++ b/build/release/verify.js
@@ -0,0 +1,243 @@
+/**
+ * Verify the latest release is reproducible
+ */
+import { exec as nodeExec } from "node:child_process";
+import crypto from "node:crypto";
+import { createWriteStream } from "node:fs";
+import { mkdir, readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { Readable } from "node:stream";
+import { finished } from "node:stream/promises";
+import util from "node:util";
+import { gunzip as nodeGunzip } from "node:zlib";
+import { rimraf } from "rimraf";
+
+const exec = util.promisify( nodeExec );
+const gunzip = util.promisify( nodeGunzip );
+
+const SRC_REPO = "https://github.com/jquery/jquery.git";
+const CDN_URL = "https://code.jquery.com";
+const REGISTRY_URL = "https://registry.npmjs.org/jquery";
+
+const excludeFromCDN = [
+ /^package\.json$/,
+ /^jquery\.factory\./
+];
+
+const rjquery = /^jquery/;
+
+async function verifyRelease( { version } = {} ) {
+ if ( !version ) {
+ version = process.env.VERSION || ( await getLatestVersion() );
+ }
+ const release = await buildRelease( { version } );
+
+ console.log( `Verifying jQuery ${ version }...` );
+
+ let verified = true;
+ const matchingFiles = [];
+ const mismatchingFiles = [];
+
+ // Check all files against the CDN
+ await Promise.all(
+ release.files
+ .filter( ( file ) => excludeFromCDN.every( ( re ) => !re.test( file.name ) ) )
+ .map( async( file ) => {
+ const url = new URL( file.cdnName, CDN_URL );
+ const response = await fetch( url );
+ if ( !response.ok ) {
+ throw new Error(
+ `Failed to download ${
+ file.cdnName
+ } from the CDN: ${ response.statusText }`
+ );
+ }
+ const cdnContents = await response.text();
+ if ( cdnContents !== file.cdnContents ) {
+ mismatchingFiles.push( url.href );
+ verified = false;
+ } else {
+ matchingFiles.push( url.href );
+ }
+ } )
+ );
+
+ // Check all files against npm.
+ // First, download npm tarball for version
+ const npmPackage = await fetch( REGISTRY_URL ).then( ( res ) => res.json() );
+
+ if ( !npmPackage.versions[ version ] ) {
+ throw new Error( `jQuery ${ version } not found on npm!` );
+ }
+ const npmTarball = npmPackage.versions[ version ].dist.tarball;
+
+ // Write npm tarball to file
+ const npmTarballPath = path.join( "tmp/verify", version, "npm.tgz" );
+ await downloadFile( npmTarball, npmTarballPath );
+
+ // Check the tarball checksum
+ const tgzSum = await sumTarball( npmTarballPath );
+ if ( tgzSum !== release.tgz.contents ) {
+ mismatchingFiles.push( `npm:${ version }.tgz` );
+ verified = false;
+ } else {
+ matchingFiles.push( `npm:${ version }.tgz` );
+ }
+
+ await Promise.all(
+ release.files.map( async( file ) => {
+
+ // Get file contents from tarball
+ const { stdout: npmContents } = await exec(
+ `tar -xOf ${ npmTarballPath } package/${ file.path }/${ file.name }`
+ );
+
+ if ( npmContents !== file.contents ) {
+ mismatchingFiles.push( `npm:${ file.path }/${ file.name }` );
+ verified = false;
+ } else {
+ matchingFiles.push( `npm:${ file.path }/${ file.name }` );
+ }
+ } )
+ );
+
+ if ( verified ) {
+ console.log( `jQuery ${ version } is reproducible! All files match!` );
+ } else {
+ console.log();
+ for ( const file of matchingFiles ) {
+ console.log( `✅ ${ file }` );
+ }
+ console.log();
+ for ( const file of mismatchingFiles ) {
+ console.log( `❌ ${ file }` );
+ }
+
+ throw new Error( `jQuery ${ version } is NOT reproducible!` );
+ }
+}
+
+async function buildRelease( { version } ) {
+ const releaseFolder = path.join( "tmp/verify", version );
+
+ // Clone the release repo
+ console.log( `Cloning jQuery ${ version }...` );
+ await rimraf( releaseFolder );
+ await mkdir( releaseFolder, { recursive: true } );
+
+ // Uses a depth of 2 so we can get the commit date of
+ // the commit used to build, which is the commit before the tag
+ await exec(
+ `git clone -q -b ${ version } --depth=2 ${ SRC_REPO } ${ releaseFolder }`
+ );
+
+ // Install node dependencies
+ console.log( `Installing dependencies for jQuery ${ version }...` );
+ await exec( "npm ci", { cwd: releaseFolder } );
+
+ // Find the date of the commit just before the release,
+ // which was used as the date in the built files
+ const { stdout: date } = await exec( "git log -1 --format=%ci HEAD~1", {
+ cwd: releaseFolder
+ } );
+
+ // Build the release
+ console.log( `Building jQuery ${ version }...` );
+ const { stdout: buildOutput } = await exec( "npm run build:all", {
+ cwd: releaseFolder,
+ env: {
+
+ // Keep existing environment variables
+ ...process.env,
+ RELEASE_DATE: date,
+ VERSION: version
+ }
+ } );
+ console.log( buildOutput );
+
+ // Pack the npm tarball
+ console.log( `Packing jQuery ${ version }...` );
+ const { stdout: packOutput } = await exec( "npm pack", { cwd: releaseFolder } );
+ console.log( packOutput );
+
+ // Get all top-level /dist and /dist-module files
+ const distFiles = await readdir(
+ path.join( releaseFolder, "dist" ),
+ { withFileTypes: true }
+ );
+ const distModuleFiles = await readdir(
+ path.join( releaseFolder, "dist-module" ),
+ { withFileTypes: true }
+ );
+
+ const files = await Promise.all(
+ [ ...distFiles, ...distModuleFiles ]
+ .filter( ( dirent ) => dirent.isFile() )
+ .map( async( dirent ) => {
+ const contents = await readFile(
+ path.join( dirent.parentPath, dirent.name ),
+ "utf8"
+ );
+ return {
+ name: dirent.name,
+ path: path.basename( dirent.parentPath ),
+ contents,
+ cdnName: dirent.name.replace( rjquery, `jquery-${ version }` ),
+ cdnContents: dirent.name.endsWith( ".map" ) ?
+
+ // The CDN has versioned filenames in the maps
+ convertMapToVersioned( contents, version ) :
+ contents
+ };
+ } )
+ );
+
+ // Get checksum of the tarball
+ const tgzFilename = `jquery-${ version }.tgz`;
+ const sum = await sumTarball( path.join( releaseFolder, tgzFilename ) );
+
+ return {
+ files,
+ tgz: {
+ name: tgzFilename,
+ contents: sum
+ },
+ version
+ };
+}
+
+async function downloadFile( url, dest ) {
+ const response = await fetch( url );
+ const fileStream = createWriteStream( dest );
+ const stream = Readable.fromWeb( response.body ).pipe( fileStream );
+ return finished( stream );
+}
+
+async function getLatestVersion() {
+ const { stdout: sha } = await exec( "git rev-list --tags --max-count=1" );
+ const { stdout: tag } = await exec( `git describe --tags ${ sha.trim() }` );
+ return tag.trim();
+}
+
+function shasum( data ) {
+ const hash = crypto.createHash( "sha256" );
+ hash.update( data );
+ return hash.digest( "hex" );
+}
+
+async function sumTarball( filepath ) {
+ const contents = await readFile( filepath );
+ const unzipped = await gunzip( contents );
+ return shasum( unzipped );
+}
+
+function convertMapToVersioned( contents, version ) {
+ const map = JSON.parse( contents );
+ return JSON.stringify( {
+ ...map,
+ file: map.file.replace( rjquery, `jquery-${ version }` ),
+ sources: map.sources.map( ( source ) => source.replace( rjquery, `jquery-${ version }` ) )
+ } );
+}
+
+verifyRelease();