Make WordPress Core

Changeset 62021


Ignore:
Timestamp:
03/13/2026 09:56:05 PM (2 months ago)
Author:
desrosj
Message:

Build/Test Tools: Improve Gutenberg artifact fetching.

This improves how the built Gutenberg asset is retrieved from the GitHub Container Registry to avoid situations where the download fails when the directory already exists.

  • The related postinstall command has changed from gutenberg:download to gutenberg:verify.
  • The --force option has been removed. gutenberg:download will now download a fresh copy every time it's run.
  • The gutenberg:verify script is now the preferred entry point for managing the files within the gutenberg directory. It will only trigger a downoad if the hashes do not match, or the folder is missing entirely.

Follow up to [61438], [61873], [61874].

Props bernhard-reiter.
See #64393.

Location:
trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • trunk/Gruntfile.js

    r61978 r62021  
    15901590    grunt.registerTask( 'gutenberg:download', 'Downloads the built Gutenberg artifact.', function() {
    15911591        const done = this.async();
    1592         const args = [ 'tools/gutenberg/download.js' ];
    1593         if ( grunt.option( 'force' ) ) {
    1594             args.push( '--force' );
    1595         }
    15961592        grunt.util.spawn( {
    15971593            cmd: 'node',
    1598             args,
     1594            args: [ 'tools/gutenberg/download.js' ],
    15991595            opts: { stdio: 'inherit' }
    16001596        }, function( error ) {
  • trunk/package.json

    r61988 r62021  
    113113    },
    114114    "scripts": {
    115         "postinstall": "npm run gutenberg:download",
     115        "postinstall": "npm run gutenberg:verify",
    116116        "build": "grunt build",
    117117        "build:dev": "grunt build --dev",
     
    141141        "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan",
    142142        "gutenberg:copy": "node tools/gutenberg/copy.js",
     143        "gutenberg:verify": "node tools/gutenberg/utils.js",
    143144        "gutenberg:download": "node tools/gutenberg/download.js",
    144145        "vendor:copy": "node tools/vendors/copy-vendors.js",
  • trunk/tools/gutenberg/download.js

    r61873 r62021  
    55 *
    66 * This script downloads a pre-built Gutenberg tar.gz artifact from the GitHub
    7  * Container Registry and extracts it into the ./gutenberg directory.
     7 * Container Registry and extracts it into the ./gutenberg directory. Any
     8 * existing gutenberg directory is removed before extraction.
    89 *
    910 * The artifact is identified by the "gutenberg.sha" value in the root
     
    1819const { Writable } = require( 'stream' );
    1920const { pipeline } = require( 'stream/promises' );
    20 const path = require( 'path' );
    2121const zlib = require( 'zlib' );
    22 const { gutenbergDir, readGutenbergConfig, verifyGutenbergVersion } = require( './utils' );
     22const { gutenbergDir, readGutenbergConfig } = require( './utils' );
    2323
    2424/**
    2525 * Main execution function.
    26  *
    27  * @param {boolean} force - Whether to force a fresh download even if the gutenberg directory exists.
    2826 */
    29 async function main( force ) {
     27async function main() {
    3028    console.log( '🔍 Checking Gutenberg configuration...' );
    3129
     
    4644    }
    4745
    48     // Skip download if the gutenberg directory already exists and --force is not set.
    49     let downloaded = false;
    50     if ( ! force && fs.existsSync( gutenbergDir ) ) {
    51         console.log( '\nℹ️  The `gutenberg` directory already exists. Use `npm run grunt gutenberg:download -- --force` to download a fresh copy.' );
    52     } else {
    53         downloaded = true;
     46    // Step 1: Get an anonymous GHCR token for pulling.
     47    console.log( '\n🔑 Fetching GHCR token...' );
     48    let token;
     49    try {
     50        const response = await fetch( `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io` );
     51        if ( ! response.ok ) {
     52            throw new Error( `Failed to fetch token: ${ response.status } ${ response.statusText }` );
     53        }
     54        const data = await response.json();
     55        token = data.token;
     56        if ( ! token ) {
     57            throw new Error( 'No token in response' );
     58        }
     59        console.log( '✅ Token acquired' );
     60    } catch ( error ) {
     61        console.error( '❌ Failed to fetch token:', error.message );
     62        process.exit( 1 );
     63    }
    5464
    55         // Step 1: Get an anonymous GHCR token for pulling.
    56         console.log( '\n🔑 Fetching GHCR token...' );
    57         let token;
    58         try {
    59             const response = await fetch( `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io` );
    60             if ( ! response.ok ) {
    61                 throw new Error( `Failed to fetch token: ${ response.status } ${ response.statusText }` );
    62             }
    63             const data = await response.json();
    64             token = data.token;
    65             if ( ! token ) {
    66                 throw new Error( 'No token in response' );
    67             }
    68             console.log( '✅ Token acquired' );
    69         } catch ( error ) {
    70             console.error( '❌ Failed to fetch token:', error.message );
    71             process.exit( 1 );
     65    // Step 2: Get the manifest to find the blob digest.
     66    console.log( `\n📋 Fetching manifest for ${ sha }...` );
     67    let digest;
     68    try {
     69        const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ sha }`, {
     70            headers: {
     71                Authorization: `Bearer ${ token }`,
     72                Accept: 'application/vnd.oci.image.manifest.v1+json',
     73            },
     74        } );
     75        if ( ! response.ok ) {
     76            throw new Error( `Failed to fetch manifest: ${ response.status } ${ response.statusText }` );
     77        }
     78        const manifest = await response.json();
     79        digest = manifest?.layers?.[ 0 ]?.digest;
     80        if ( ! digest ) {
     81            throw new Error( 'No layer digest found in manifest' );
     82        }
     83        console.log( `✅ Blob digest: ${ digest }` );
     84    } catch ( error ) {
     85        console.error( '❌ Failed to fetch manifest:', error.message );
     86        process.exit( 1 );
     87    }
     88
     89    // Remove existing gutenberg directory so the extraction is clean.
     90    if ( fs.existsSync( gutenbergDir ) ) {
     91        console.log( '\n🗑️  Removing existing gutenberg directory...' );
     92        fs.rmSync( gutenbergDir, { recursive: true, force: true } );
     93    }
     94
     95    fs.mkdirSync( gutenbergDir, { recursive: true } );
     96
     97    /*
     98     * Step 3: Stream the blob directly through gunzip into tar, writing
     99     * into ./gutenberg with no temporary file on disk.
     100     */
     101    console.log( `\n📥 Downloading and extracting artifact...` );
     102    try {
     103        const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, {
     104            headers: {
     105                Authorization: `Bearer ${ token }`,
     106            },
     107        } );
     108        if ( ! response.ok ) {
     109            throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` );
    72110        }
    73111
    74         // Step 2: Get the manifest to find the blob digest.
    75         console.log( `\n📋 Fetching manifest for ${ sha }...` );
    76         let digest;
    77         try {
    78             const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ sha }`, {
    79                 headers: {
    80                     Authorization: `Bearer ${ token }`,
    81                     Accept: 'application/vnd.oci.image.manifest.v1+json',
    82                 },
     112        /*
     113         * Spawn tar to read from stdin and extract into gutenbergDir.
     114         * `tar` is available on macOS, Linux, and Windows 10+.
     115         */
     116        const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], {
     117            stdio: [ 'pipe', 'inherit', 'inherit' ],
     118        } );
     119
     120        const tarDone = new Promise( ( resolve, reject ) => {
     121            tar.on( 'close', ( code ) => {
     122                if ( code !== 0 ) {
     123                    reject( new Error( `tar exited with code ${ code }` ) );
     124                } else {
     125                    resolve();
     126                }
    83127            } );
    84             if ( ! response.ok ) {
    85                 throw new Error( `Failed to fetch manifest: ${ response.status } ${ response.statusText }` );
    86             }
    87             const manifest = await response.json();
    88             digest = manifest?.layers?.[ 0 ]?.digest;
    89             if ( ! digest ) {
    90                 throw new Error( 'No layer digest found in manifest' );
    91             }
    92             console.log( `✅ Blob digest: ${ digest }` );
    93         } catch ( error ) {
    94             console.error( '❌ Failed to fetch manifest:', error.message );
    95             process.exit( 1 );
    96         }
    97 
    98         // Remove existing gutenberg directory so the extraction is clean.
    99         if ( fs.existsSync( gutenbergDir ) ) {
    100             console.log( '\n🗑️  Removing existing gutenberg directory...' );
    101             fs.rmSync( gutenbergDir, { recursive: true, force: true } );
    102         }
    103 
    104         fs.mkdirSync( gutenbergDir, { recursive: true } );
     128            tar.on( 'error', reject );
     129        } );
    105130
    106131        /*
    107          * Step 3: Stream the blob directly through gunzip into tar, writing
    108          * into ./gutenberg with no temporary file on disk.
     132         * Pipe: fetch body → gunzip → tar stdin.
     133         * Decompressing in Node keeps the pipeline error handling
     134         * consistent and means tar only sees plain tar data on stdin.
    109135         */
    110         console.log( `\n📥 Downloading and extracting artifact...` );
    111         try {
    112             const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, {
    113                 headers: {
    114                     Authorization: `Bearer ${ token }`,
    115                 },
    116             } );
    117             if ( ! response.ok ) {
    118                 throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` );
    119             }
     136        await pipeline(
     137            response.body,
     138            zlib.createGunzip(),
     139            Writable.toWeb( tar.stdin ),
     140        );
    120141
    121             /*
    122              * Spawn tar to read from stdin and extract into gutenbergDir.
    123              * `tar` is available on macOS, Linux, and Windows 10+.
    124              */
    125             const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], {
    126                 stdio: [ 'pipe', 'inherit', 'inherit' ],
    127             } );
     142        await tarDone;
    128143
    129             const tarDone = new Promise( ( resolve, reject ) => {
    130                 tar.on( 'close', ( code ) => {
    131                     if ( code !== 0 ) {
    132                         reject( new Error( `tar exited with code ${ code }` ) );
    133                     } else {
    134                         resolve();
    135                     }
    136                 } );
    137                 tar.on( 'error', reject );
    138             } );
    139 
    140             /*
    141              * Pipe: fetch body → gunzip → tar stdin.
    142              * Decompressing in Node keeps the pipeline error handling
    143              * consistent and means tar only sees plain tar data on stdin.
    144              */
    145             await pipeline(
    146                 response.body,
    147                 zlib.createGunzip(),
    148                 Writable.toWeb( tar.stdin ),
    149             );
    150 
    151             await tarDone;
    152 
    153             console.log( '✅ Download and extraction complete' );
    154         } catch ( error ) {
    155             console.error( '❌ Download/extraction failed:', error.message );
    156             process.exit( 1 );
    157         }
     144        console.log( '✅ Download and extraction complete' );
     145    } catch ( error ) {
     146        console.error( '❌ Download/extraction failed:', error.message );
     147        process.exit( 1 );
    158148    }
    159149
    160     // Verify the downloaded version matches the expected SHA.
    161     verifyGutenbergVersion();
    162 
    163     if ( downloaded ) {
    164         console.log( '\n✅ Gutenberg download complete!' );
    165     }
     150    console.log( '\n✅ Gutenberg download complete!' );
    166151}
    167152
    168153// Run main function.
    169 const force = process.argv.includes( '--force' );
    170 main( force ).catch( ( error ) => {
     154main().catch( ( error ) => {
    171155    console.error( '❌ Unexpected error:', error );
    172156    process.exit( 1 );
  • trunk/tools/gutenberg/utils.js

    r61873 r62021  
    55 *
    66 * Shared helpers used by the Gutenberg download script. When run directly,
    7  * verifies that the installed Gutenberg build matches the SHA in package.json.
     7 * verifies that the installed Gutenberg build matches the SHA in package.json,
     8 * and automatically downloads the correct version when needed.
    89 *
    910 * @package WordPress
    1011 */
    1112
     13const { spawnSync } = require( 'child_process' );
    1214const fs = require( 'fs' );
    1315const path = require( 'path' );
     
    1618const rootDir = path.resolve( __dirname, '../..' );
    1719const gutenbergDir = path.join( rootDir, 'gutenberg' );
     20const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' );
    1821
    1922/**
     
    4043
    4144/**
     45 * Trigger a fresh download of the Gutenberg artifact by spawning download.js.
     46 * Exits the process if the download fails.
     47 */
     48function downloadGutenberg() {
     49    const result = spawnSync( 'node', [ path.join( __dirname, 'download.js' ) ], { stdio: 'inherit' } );
     50    if ( result.status !== 0 ) {
     51        process.exit( result.status ?? 1 );
     52    }
     53}
     54
     55/**
    4256 * Verify that the installed Gutenberg version matches the expected SHA in
    43  * package.json. Logs progress to the console and exits with a non-zero code
    44  * on failure.
     57 * package.json. Automatically downloads the correct version when the directory
     58 * is missing, the hash file is absent, or the hash does not match. Logs
     59 * progress to the console and exits with a non-zero code on failure.
    4560 */
    4661function verifyGutenbergVersion() {
     
    5570    }
    5671
    57     const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' );
     72    // Check for conditions that require a fresh download.
     73    if ( ! fs.existsSync( gutenbergDir ) ) {
     74        console.log( 'ℹ️  Gutenberg directory not found. Downloading...' );
     75        downloadGutenberg();
     76    } else {
     77        let installedHash = null;
     78        try {
     79            installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim();
     80        } catch ( error ) {
     81            if ( error.code !== 'ENOENT' ) {
     82                console.error( `❌ ${ error.message }` );
     83                process.exit( 1 );
     84            }
     85        }
     86
     87        if ( installedHash === null ) {
     88            console.log( 'ℹ️  Hash file not found. Downloading expected version...' );
     89            downloadGutenberg();
     90        } else if ( installedHash !== sha ) {
     91            console.log( `ℹ️  Hash mismatch (found ${ installedHash }, expected ${ sha }). Downloading expected version...` );
     92            downloadGutenberg();
     93        }
     94    }
     95
     96    // Final verification — confirms the download (if any) produced the correct version.
    5897    try {
    5998        const installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim();
    6099        if ( installedHash !== sha ) {
    61             console.error(
    62                 `❌ SHA mismatch: expected ${ sha } but found ${ installedHash }. Run \`npm run grunt gutenberg:download -- --force\` to download the correct version.`
    63             );
     100            console.error( `❌ SHA mismatch after download: expected ${ sha } but found ${ installedHash }.` );
    64101            process.exit( 1 );
    65102        }
    66103    } catch ( error ) {
    67104        if ( error.code === 'ENOENT' ) {
    68             console.error( `❌ .gutenberg-hash not found. Run \`npm run grunt gutenberg:download\` to download Gutenberg.` );
     105            console.error( '❌ .gutenberg-hash not found after download. This is unexpected.' );
    69106        } else {
    70107            console.error( `❌ ${ error.message }` );
Note: See TracChangeset for help on using the changeset viewer.