Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
210 changes: 97 additions & 113 deletions tools/gutenberg/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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...' );

/*
Expand All @@ -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 );
} );
53 changes: 45 additions & 8 deletions tools/gutenberg/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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...' );
Expand All @@ -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 }` );
}
Expand Down
Loading