diff --git a/Gruntfile.js b/Gruntfile.js index d196c51152658..8603635b28fbc 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1589,13 +1589,9 @@ module.exports = function(grunt) { grunt.registerTask( 'gutenberg:download', 'Downloads the built Gutenberg artifact.', function() { const done = this.async(); - const args = [ 'tools/gutenberg/download.js' ]; - if ( grunt.option( 'force' ) ) { - args.push( '--force' ); - } grunt.util.spawn( { cmd: 'node', - args, + args: [ 'tools/gutenberg/download.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); diff --git a/package.json b/package.json index 5a390aac47174..4e9b800d7aad9 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "wicg-inert": "3.1.3" }, "scripts": { - "postinstall": "npm run gutenberg:download", + "postinstall": "npm run gutenberg:verify", "build": "grunt build", "build:dev": "grunt build --dev", "build:gutenberg": "grunt build:gutenberg", @@ -140,6 +140,7 @@ "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan", "gutenberg:copy": "node tools/gutenberg/copy.js", + "gutenberg:verify": "node tools/gutenberg/utils.js", "gutenberg:download": "node tools/gutenberg/download.js", "vendor:copy": "node tools/vendors/copy-vendors.js", "sync-gutenberg-packages": "grunt sync-gutenberg-packages", diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js index f936136ffa25c..dcca072df39bd 100644 --- a/tools/gutenberg/download.js +++ b/tools/gutenberg/download.js @@ -4,7 +4,8 @@ * Download Gutenberg Repository Script. * * This script downloads a pre-built Gutenberg tar.gz artifact from the GitHub - * Container Registry and extracts it into the ./gutenberg directory. + * Container Registry and extracts it into the ./gutenberg directory. Any + * existing gutenberg directory is removed before extraction. * * The artifact is identified by the "gutenberg.sha" value in the root * package.json, which is used as the OCI image tag for the gutenberg-build @@ -17,16 +18,13 @@ const { spawn } = require( 'child_process' ); const fs = require( 'fs' ); const { Writable } = require( 'stream' ); const { pipeline } = require( 'stream/promises' ); -const path = require( 'path' ); const zlib = require( 'zlib' ); -const { gutenbergDir, readGutenbergConfig, verifyGutenbergVersion } = require( './utils' ); +const { gutenbergDir, readGutenbergConfig } = require( './utils' ); /** * Main execution function. - * - * @param {boolean} force - Whether to force a fresh download even if the gutenberg directory exists. */ -async function main( force ) { +async function main() { console.log( 'šŸ” Checking Gutenberg configuration...' ); /* @@ -45,129 +43,115 @@ async function main( force ) { process.exit( 1 ); } - // Skip download if the gutenberg directory already exists and --force is not set. - let downloaded = false; - if ( ! force && fs.existsSync( gutenbergDir ) ) { - console.log( '\nā„¹ļø The `gutenberg` directory already exists. Use `npm run grunt gutenberg:download -- --force` to download a fresh copy.' ); - } else { - downloaded = true; - - // Step 1: Get an anonymous GHCR token for pulling. - console.log( '\nšŸ”‘ Fetching GHCR token...' ); - let token; - try { - const response = await fetch( `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io` ); - if ( ! response.ok ) { - throw new Error( `Failed to fetch token: ${ response.status } ${ response.statusText }` ); - } - const data = await response.json(); - token = data.token; - if ( ! token ) { - throw new Error( 'No token in response' ); - } - console.log( 'āœ… Token acquired' ); - } catch ( error ) { - console.error( 'āŒ Failed to fetch token:', error.message ); - process.exit( 1 ); + // Step 1: Get an anonymous GHCR token for pulling. + console.log( '\nšŸ”‘ Fetching GHCR token...' ); + let token; + try { + const response = await fetch( `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io` ); + if ( ! response.ok ) { + throw new Error( `Failed to fetch token: ${ response.status } ${ response.statusText }` ); } - - // Step 2: Get the manifest to find the blob digest. - console.log( `\nšŸ“‹ Fetching manifest for ${ sha }...` ); - let digest; - try { - const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ sha }`, { - headers: { - Authorization: `Bearer ${ token }`, - Accept: 'application/vnd.oci.image.manifest.v1+json', - }, - } ); - if ( ! response.ok ) { - throw new Error( `Failed to fetch manifest: ${ response.status } ${ response.statusText }` ); - } - const manifest = await response.json(); - digest = manifest?.layers?.[ 0 ]?.digest; - if ( ! digest ) { - throw new Error( 'No layer digest found in manifest' ); - } - console.log( `āœ… Blob digest: ${ digest }` ); - } catch ( error ) { - console.error( 'āŒ Failed to fetch manifest:', error.message ); - process.exit( 1 ); + const data = await response.json(); + token = data.token; + if ( ! token ) { + throw new Error( 'No token in response' ); } + console.log( 'āœ… Token acquired' ); + } catch ( error ) { + console.error( 'āŒ Failed to fetch token:', error.message ); + process.exit( 1 ); + } - // Remove existing gutenberg directory so the extraction is clean. - if ( fs.existsSync( gutenbergDir ) ) { - console.log( '\nšŸ—‘ļø Removing existing gutenberg directory...' ); - fs.rmSync( gutenbergDir, { recursive: true, force: true } ); + // Step 2: Get the manifest to find the blob digest. + console.log( `\nšŸ“‹ Fetching manifest for ${ sha }...` ); + let digest; + try { + const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ sha }`, { + headers: { + Authorization: `Bearer ${ token }`, + Accept: 'application/vnd.oci.image.manifest.v1+json', + }, + } ); + if ( ! response.ok ) { + throw new Error( `Failed to fetch manifest: ${ response.status } ${ response.statusText }` ); } + const manifest = await response.json(); + digest = manifest?.layers?.[ 0 ]?.digest; + if ( ! digest ) { + throw new Error( 'No layer digest found in manifest' ); + } + console.log( `āœ… Blob digest: ${ digest }` ); + } catch ( error ) { + console.error( 'āŒ Failed to fetch manifest:', error.message ); + process.exit( 1 ); + } - fs.mkdirSync( gutenbergDir, { recursive: true } ); + // Remove existing gutenberg directory so the extraction is clean. + if ( fs.existsSync( gutenbergDir ) ) { + console.log( '\nšŸ—‘ļø Removing existing gutenberg directory...' ); + fs.rmSync( gutenbergDir, { recursive: true, force: true } ); + } + + fs.mkdirSync( gutenbergDir, { recursive: true } ); + + /* + * Step 3: Stream the blob directly through gunzip into tar, writing + * into ./gutenberg with no temporary file on disk. + */ + console.log( `\nšŸ“„ Downloading and extracting artifact...` ); + try { + const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, { + headers: { + Authorization: `Bearer ${ token }`, + }, + } ); + if ( ! response.ok ) { + throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` ); + } /* - * Step 3: Stream the blob directly through gunzip into tar, writing - * into ./gutenberg with no temporary file on disk. + * Spawn tar to read from stdin and extract into gutenbergDir. + * `tar` is available on macOS, Linux, and Windows 10+. */ - console.log( `\nšŸ“„ Downloading and extracting artifact...` ); - try { - const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, { - headers: { - Authorization: `Bearer ${ token }`, - }, - } ); - if ( ! response.ok ) { - throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` ); - } - - /* - * Spawn tar to read from stdin and extract into gutenbergDir. - * `tar` is available on macOS, Linux, and Windows 10+. - */ - const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], { - stdio: [ 'pipe', 'inherit', 'inherit' ], - } ); - - const tarDone = new Promise( ( resolve, reject ) => { - tar.on( 'close', ( code ) => { - if ( code !== 0 ) { - reject( new Error( `tar exited with code ${ code }` ) ); - } else { - resolve(); - } - } ); - tar.on( 'error', reject ); + const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], { + stdio: [ 'pipe', 'inherit', 'inherit' ], + } ); + + const tarDone = new Promise( ( resolve, reject ) => { + tar.on( 'close', ( code ) => { + if ( code !== 0 ) { + reject( new Error( `tar exited with code ${ code }` ) ); + } else { + resolve(); + } } ); + tar.on( 'error', reject ); + } ); - /* - * Pipe: fetch body → gunzip → tar stdin. - * Decompressing in Node keeps the pipeline error handling - * consistent and means tar only sees plain tar data on stdin. - */ - await pipeline( - response.body, - zlib.createGunzip(), - Writable.toWeb( tar.stdin ), - ); - - await tarDone; - - console.log( 'āœ… Download and extraction complete' ); - } catch ( error ) { - console.error( 'āŒ Download/extraction failed:', error.message ); - process.exit( 1 ); - } - } + /* + * Pipe: fetch body → gunzip → tar stdin. + * Decompressing in Node keeps the pipeline error handling + * consistent and means tar only sees plain tar data on stdin. + */ + await pipeline( + response.body, + zlib.createGunzip(), + Writable.toWeb( tar.stdin ), + ); - // Verify the downloaded version matches the expected SHA. - verifyGutenbergVersion(); + await tarDone; - if ( downloaded ) { - console.log( '\nāœ… Gutenberg download complete!' ); + console.log( 'āœ… Download and extraction complete' ); + } catch ( error ) { + console.error( 'āŒ Download/extraction failed:', error.message ); + process.exit( 1 ); } + + console.log( '\nāœ… Gutenberg download complete!' ); } // Run main function. -const force = process.argv.includes( '--force' ); -main( force ).catch( ( error ) => { +main().catch( ( error ) => { console.error( 'āŒ Unexpected error:', error ); process.exit( 1 ); } ); diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js index 4a04210f699d7..2b30befd38735 100644 --- a/tools/gutenberg/utils.js +++ b/tools/gutenberg/utils.js @@ -4,17 +4,20 @@ * Gutenberg build utilities. * * Shared helpers used by the Gutenberg download script. When run directly, - * verifies that the installed Gutenberg build matches the SHA in package.json. + * verifies that the installed Gutenberg build matches the SHA in package.json, + * and automatically downloads the correct version when needed. * * @package WordPress */ +const { spawnSync } = require( 'child_process' ); const fs = require( 'fs' ); const path = require( 'path' ); // Paths. const rootDir = path.resolve( __dirname, '../..' ); const gutenbergDir = path.join( rootDir, 'gutenberg' ); +const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' ); /** * Read Gutenberg configuration from package.json. @@ -38,10 +41,22 @@ function readGutenbergConfig() { return { sha, ghcrRepo }; } +/** + * Trigger a fresh download of the Gutenberg artifact by spawning download.js. + * Exits the process if the download fails. + */ +function downloadGutenberg() { + const result = spawnSync( 'node', [ path.join( __dirname, 'download.js' ) ], { stdio: 'inherit' } ); + if ( result.status !== 0 ) { + process.exit( result.status ?? 1 ); + } +} + /** * Verify that the installed Gutenberg version matches the expected SHA in - * package.json. Logs progress to the console and exits with a non-zero code - * on failure. + * package.json. Automatically downloads the correct version when the directory + * is missing, the hash file is absent, or the hash does not match. Logs + * progress to the console and exits with a non-zero code on failure. */ function verifyGutenbergVersion() { console.log( '\nšŸ” Verifying Gutenberg version...' ); @@ -54,18 +69,40 @@ function verifyGutenbergVersion() { process.exit( 1 ); } - const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' ); + // Check for conditions that require a fresh download. + if ( ! fs.existsSync( gutenbergDir ) ) { + console.log( 'ā„¹ļø Gutenberg directory not found. Downloading...' ); + downloadGutenberg(); + } else { + let installedHash = null; + try { + installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim(); + } catch ( error ) { + if ( error.code !== 'ENOENT' ) { + console.error( `āŒ ${ error.message }` ); + process.exit( 1 ); + } + } + + if ( installedHash === null ) { + console.log( 'ā„¹ļø Hash file not found. Downloading expected version...' ); + downloadGutenberg(); + } else if ( installedHash !== sha ) { + console.log( `ā„¹ļø Hash mismatch (found ${ installedHash }, expected ${ sha }). Downloading expected version...` ); + downloadGutenberg(); + } + } + + // Final verification — confirms the download (if any) produced the correct version. try { const installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim(); if ( installedHash !== sha ) { - console.error( - `āŒ SHA mismatch: expected ${ sha } but found ${ installedHash }. Run \`npm run grunt gutenberg:download -- --force\` to download the correct version.` - ); + console.error( `āŒ SHA mismatch after download: expected ${ sha } but found ${ installedHash }.` ); process.exit( 1 ); } } catch ( error ) { if ( error.code === 'ENOENT' ) { - console.error( `āŒ .gutenberg-hash not found. Run \`npm run grunt gutenberg:download\` to download Gutenberg.` ); + console.error( 'āŒ .gutenberg-hash not found after download. This is unexpected.' ); } else { console.error( `āŒ ${ error.message }` ); }