diff --git a/.env.example b/.env.example index 76a4744165505..2c98c296eee45 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,21 @@ # - '{version}': any major.minor PHP version from 5.2 onwards. ## +## +# Local Gutenberg repository mode. +# +# When set to true, the build system will clone the Gutenberg git repository at +# the commit SHA specified in package.json instead of downloading a pre-built +# zip artifact. The repository will be built from source during `npm install` +# and before each `grunt build`. +# +# This is intended for contributors who need to work across both wordpress-develop +# and the Gutenberg repository simultaneously. +# +# Note: The initial build can take several minutes. +## +GUTENBERG_LOCAL_REPO=false + # The site will be available at http://localhost:LOCAL_PORT LOCAL_PORT=8889 diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index de2de9091677c..12c58a0ac2afb 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -78,6 +78,7 @@ jobs: tests-domain: [ 'example.org' ] multisite: [ false, true ] memcached: [ false ] + build-from-gutenberg: [ false ] include: # Include jobs that test with memcached. @@ -118,6 +119,15 @@ jobs: multisite: false memcached: false report: true + # Build directly using the Gutenberg repository. + - os: 'ubuntu-24.04' + php: '8.4' + db-type: 'mysql' + db-version: '8.4' + tests-domain: 'example.org' + multisite: false + memcached: false + build-from-gutenberg: true with: os: ${{ matrix.os }} php: ${{ matrix.php }} @@ -128,6 +138,7 @@ jobs: phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} tests-domain: ${{ matrix.tests-domain }} report: ${{ matrix.report || false }} + build-from-gutenberg-repo: ${{ matrix.build-from-gutenberg || false }} # # Creates a PHPUnit test job for each PHP/MariaDB combination. diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 45198c20f5e52..24ba78ed31b99 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -72,6 +72,12 @@ on: required: false type: boolean default: false + build-from-gutenberg-repo: + description: 'Whether to build directly from the Gutenberg repository.' + required: false + type: 'boolean' + default: false + secrets: CODECOV_TOKEN: description: 'The Codecov token required for uploading reports.' @@ -118,11 +124,13 @@ jobs: # - Checks out the WordPress Test reporter repository. # - Submit the test results to the WordPress.org host test results. phpunit-tests: - name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} + name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }}${{ inputs.build-from-gutenberg-repo && ' built from Gutenberg repo' || '' }} runs-on: ${{ inputs.os }} timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} permissions: contents: read + env: + GUTENBERG_LOCAL_REPO: ${{ inputs.build-from-gutenberg-repo }} steps: - name: Configure environment variables diff --git a/.github/workflows/reusable-test-core-build-process.yml b/.github/workflows/reusable-test-core-build-process.yml index 4bec59e285c57..f19888d3fac65 100644 --- a/.github/workflows/reusable-test-core-build-process.yml +++ b/.github/workflows/reusable-test-core-build-process.yml @@ -36,6 +36,11 @@ on: required: false type: 'boolean' default: false + build-from-gutenberg-repo: + description: 'Whether to build directly from the Gutenberg repository.' + required: false + type: 'boolean' + default: false env: PUPPETEER_SKIP_DOWNLOAD: ${{ true }} @@ -62,11 +67,13 @@ jobs: # - Saves the pull request number to a text file. # - Uploads the pull request number as an artifact. build-process-tests: - name: ${{ contains( inputs.os, 'macos-' ) && 'MacOS' || contains( inputs.os, 'windows-' ) && 'Windows' || 'Linux' }} + name: ${{ contains( inputs.os, 'macos-' ) && 'MacOS' || contains( inputs.os, 'windows-' ) && 'Windows' || 'Linux' }}${{ inputs.build-from-gutenberg-repo && ' built from Gutenberg repo' || '' }} permissions: contents: read runs-on: ${{ inputs.os }} timeout-minutes: 20 + env: + GUTENBERG_LOCAL_REPO: ${{ inputs.build-from-gutenberg-repo }} steps: - name: Checkout repository diff --git a/.github/workflows/test-build-processes.yml b/.github/workflows/test-build-processes.yml index 150c36ef0893c..0a7ccf56e28a8 100644 --- a/.github/workflows/test-build-processes.yml +++ b/.github/workflows/test-build-processes.yml @@ -59,18 +59,23 @@ jobs: os: [ 'ubuntu-24.04' ] directory: [ 'src', 'build' ] test-certificates: [ true ] + build-from-gutenberg: [ true, false ] + include: # Only prepare artifacts for Playground once. - os: 'ubuntu-24.04' directory: 'build' save-build: true prepare-playground: ${{ github.event_name == 'pull_request' && true || '' }} + build-from-gutenberg: false + with: os: ${{ matrix.os }} directory: ${{ matrix.directory }} test-certificates: ${{ matrix.test-certificates && true || false }} save-build: ${{ matrix.save-build && matrix.save-build || false }} prepare-playground: ${{ matrix.prepare-playground && matrix.prepare-playground || false }} + build-from-gutenberg-repo: ${{ matrix.build-from-gutenberg && matrix.build-from-gutenberg || false }} # Tests the WordPress Core build process on additional operating systems. # diff --git a/.gitignore b/.gitignore index 1cd9da16b3ed0..926f6ffb9dcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,8 @@ wp-tests-config.php /src/wp-includes/build /src/wp-includes/theme.json /packagehash.txt -/.gutenberg-hash +/.gutenberg-dir-hash +/.gutenberg-repo-hash /artifacts /setup.log /coverage diff --git a/Gruntfile.js b/Gruntfile.js index 81da2faae228f..dccfeb1637f34 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -53,7 +53,6 @@ module.exports = function(grunt) { webpackFiles = [ 'wp-includes/assets/*', 'wp-includes/css/dist', - 'wp-includes/blocks/**/*.css', '!wp-includes/assets/script-loader-packages.min.php', '!wp-includes/assets/script-modules-packages.min.php', ], @@ -1468,45 +1467,81 @@ module.exports = function(grunt) { } ); // Gutenberg integration tasks. - grunt.registerTask( 'gutenberg-checkout', 'Checks out the Gutenberg repository.', function() { + grunt.registerTask( 'gutenberg:verify', 'Verifies the installed Gutenberg version matches the expected SHA.', function() { const done = this.async(); grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/checkout-gutenberg.js' ], + args: [ 'tools/gutenberg/utils.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg-build', 'Builds the Gutenberg repository.', function() { + 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: [ 'tools/gutenberg/build-gutenberg.js' ], + args, opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg-copy', 'Copies Gutenberg build output to WordPress Core.', function() { + grunt.registerTask( 'gutenberg:checkout', 'Clones the Gutenberg git repository at the SHA specified in package.json (local repository mode only).', function() { + const done = this.async(); + const args = [ 'tools/gutenberg/checkout.js' ]; + if ( grunt.option( 'force' ) ) { + args.push( '--force' ); + } + grunt.util.spawn( { + cmd: 'node', + args, + opts: { stdio: 'inherit' } + }, function( error ) { + done( ! error ); + } ); + } ); + + grunt.registerTask( 'gutenberg:build:run', 'Builds Gutenberg from source for WordPress Core (local repository mode only).', function() { + const done = this.async(); + const args = [ 'tools/gutenberg/build.js' ]; + if ( grunt.option( 'force' ) ) { + args.push( '--force' ); + } + grunt.util.spawn( { + cmd: 'node', + args, + opts: { stdio: 'inherit' } + }, function( error ) { + done( ! error ); + } ); + } ); + + grunt.registerTask( 'gutenberg:build', 'Builds Gutenberg from source and copies the result to WordPress Core.', [ 'gutenberg:build:run', 'gutenberg:copy' ] ); + + grunt.registerTask( 'gutenberg:sync', 'Downloads or checks out Gutenberg at the expected SHA, builds if needed, and copies to WordPress Core.', function() { const done = this.async(); - const buildDir = grunt.option( 'dev' ) ? 'src' : 'build'; grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/copy-gutenberg-build.js', `--build-dir=${ buildDir }` ], + args: [ 'tools/gutenberg/sync.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg-sync', 'Syncs Gutenberg checkout and build if ref has changed.', function() { + grunt.registerTask( 'gutenberg:copy', 'Copies Gutenberg build output to WordPress Core.', function() { const done = this.async(); + const buildDir = grunt.option( 'dev' ) ? 'src' : 'build'; grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/sync-gutenberg.js' ], + args: [ 'tools/gutenberg/copy.js', `--build-dir=${ buildDir }` ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); @@ -1951,23 +1986,23 @@ module.exports = function(grunt) { grunt.registerTask( 'build', function() { if ( grunt.option( 'dev' ) ) { grunt.task.run( [ + 'gutenberg:verify', 'build:js', 'build:css', 'build:codemirror', - 'gutenberg-sync', - 'gutenberg-copy', + 'gutenberg:build', 'copy-vendor-scripts', 'build:certificates' ] ); } else { grunt.task.run( [ + 'gutenberg:verify', 'build:certificates', 'build:files', 'build:js', 'build:css', 'build:codemirror', - 'gutenberg-sync', - 'gutenberg-copy', + 'gutenberg:build', 'copy-vendor-scripts', 'replace:source-maps', 'verify:build' diff --git a/package.json b/package.json index acbe679d2f787..f4eec6bb3480a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "ref": "022d8dd3d461f91b15c1f0410649d3ebb027207f" + "sha": "1ed7da329cc9ffb27cbe5d373ea15db309a135b7", + "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { "node": ">=20.10.0", @@ -106,7 +107,7 @@ "wicg-inert": "3.1.3" }, "scripts": { - "postinstall": "npm run gutenberg:sync && npm run gutenberg:copy -- --dev", + "postinstall": "npm run gutenberg:sync", "build": "grunt build", "build:dev": "grunt build --dev", "dev": "grunt watch --dev", @@ -131,12 +132,13 @@ "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "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:checkout": "node tools/gutenberg/checkout-gutenberg.js", - "gutenberg:build": "node tools/gutenberg/build-gutenberg.js", - "gutenberg:copy": "node tools/gutenberg/copy-gutenberg-build.js", - "gutenberg:sync": "node tools/gutenberg/sync-gutenberg.js", + "gutenberg:build": "node tools/gutenberg/build.js", + "gutenberg:checkout": "node tools/gutenberg/checkout.js", + "gutenberg:copy": "node tools/gutenberg/copy.js", + "gutenberg:download": "node tools/gutenberg/download.js", + "gutenberg:sync": "node tools/gutenberg/sync.js", "vendor:copy": "node tools/vendors/copy-vendors.js", "sync-gutenberg-packages": "grunt sync-gutenberg-packages", "postsync-gutenberg-packages": "grunt wp-packages:sync-stable-blocks && grunt build --dev && grunt build" } -} \ No newline at end of file +} diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index e3cc0beec92f0..4fe5b62afec1f 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -105,7 +105,7 @@ parameters: - ../../src/wp-includes/deprecated.php - ../../src/wp-includes/ms-deprecated.php - ../../src/wp-includes/pluggable-deprecated.php - # These files are sourced by wordpress/gutenberg in `tools/release/sync-stable-blocks.js`. + # These files are autogenerated by tools/gutenberg/copy.js. - ../../src/wp-includes/blocks # Third-party libraries. - ../../src/wp-admin/includes/class-ftp-pure.php diff --git a/tools/gutenberg/build-gutenberg.js b/tools/gutenberg/build.js similarity index 57% rename from tools/gutenberg/build-gutenberg.js rename to tools/gutenberg/build.js index 01cf4489de1fa..1f84a02a8b0e3 100644 --- a/tools/gutenberg/build-gutenberg.js +++ b/tools/gutenberg/build.js @@ -12,13 +12,14 @@ const { spawn } = require( 'child_process' ); const fs = require( 'fs' ); const path = require( 'path' ); +const { gutenbergDir, isGutenbergRepoClone, dirHashChanged, writeDirHash } = require( './utils' ); // Paths const rootDir = path.resolve( __dirname, '../..' ); -const gutenbergDir = path.join( rootDir, 'gutenberg' ); /** * Execute a command and return a promise. + * * Captures output and only displays it on failure for cleaner logs. * * @param {string} command - Command to execute. @@ -81,13 +82,31 @@ function exec( command, args, options = {} ) { /** * Main execution function. */ -async function main() { +async function main( force ) { + if ( ! isGutenbergRepoClone() ) { + console.log( 'ā„¹ļø Skipping Gutenberg build: gutenberg/ is not a git clone.' ); + console.log( ' This step only runs in local repository mode (GUTENBERG_LOCAL_REPO=true).' ); + return; + } + + /* + * Skip the build if the source is unchanged since the last build. + * .gutenberg-dir-hash records a hash of gutenberg/ (excluding build/, + * node_modules/, .git/) written after each successful build. If the source + * is identical, there is nothing new to compile. + */ + if ( ! force && ! dirHashChanged() ) { + console.log( 'ā„¹ļø The gutenberg directory files have not changed. Skipping build.' ); + console.log( ' Use `npm run grunt gutenberg:build -- --force` to rebuild anyway.' ); + return; + } + console.log( 'šŸ” Checking Gutenberg setup...' ); // Verify Gutenberg directory exists if ( ! fs.existsSync( gutenbergDir ) ) { console.error( 'āŒ Gutenberg directory not found at:', gutenbergDir ); - console.error( ' Run: node tools/gutenberg/checkout-gutenberg.js' ); + console.error( ' Run: node tools/gutenberg/checkout.js' ); process.exit( 1 ); } @@ -95,98 +114,61 @@ async function main() { const nodeModulesPath = path.join( gutenbergDir, 'node_modules' ); if ( ! fs.existsSync( nodeModulesPath ) ) { console.error( 'āŒ Gutenberg dependencies not installed' ); - console.error( ' Run: node tools/gutenberg/checkout-gutenberg.js' ); + console.error( ' Run: node tools/gutenberg/checkout.js' ); process.exit( 1 ); } console.log( 'āœ… Gutenberg directory found' ); - // Modify Gutenberg's package.json for Core build - console.log( '\nāš™ļø Configuring build for WordPress Core...' ); - const gutenbergPackageJsonPath = path.join( gutenbergDir, 'package.json' ); - - try { - const content = fs.readFileSync( gutenbergPackageJsonPath, 'utf8' ); - const gutenbergPackageJson = JSON.parse( content ); - - // Set Core environment variables - gutenbergPackageJson.config = gutenbergPackageJson.config || {}; - gutenbergPackageJson.config.IS_GUTENBERG_PLUGIN = false; - gutenbergPackageJson.config.IS_WORDPRESS_CORE = true; - - // Set wpPlugin.name for Core naming convention - gutenbergPackageJson.wpPlugin = gutenbergPackageJson.wpPlugin || {}; - gutenbergPackageJson.wpPlugin.name = 'wp'; - - fs.writeFileSync( - gutenbergPackageJsonPath, - JSON.stringify( gutenbergPackageJson, null, '\t' ) + '\n' - ); - - console.log( ' āœ… IS_GUTENBERG_PLUGIN = false' ); - console.log( ' āœ… IS_WORDPRESS_CORE = true' ); - console.log( ' āœ… wpPlugin.name = wp' ); - } catch ( error ) { - console.error( - 'āŒ Error modifying Gutenberg package.json:', - error.message - ); - process.exit( 1 ); - } - // Build Gutenberg console.log( '\nšŸ”Ø Building Gutenberg for WordPress Core...' ); console.log( ' (This may take a few minutes)' ); const startTime = Date.now(); + /* + * Invoke the build script directly with node instead of going through + * `npm run build --` to avoid shell argument mangling of the base-url + * value (which contains spaces, parentheses, and single quotes). + * The PATH is extended with node_modules/.bin so that bin commands + * like `wp-build` are found, matching what npm would normally provide. + */ + const binPath = path.join( gutenbergDir, 'node_modules', '.bin' ); try { - // Invoke the build script directly with node instead of going through - // `npm run build --` to avoid shell argument mangling of the base-url - // value (which contains spaces, parentheses, and single quotes). - // The PATH is extended with node_modules/.bin so that bin commands - // like `wp-build` are found, matching what npm would normally provide. - const binPath = path.join( gutenbergDir, 'node_modules', '.bin' ); await exec( 'node', [ 'bin/build.mjs', '--skip-types', "--base-url=includes_url( 'build/' )", ], { cwd: gutenbergDir, + stdio: 'inherit', env: { ...process.env, PATH: binPath + path.delimiter + process.env.PATH, + IS_GUTENBERG_PLUGIN: 'false', + IS_WORDPRESS_CORE: 'true', + WP_PLUGIN_NAME: 'wp', }, } ); const duration = Math.round( ( Date.now() - startTime ) / 1000 ); console.log( `āœ… Build completed in ${ duration }s` ); + + /* + * Update .gutenberg-dir-hash so the next run can skip if the source + * is unchanged. + */ + writeDirHash(); } catch ( error ) { console.error( 'āŒ Build failed:', error.message ); throw error; - } finally { - // Restore Gutenberg's package.json regardless of success or failure - await restorePackageJson(); } } -/** - * Restore Gutenberg's package.json to its original state. - */ -async function restorePackageJson() { - console.log( '\nšŸ”„ Restoring Gutenberg package.json...' ); - try { - await exec( 'git', [ 'checkout', '--', 'package.json' ], { - cwd: gutenbergDir, - } ); - console.log( 'āœ… package.json restored' ); - } catch ( error ) { - console.warn( 'āš ļø Could not restore package.json:', error.message ); - } -} // Run main function -main().catch( ( error ) => { +const force = process.argv.includes( '--force' ); +main( force ).catch( ( error ) => { console.error( 'āŒ Unexpected error:', error ); process.exit( 1 ); } ); diff --git a/tools/gutenberg/checkout-gutenberg.js b/tools/gutenberg/checkout.js similarity index 70% rename from tools/gutenberg/checkout-gutenberg.js rename to tools/gutenberg/checkout.js index 42e35a1967b78..399377c909dca 100644 --- a/tools/gutenberg/checkout-gutenberg.js +++ b/tools/gutenberg/checkout.js @@ -18,6 +18,7 @@ const { spawn } = require( 'child_process' ); const fs = require( 'fs' ); const path = require( 'path' ); +const { readGutenbergConfig, isLocalRepoMode, isGutenbergRepoClone, writeRepoHash } = require( './utils' ); // Constants const GUTENBERG_REPO = 'https://github.com/WordPress/gutenberg.git'; @@ -25,7 +26,6 @@ const GUTENBERG_REPO = 'https://github.com/WordPress/gutenberg.git'; // Paths const rootDir = path.resolve( __dirname, '../..' ); const gutenbergDir = path.join( rootDir, 'gutenberg' ); -const packageJsonPath = path.join( rootDir, 'package.json' ); /** * Execute a command and return a promise. @@ -89,68 +89,29 @@ function exec( command, args, options = {} ) { } /** - * Execute a command and capture its output. - * - * @param {string} command - Command to execute. - * @param {string[]} args - Command arguments. - * @param {Object} options - Spawn options. - * @return {Promise} Promise that resolves with command output. + * Main execution function. */ -function execOutput( command, args, options = {} ) { - return new Promise( ( resolve, reject ) => { - const child = spawn( command, args, { - cwd: options.cwd || rootDir, - shell: process.platform === 'win32', // Use shell on Windows to find .cmd files - ...options, - } ); - - let stdout = ''; - let stderr = ''; - - if ( child.stdout ) { - child.stdout.on( 'data', ( data ) => { - stdout += data.toString(); - } ); - } - - if ( child.stderr ) { - child.stderr.on( 'data', ( data ) => { - stderr += data.toString(); - } ); - } - - child.on( 'close', ( code ) => { - if ( code !== 0 ) { - reject( new Error( `${ command } failed: ${ stderr }` ) ); - } else { - resolve( stdout.trim() ); - } - } ); +async function main( force ) { + if ( ! isLocalRepoMode() ) { + console.log( 'ā„¹ļø Skipping Gutenberg checkout: GUTENBERG_LOCAL_REPO is not set to true.' ); + console.log( ' Add GUTENBERG_LOCAL_REPO=true to your .env file to enable local repository mode.' ); + return; + } - child.on( 'error', reject ); - } ); -} + if ( ! force && isGutenbergRepoClone() ) { + console.log( 'ā„¹ļø The `gutenberg` directory is already a git clone.' ); + console.log( ' Use `npm run grunt gutenberg:checkout -- --force` to remove and re-clone.' ); + return; + } -/** - * Main execution function. - */ -async function main() { console.log( 'šŸ” Checking Gutenberg configuration...' ); - // Read Gutenberg ref from package.json + // Read Gutenberg SHA from package.json let ref; try { - const packageJson = JSON.parse( - fs.readFileSync( packageJsonPath, 'utf8' ) - ); - ref = packageJson.gutenberg?.ref; - - if ( ! ref ) { - throw new Error( 'Missing "gutenberg.ref" in package.json' ); - } - + ( { sha: ref } = readGutenbergConfig() ); console.log( ` Repository: ${ GUTENBERG_REPO }` ); - console.log( ` Reference: ${ ref }` ); + console.log( ` SHA: ${ ref }` ); } catch ( error ) { console.error( 'āŒ Error reading package.json:', error.message ); process.exit( 1 ); @@ -162,8 +123,10 @@ async function main() { if ( ! gutenbergExists ) { console.log( '\nšŸ“„ Cloning Gutenberg repository (shallow clone)...' ); try { - // Generic shallow clone approach that works for both branches and commit hashes - // 1. Clone with no checkout and shallow depth + /* + * Generic shallow clone approach that works for both branches and commit hashes. + * 1. Clone with no checkout and shallow depth. + */ await exec( 'git', [ 'clone', '--depth', @@ -195,15 +158,21 @@ async function main() { // Fetch and checkout target ref console.log( `\nšŸ“” Fetching and checking out: ${ ref }` ); try { - // Fetch the specific ref (works for branches, tags, and commit hashes) + // Fetch the specific ref (works for branches, tags, and commit hashes). await exec( 'git', [ 'fetch', '--depth', '1', 'origin', ref ], { cwd: gutenbergDir, } ); - // Checkout what was just fetched - await exec( 'git', [ 'checkout', 'FETCH_HEAD' ], { - cwd: gutenbergDir, - } ); + if ( force ) { + // Hard-reset the index and working tree to FETCH_HEAD, discarding any local modifications. + await exec( 'git', [ 'reset', '--hard', 'FETCH_HEAD' ], { + cwd: gutenbergDir, + } ); + } else { + await exec( 'git', [ 'checkout', 'FETCH_HEAD' ], { + cwd: gutenbergDir, + } ); + } console.log( 'āœ… Checked out successfully' ); } catch ( error ) { @@ -229,11 +198,26 @@ async function main() { process.exit( 1 ); } + /* + * Record the checked-out SHA so build.js and copy.js can detect if HEAD + * has been moved manually since this checkout was performed. + */ + writeRepoHash( ref ); + + /* + * Invalidate the stale directory hash. The source tree has just changed + * (new checkout), so the old hash is no longer valid. Removing the file + * ensures checkDirHash() and the build skip check stay silent until + * build.js writes a fresh hash after the first successful build. + */ + fs.rmSync( path.join( rootDir, '.gutenberg-dir-hash' ), { force: true } ); + console.log( '\nāœ… Gutenberg checkout complete!' ); } // Run main function -main().catch( ( error ) => { +const force = process.argv.includes( '--force' ); +main( force ).catch( ( error ) => { console.error( 'āŒ Unexpected error:', error ); process.exit( 1 ); } ); diff --git a/tools/gutenberg/copy-gutenberg-build.js b/tools/gutenberg/copy.js similarity index 92% rename from tools/gutenberg/copy-gutenberg-build.js rename to tools/gutenberg/copy.js index 0d1c454ca8085..7589735ec7c61 100644 --- a/tools/gutenberg/copy-gutenberg-build.js +++ b/tools/gutenberg/copy.js @@ -13,15 +13,17 @@ const fs = require( 'fs' ); const path = require( 'path' ); const json2php = require( 'json2php' ); const glob = require( 'glob' ); +const { checkDirHash, checkRepoHash, isGutenbergRepoClone } = require( './utils' ); // Paths const rootDir = path.resolve( __dirname, '../..' ); const gutenbergDir = path.join( rootDir, 'gutenberg' ); const gutenbergBuildDir = path.join( gutenbergDir, 'build' ); -const gutenbergPackagesDir = path.join( gutenbergDir, 'packages' ); -// Determine build target from command line argument (--dev or --build-dir) -// Default to 'src' for development +/* + * Determine build target from command line argument (--dev or --build-dir). + * Default to 'src' for development. + */ const args = process.argv.slice( 2 ); const buildDirArg = args.find( ( arg ) => arg.startsWith( '--build-dir=' ) ); const buildTarget = buildDirArg @@ -70,8 +72,10 @@ const COPY_CONFIG = { copyAll: true, }, - // Blocks (to wp-includes/blocks/) - // Unified configuration for all block types + /* + * Blocks (to wp-includes/blocks/). + * Unified configuration for all block types. + */ blocks: { destination: 'blocks', sources: [ @@ -80,14 +84,14 @@ const COPY_CONFIG = { name: 'block-library', scripts: 'scripts/block-library', styles: 'styles/block-library', - php: 'block-library/src', + php: 'scripts/block-library', }, { // Widget blocks name: 'widgets', scripts: 'scripts/widgets/blocks', styles: 'styles/widgets', - php: 'widgets/src/blocks', + php: 'scripts/widgets/blocks', }, ], }, @@ -207,7 +211,7 @@ function copyBlockAssets( config ) { for ( const source of config.sources ) { const scriptsSrc = path.join( gutenbergBuildDir, source.scripts ); const stylesSrc = path.join( gutenbergBuildDir, source.styles ); - const phpSrc = path.join( gutenbergPackagesDir, source.php ); + const phpSrc = path.join( gutenbergBuildDir, source.php ); if ( ! fs.existsSync( scriptsSrc ) ) { continue; @@ -241,7 +245,7 @@ function copyBlockAssets( config ) { blockDest, { recursive: true, - // Skip PHP, copied from packages + // Skip PHP, copied from build in steps 3 & 4 filter: f => ! f.endsWith( '.php' ), } ); @@ -261,19 +265,18 @@ function copyBlockAssets( config ) { } } - // 3. Copy PHP from packages - const blockPhpSrc = path.join( phpSrc, blockName, 'index.php' ); + // 3. Copy PHP from build + const blockPhpSrc = path.join( phpSrc, `${ blockName }.php` ); + const phpDest = path.join( + wpIncludesDir, + config.destination, + `${ blockName }.php` + ); if ( fs.existsSync( blockPhpSrc ) ) { - const phpDest = path.join( - wpIncludesDir, - config.destination, - `${ blockName }.php` - ); - const content = fs.readFileSync( blockPhpSrc, 'utf8' ); - fs.writeFileSync( phpDest, content ); + fs.copyFileSync( blockPhpSrc, phpDest ); } - // 4. Copy PHP subdirectories from packages (e.g., shared/helpers.php) + // 4. Copy PHP subdirectories from build (e.g., navigation-link/shared/*.php) const blockPhpDir = path.join( phpSrc, blockName ); if ( fs.existsSync( blockPhpDir ) ) { const rootIndex = path.join( blockPhpDir, 'index.php' ); @@ -557,7 +560,7 @@ function generateBlockRegistrationFiles() { // Generate require-dynamic-blocks.php const dynamicContent = ` `\t'${ name }',` ).join( '\n' ) } @@ -754,8 +757,12 @@ function parsePHPArray( phpArrayContent ) { } else { currentPart += char; if ( ! inString ) { - if ( char === '(' ) depth++; - if ( char === ')' ) depth--; + if ( char === '(' ) { + depth++; + } + if ( char === ')' ) { + depth--; + } } } } @@ -830,10 +837,12 @@ function parsePHPArray( phpArrayContent ) { function transformPHPContent( content ) { let transformed = content; - // Fix boot module asset file path for Core's different directory structure - // FROM: __DIR__ . '/../../modules/boot/index.min.asset.php' - // TO: ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php' - // This is needed because Core copies modules to a different location than the plugin structure + /* + * Fix boot module asset file path for Core's different directory structure. + * FROM: __DIR__ . '/../../modules/boot/index.min.asset.php' + * TO: ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php' + * This is needed because Core copies modules to a different location than the plugin structure. + */ transformed = transformed.replace( /__DIR__\s*\.\s*['"]\/\.\.\/\.\.\/modules\/boot\/index\.min\.asset\.php['"]/g, "ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'" @@ -849,9 +858,11 @@ function transformPHPContent( content ) { * @return {string} Transformed content. */ function transformManifestPHP( content ) { - // Remove 'gutenberg' text domain from _x() calls - // FROM: _x( '...', 'icon label', 'gutenberg' ) - // TO: _x( '...', 'icon label' ) + /* + * Remove 'gutenberg' text domain from _x() calls. + * FROM: _x( '...', 'icon label', 'gutenberg' ) + * TO: _x( '...', 'icon label' ) + */ const transformedContent = content.replace( /_x\(\s*([^,]+),\s*([^,]+),\s*['"]gutenberg['"]\s*\)/g, '_x( $1, $2 )' @@ -869,12 +880,21 @@ async function main() { // Verify Gutenberg build exists if ( ! fs.existsSync( gutenbergBuildDir ) ) { console.error( 'āŒ Gutenberg build directory not found' ); - console.error( ' Run: node tools/gutenberg/build-gutenberg.js' ); + console.error( ' In local repository mode: run `npm run grunt gutenberg:build`' ); + console.error( ' In default mode: run `npm run grunt gutenberg:download`' ); process.exit( 1 ); } console.log( 'āœ… Gutenberg build found' ); + // In local repo mode, warn if HEAD has moved since the last checkout. + if ( isGutenbergRepoClone() ) { + checkRepoHash(); + } + + // Warn if the build directory has changed since the last copy. + checkDirHash(); + // 1. Copy PHP infrastructure console.log( '\nšŸ“¦ Copying PHP infrastructure...' ); const phpConfig = COPY_CONFIG.phpInfrastructure; @@ -916,9 +936,11 @@ async function main() { const scriptsSrc = path.join( gutenbergBuildDir, scriptsConfig.source ); const scriptsDest = path.join( wpIncludesDir, scriptsConfig.destination ); - // Transform function to remove source map comments from all JS files. - // Only match actual source map comments at the start of a line (possibly - // with whitespace), not occurrences inside string literals. + /* + * Transform function to remove source map comments from all JS files. + * Only match actual source map comments at the start of a line (possibly + * with whitespace), not occurrences inside string literals. + */ const removeSourceMaps = ( content ) => { return content.replace( /^\s*\/\/# sourceMappingURL=.*$/gm, '' ).trimEnd(); }; @@ -936,8 +958,10 @@ async function main() { scriptsConfig.directoryRenames && scriptsConfig.directoryRenames[ entry.name ] ) { - // Copy special directories with rename (vendors/ → vendor/) - // Only copy react-jsx-runtime from vendors (react and react-dom come from Core's node_modules) + /* + * Copy special directories with rename (vendors/ → vendor/). + * Only copy react-jsx-runtime from vendors (react and react-dom come from Core's node_modules). + */ const destName = scriptsConfig.directoryRenames[ entry.name ]; const dest = path.join( scriptsDest, destName ); @@ -975,8 +999,10 @@ async function main() { ); } } else { - // Flatten package structure: package-name/index.js → package-name.js - // This matches Core's expected file structure + /* + * Flatten package structure: package-name/index.js → package-name.js. + * This matches Core's expected file structure. + */ const packageFiles = fs.readdirSync( src ); for ( const file of packageFiles ) { @@ -1130,6 +1156,7 @@ async function main() { console.log( ` Script modules: ${ modulesDest }` ); console.log( ` Styles: ${ stylesDest }` ); console.log( ` Blocks: ${ blocksDest }` ); + } // Run main function diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js new file mode 100644 index 0000000000000..366cfa3cdc300 --- /dev/null +++ b/tools/gutenberg/download.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +/** + * 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. + * + * 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 + * package on GitHub Container Registry. + * + * @package WordPress + */ + +const { spawn } = require( 'child_process' ); +const fs = require( 'fs' ); +const { Writable } = require( 'stream' ); +const { pipeline } = require( 'stream/promises' ); +const zlib = require( 'zlib' ); +const { rootDir, gutenbergDir, readGutenbergConfig, verifyGutenbergVersion, writeDirHash, isLocalRepoMode } = require( './utils' ); + +/** + * Main execution function. + * + * @param {boolean} force - Whether to force a fresh download even if the gutenberg directory exists. + */ +async function main( force ) { + if ( isLocalRepoMode() ) { + console.log( 'ā„¹ļø Skipping Gutenberg download: GUTENBERG_LOCAL_REPO=true is set.' ); + console.log( ' Use `npm run grunt gutenberg:checkout` to set up the local repository.' ); + return; + } + + console.log( 'šŸ” Checking Gutenberg configuration...' ); + + /* + * Read Gutenberg configuration from package.json. + * + * Note: ghcr stands for GitHub Container Registry where wordpress-develop ready builds of the Gutenberg plugin + * are published on every repository push event. + */ + let sha, ghcrRepo; + try { + ( { sha, ghcrRepo } = readGutenbergConfig() ); + console.log( ` SHA: ${ sha }` ); + console.log( ` GHCR repository: ${ ghcrRepo }` ); + } catch ( error ) { + console.error( 'āŒ Error reading package.json:', error.message ); + 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 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 ); + } + + // 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 }` ); + } + + /* + * 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 ); + } ); + + /* + * 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 ); + } + } + + if ( downloaded ) { + /* + * Record a baseline hash of the freshly extracted directory before + * verifying, so that verifyGutenbergVersion()'s checkDirHash() call + * compares against the fresh state and does not produce a stale warning. + */ + writeDirHash(); + } + + // Verify the downloaded version matches the expected SHA. + verifyGutenbergVersion(); + + if ( downloaded ) { + console.log( '\nāœ… Gutenberg download complete!' ); + } +} + +// Run main function. +const force = process.argv.includes( '--force' ); +main( force ).catch( ( error ) => { + console.error( 'āŒ Unexpected error:', error ); + process.exit( 1 ); +} ); diff --git a/tools/gutenberg/sync-gutenberg.js b/tools/gutenberg/sync-gutenberg.js deleted file mode 100644 index 814188d920cfa..0000000000000 --- a/tools/gutenberg/sync-gutenberg.js +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env node - -/** - * Sync Gutenberg Script - * - * This script ensures Gutenberg is checked out and built for the correct ref. - * It follows the same pattern as install-changed: - * - Stores the built ref in .gutenberg-hash - * - Compares current package.json ref with stored hash - * - Only runs checkout + build when they differ - * - * @package WordPress - */ - -const { spawn } = require( 'child_process' ); -const fs = require( 'fs' ); -const path = require( 'path' ); - -// Paths -const rootDir = path.resolve( __dirname, '../..' ); -const gutenbergDir = path.join( rootDir, 'gutenberg' ); -const gutenbergBuildDir = path.join( gutenbergDir, 'build' ); -const packageJsonPath = path.join( rootDir, 'package.json' ); -const hashFilePath = path.join( rootDir, '.gutenberg-hash' ); - -/** - * Execute a command and return a promise. - * - * @param {string} command - Command to execute. - * @param {string[]} args - Command arguments. - * @param {Object} options - Spawn options. - * @return {Promise} Promise that resolves when command completes. - */ -function exec( command, args, options = {} ) { - return new Promise( ( resolve, reject ) => { - const child = spawn( command, args, { - cwd: options.cwd || rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', - ...options, - } ); - - child.on( 'close', ( code ) => { - if ( code !== 0 ) { - reject( - new Error( - `${ command } ${ args.join( ' ' ) } failed with code ${ code }` - ) - ); - } else { - resolve(); - } - } ); - - child.on( 'error', reject ); - } ); -} - -/** - * Read the expected Gutenberg ref from package.json. - * - * @return {string} The Gutenberg ref. - */ -function getExpectedRef() { - const packageJson = JSON.parse( fs.readFileSync( packageJsonPath, 'utf8' ) ); - const ref = packageJson.gutenberg?.ref; - - if ( ! ref ) { - throw new Error( 'Missing "gutenberg.ref" in package.json' ); - } - - return ref; -} - -/** - * Read the stored hash from .gutenberg-hash file. - * - * @return {string|null} The stored ref, or null if file doesn't exist. - */ -function getStoredHash() { - try { - return fs.readFileSync( hashFilePath, 'utf8' ).trim(); - } catch ( error ) { - return null; - } -} - -/** - * Write the ref to .gutenberg-hash file. - * - * @param {string} ref - The ref to store. - */ -function writeHash( ref ) { - fs.writeFileSync( hashFilePath, ref + '\n' ); -} - -/** - * Check if Gutenberg build exists. - * - * @return {boolean} True if build directory exists. - */ -function hasBuild() { - return fs.existsSync( gutenbergBuildDir ); -} - -/** - * Main execution function. - */ -async function main() { - console.log( 'šŸ” Checking Gutenberg sync status...' ); - - const expectedRef = getExpectedRef(); - const storedHash = getStoredHash(); - - console.log( ` Expected ref: ${ expectedRef }` ); - console.log( ` Stored hash: ${ storedHash || '(none)' }` ); - - // Check if we need to rebuild - if ( storedHash === expectedRef && hasBuild() ) { - console.log( 'āœ… Gutenberg is already synced and built' ); - return; - } - - if ( storedHash !== expectedRef ) { - console.log( '\nšŸ“¦ Gutenberg ref has changed, rebuilding...' ); - } else { - console.log( '\nšŸ“¦ Gutenberg build not found, building...' ); - } - - // Run checkout - console.log( '\nšŸ”„ Running gutenberg:checkout...' ); - await exec( 'node', [ 'tools/gutenberg/checkout-gutenberg.js' ] ); - - // Run build - console.log( '\nšŸ”„ Running gutenberg:build...' ); - await exec( 'node', [ 'tools/gutenberg/build-gutenberg.js' ] ); - - // Write the hash after successful build - writeHash( expectedRef ); - console.log( `\nāœ… Updated .gutenberg-hash to ${ expectedRef }` ); - - console.log( '\nāœ… Gutenberg sync complete!' ); -} - -// Run main function -main().catch( ( error ) => { - console.error( 'āŒ Sync failed:', error.message ); - process.exit( 1 ); -} ); diff --git a/tools/gutenberg/sync.js b/tools/gutenberg/sync.js new file mode 100644 index 0000000000000..8923eee95a60b --- /dev/null +++ b/tools/gutenberg/sync.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +/** + * Gutenberg Setup Dispatcher + * + * This is the postinstall entry point for Gutenberg setup. It detects whether + * the contributor is in local repository mode (GUTENBERG_LOCAL_REPO=true in + * their .env file) and runs the appropriate setup flow: + * + * - Default mode: download pre-built zip → copy to Core + * - Local repository mode: checkout git repo → build from source → copy to Core + * + * @package WordPress + */ + +const { spawn } = require( 'child_process' ); +const path = require( 'path' ); +const { isLocalRepoMode } = require( './utils' ); + +const rootDir = path.resolve( __dirname, '../..' ); + +/** + * Spawn a Node.js script and inherit stdio so output is visible in the terminal. + * + * @param {string} scriptPath - Path to the script, relative to the repo root. + * @param {string[]} args - Arguments to pass to the script. + * @return {Promise} Promise that resolves when the script exits successfully. + */ +function runScript( scriptPath, args = [] ) { + return new Promise( ( resolve, reject ) => { + const child = spawn( process.execPath, [ scriptPath, ...args ], { + cwd: rootDir, + stdio: 'inherit', + } ); + child.on( 'close', ( code ) => { + if ( code !== 0 ) { + reject( new Error( `${ scriptPath } exited with code ${ code }` ) ); + } else { + resolve(); + } + } ); + child.on( 'error', reject ); + } ); +} + +async function main() { + if ( isLocalRepoMode() ) { + console.log( 'ā„¹ļø Local Gutenberg repository mode active (GUTENBERG_LOCAL_REPO=true).\n' ); + await runScript( 'tools/gutenberg/checkout.js' ); + await runScript( 'tools/gutenberg/build.js' ); + } else { + await runScript( 'tools/gutenberg/download.js' ); + } + + await runScript( 'tools/gutenberg/copy.js', [ '--dev' ] ); +} + +main().catch( ( error ) => { + console.error( 'āŒ Gutenberg setup failed:', error.message ); + process.exit( 1 ); +} ); diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js new file mode 100644 index 0000000000000..8ccd62cadb781 --- /dev/null +++ b/tools/gutenberg/utils.js @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +/** + * Gutenberg build utilities. + * + * Shared helpers used by the Gutenberg download and checkout scripts. When run + * directly, verifies that the installed Gutenberg build matches the SHA in + * package.json. + * + * @package WordPress + */ + +const crypto = require( 'crypto' ); +const fs = require( 'fs' ); +const path = require( 'path' ); +const { spawnSync } = require( 'child_process' ); +const dotenv = require( 'dotenv' ); +const dotenvExpand = require( 'dotenv-expand' ); +const rootDir = path.resolve( __dirname, '../..' ); +const gutenbergDir = path.join( rootDir, 'gutenberg' ); + +/* + * Hash file paths (all at the repo root so they are not committed and are + * covered by the top-level .gitignore entries). + * + * .gutenberg-dir-hash — SHA-256 of gutenberg/ excluding build/, node_modules/, + * and .git/. Written after a successful build (clone mode) + * or download (download mode). Checked at verify time and + * before building to detect source changes. + * + * .gutenberg-repo-hash — The git commit SHA that was checked out into + * gutenberg/ by checkout.js. + * Written after checkout, checked before build and copy + * to warn when HEAD has moved away from the expected SHA. + */ +const gutenbergDirHashFile = path.join( rootDir, '.gutenberg-dir-hash' ); +const gutenbergRepoHashFile = path.join( rootDir, '.gutenberg-repo-hash' ); + +/** + * Directories inside gutenberg/ to exclude when hashing. + * These are build artefacts or dependency caches, not source files. + */ +const DIR_HASH_EXCLUDE = new Set( [ '.git', 'node_modules', 'phpunit', 'platform-docs', 'tests', 'vendor' ] ); + +/* + * Load .env so GUTENBERG_LOCAL_REPO and other vars are available regardless + * of how this module is invoked (grunt task, direct node call, postinstall). + */ +dotenvExpand.expand( dotenv.config( { path: path.join( rootDir, '.env' ) } ) ); + +/** + * Read Gutenberg configuration from package.json. + * + * @return {{ sha: string, ghcrRepo: string }} The Gutenberg configuration. + * @throws {Error} If the configuration is missing or invalid. + */ +function readGutenbergConfig() { + const packageJson = require( path.join( rootDir, 'package.json' ) ); + const sha = packageJson.gutenberg?.sha; + const ghcrRepo = packageJson.gutenberg?.ghcrRepo; + + if ( ! sha ) { + throw new Error( 'Missing "gutenberg.sha" in package.json' ); + } + + if ( ! ghcrRepo ) { + throw new Error( 'Missing "gutenberg.ghcrRepo" in package.json' ); + } + + return { sha, ghcrRepo }; +} + +/** + * Whether the contributor has opted into local Gutenberg repository mode. + * + * Returns true only when GUTENBERG_LOCAL_REPO=true is present in the .env file + * or the current process environment. + * + * @return {boolean} Whether local repo mode is active. + */ +function isLocalRepoMode() { + return process.env.GUTENBERG_LOCAL_REPO === 'true'; +} + +/** + * Whether the gutenberg/ directory is a git repository clone. + * + * This is true when gutenberg/.git exists, which indicates a contributor has + * run `gutenberg:checkout` rather than `gutenberg:download`. + * + * @return {boolean} Whether the gutenberg directory is a git clone. + */ +function isGutenbergRepoClone() { + return fs.existsSync( path.join( gutenbergDir, '.git' ) ); +} + +/** + * 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. + * + * In git clone mode, a SHA mismatch is a warning only — the developer may + * intentionally be on a different commit. In download mode it is a hard error + * because the zip is supposed to exactly match. + */ +function verifyGutenbergVersion() { + console.log( '\nšŸ” Verifying Gutenberg version...' ); + + let sha; + try { + ( { sha } = readGutenbergConfig() ); + } catch ( error ) { + console.error( 'āŒ Error reading package.json:', error.message ); + process.exit( 1 ); + } + + if ( isGutenbergRepoClone() ) { + const result = spawnSync( 'git', [ 'rev-parse', 'HEAD' ], { + cwd: gutenbergDir, + encoding: 'utf8', + } ); + if ( result.status !== 0 ) { + console.error( 'āŒ Could not determine the current Gutenberg git commit.' ); + process.exit( 1 ); + } + const currentSha = result.stdout.trim(); + if ( currentSha !== sha ) { + console.warn( + `āš ļø Gutenberg HEAD (${ currentSha.slice( 0, 12 ) }) does not match the expected SHA (${ sha.slice( 0, 12 ) }).` + + ` Run \`npm run grunt gutenberg:checkout -- --force\` to reset to the expected SHA.` + ); + } else { + console.log( 'āœ… Version verified (git clone at expected SHA)' ); + } + + // Warn if HEAD has drifted away from the SHA recorded at checkout time. + checkRepoHash(); + + // Warn if the source has changed since the last build. + checkDirHash(); + return; + } + + /* + * Download mode: the zip contains a .gutenberg-hash file with the commit SHA + * that produced the artifact. + */ + const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' ); + try { + const installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim(); + if ( installedHash !== sha ) { + console.error( + `āŒ SHA mismatch: expected ${ sha } but the downloaded artifact was built from ${ installedHash }.` + + ` Run \`npm run grunt gutenberg:download -- --force\` to download the correct version.` + ); + process.exit( 1 ); + } + } catch ( error ) { + if ( error.code === 'ENOENT' ) { + console.error( `āŒ .gutenberg-hash not found inside gutenberg/. Run \`npm run grunt gutenberg:download\` to download Gutenberg.` ); + } else { + console.error( `āŒ ${ error.message }` ); + } + process.exit( 1 ); + } + + console.log( 'āœ… Version verified' ); + + // Warn if gutenberg/ has changed since it was downloaded. + checkDirHash(); +} + +// --------------------------------------------------------------------------- +// Directory hash helpers +// --------------------------------------------------------------------------- + +/** + * Recursively collect all regular files under a directory, sorted. + * + * Symlinks are skipped because they can point to directories and would throw + * EISDIR when read as regular files. + * + * @param {string} dir - Directory to walk. + * @param {Set} excludeDirs - Directory names to skip entirely. + * @return {string[]} Sorted list of absolute file paths. + */ +function collectFiles( dir, excludeDirs = new Set() ) { + const files = []; + for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ).sort( ( a, b ) => a.name.localeCompare( b.name ) ) ) { + if ( entry.isDirectory() && excludeDirs.has( entry.name ) ) { + continue; + } + const fullPath = path.join( dir, entry.name ); + if ( entry.isDirectory() ) { + files.push( ...collectFiles( fullPath, excludeDirs ) ); + } else if ( entry.isFile() ) { + files.push( fullPath ); + } + } + return files; +} + +/** + * Compute a deterministic SHA-256 hash of a directory's contents. + * + * Files are processed in sorted order so the result is stable across runs. + * Each file contributes its path relative to `hashRoot` and its contents to + * the hash so that renames and edits both produce a different digest. + * + * @param {string} hashRoot - Directory to hash. + * @param {Set} excludeDirs - Directory names to skip. + * @return {string} Hex digest. + */ +function computeDirHash( hashRoot, excludeDirs = new Set() ) { + const hash = crypto.createHash( 'sha256' ); + for ( const filePath of collectFiles( hashRoot, excludeDirs ) ) { + hash.update( path.relative( hashRoot, filePath ) ); + hash.update( fs.readFileSync( filePath ) ); + } + return hash.digest( 'hex' ); +} + +/** + * Compute the hash of gutenberg/ excluding build/, node_modules/, and .git/. + * + * @return {string} Hex digest. + */ +function computeGutenbergHash() { + return computeDirHash( gutenbergDir, DIR_HASH_EXCLUDE ); +} + +/** + * Write the current gutenberg/ directory hash to .gutenberg-dir-hash. + * + * Call this after a successful build (clone mode) or download (download mode) + * so subsequent runs can detect when the source has changed. + */ +function writeDirHash() { + if ( ! fs.existsSync( gutenbergDir ) ) { + return; + } + fs.writeFileSync( gutenbergDirHashFile, computeGutenbergHash() ); +} + +/** + * Check whether gutenberg/ has changed since the last time writeDirHash() ran. + * + * Returns true when .gutenberg-dir-hash does not exist (no baseline recorded + * yet) or when the stored hash differs from the current directory contents. + * + * @return {boolean} True if the directory has changed. + */ +function dirHashChanged() { + if ( ! fs.existsSync( gutenbergDirHashFile ) ) { + return true; + } + const storedHash = fs.readFileSync( gutenbergDirHashFile, 'utf8' ).trim(); + return computeGutenbergHash() !== storedHash; +} + +/** + * Warn if gutenberg/ has changed since the last build or download. + * + * Reads .gutenberg-dir-hash and compares it to the current hash. Only a + * warning is issued — the calling script is not aborted. + */ +function checkDirHash() { + if ( ! fs.existsSync( gutenbergDir ) ) { + return; + } + + if ( ! fs.existsSync( gutenbergDirHashFile ) ) { + // No stored hash yet — nothing to compare against. + return; + } + + if ( dirHashChanged() ) { + if ( isGutenbergRepoClone() ) { + console.warn( + 'āš ļø The gutenberg/ source has changed since the last build.' + + ' The Gutenberg build process will be run before building Core.' + ); + } else { + console.warn( + 'āš ļø The gutenberg/ directory has changed since it was downloaded.' + + ' Run `npm run grunt gutenberg:download -- --force` to restore the original files,' + + ' or `npm run grunt gutenberg:copy` if you intentionally modified the build output.' + ); + } + } else { + if ( isGutenbergRepoClone() ) { + console.log( 'āœ… No changes to the local Gutenberg checkout.' ); + } else { + console.log( 'āœ… Gutenberg directory contents match the downloaded version.' ); + } + } +} + +// --------------------------------------------------------------------------- +// Repository hash helpers (local repo clone mode) +// --------------------------------------------------------------------------- + +/** + * Write a git commit SHA to .gutenberg-repo-hash. + * + * Call this immediately after checkout so subsequent commands can verify that + * HEAD has not drifted away from the expected commit. + * + * @param {string} sha - The full git commit SHA that was checked out. + */ +function writeRepoHash( sha ) { + fs.writeFileSync( gutenbergRepoHashFile, sha ); +} + +/** + * Warn if the current HEAD of gutenberg/ differs from the SHA recorded in + * .gutenberg-repo-hash. + * + * A difference means the contributor has manually moved HEAD (e.g. via + * `git checkout` or `git pull`) since the last `gutenberg:checkout` run. + * Only a warning is issued — the calling script is not aborted. + */ +function checkRepoHash() { + if ( ! fs.existsSync( gutenbergRepoHashFile ) ) { + // No stored hash — checkout.js has not been run yet, nothing to compare. + return; + } + + const storedSha = fs.readFileSync( gutenbergRepoHashFile, 'utf8' ).trim(); + + const result = spawnSync( 'git', [ 'rev-parse', 'HEAD' ], { + cwd: gutenbergDir, + encoding: 'utf8', + } ); + + if ( result.status !== 0 ) { + // Can't read HEAD — not a git repo or git not available. + return; + } + + const currentSha = result.stdout.trim(); + if ( currentSha !== storedSha ) { + console.warn( + `āš ļø Gutenberg HEAD (${ currentSha.slice( 0, 12 ) }) does not match the last checked-out SHA (${ storedSha.slice( 0, 12 ) }).` + + ` HEAD has moved since the last \`gutenberg:checkout\` run.` + + ` Run \`npm run grunt gutenberg:build\` to rebuild from the current HEAD,` + + ` or \`npm run grunt gutenberg:checkout -- --force\` to reset to the expected commit.` + ); + } +} + +module.exports = { + rootDir, + gutenbergDir, + readGutenbergConfig, + isLocalRepoMode, + isGutenbergRepoClone, + verifyGutenbergVersion, + computeDirHash, + computeGutenbergHash, + writeDirHash, + dirHashChanged, + checkDirHash, + writeRepoHash, + checkRepoHash, +}; + +if ( require.main === module ) { + verifyGutenbergVersion(); +} diff --git a/webpack.config.js b/webpack.config.js index 29ebbd696b875..2fbda4cf10165 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,7 +14,7 @@ module.exports = function ( // Only building Core-specific media files and development scripts. // Blocks, packages, script modules, and vendors are now sourced from - // the Gutenberg build (see tools/gutenberg/copy-gutenberg-build.js). + // the Gutenberg build (see tools/gutenberg/copy.js). // Note: developmentConfig returns an array of configs, so we spread it. const config = [ mediaConfig( env ),