Make WordPress Core

Changeset 62422


Ignore:
Timestamp:
05/27/2026 06:18:14 PM (4 months ago)
Author:
desrosj
Message:

Build/Test Tools: Support testing unmerged changes from Gutenberg.

The gutenberg.sha property in the package.json file expects a full-length SHA value to be specified in order to download the built assets from the Gutenberg repository and include the files through the build script.

After https://github.com/WordPress/gutenberg/pull/78211, assets are now published for pull_request events in addition to push so long as the HEAD branch of the pull request exists in the repository and not in a fork. This makes the built assets for pull requests available for testing within wordpress-develop before they are merged through a pr-### tag on the GHCR package.

To test a given pull request, just set the gutenberg.sha value to the corresponding pr-### tag. In addition to pull request-specific tags, each wp/X.Y, release/X.Y, and trunk now have a tag that can be used to test the latest changes. Each time build or build:dev is called the script will attempt to confirm the latest version of the assets are present locally, pulling down the latest changes when an update is available.

All of the tags described in this changeset are mutable, so they should never be committed. Only immutable SHA values should be used for gutenberg.sha in commits to this repository.

This commit also adds the related JavaScript files to the TypeScript configuration file.

Props westonruter, jorbin, manhar.
Fixes #65224.

Location:
trunk
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • trunk/package-lock.json

    r62411 r62422  
    4848                                "@types/htmlhint": "1.1.5",
    4949                                "@types/jquery": "3.5.34",
     50                                "@types/node": "20.19.41",
    5051                                "@types/underscore": "1.13.0",
    5152                                "@wordpress/e2e-test-utils-playwright": "1.42.0",
     
    56115612                },
    56125613                "node_modules/@types/node": {
    5613                         "version": "14.14.20",
    5614                         "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz",
    5615                         "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==",
    5616                         "dev": true
     5614                        "version": "20.19.41",
     5615                        "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
     5616                        "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
     5617                        "dev": true,
     5618                        "license": "MIT",
     5619                        "dependencies": {
     5620                                "undici-types": "~6.21.0"
     5621                        }
    56175622                },
    56185623                "node_modules/@types/node-forge": {
     
    3203932044                        }
    3204032045                },
     32046                "node_modules/undici-types": {
     32047                        "version": "6.21.0",
     32048                        "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
     32049                        "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
     32050                        "dev": true,
     32051                        "license": "MIT"
     32052                },
    3204132053                "node_modules/unicode-canonical-property-names-ecmascript": {
    3204232054                        "version": "2.0.1",
  • trunk/package.json

    r62411 r62422  
    3636                "@types/htmlhint": "1.1.5",
    3737                "@types/jquery": "3.5.34",
     38                "@types/node": "20.19.41",
    3839                "@types/underscore": "1.13.0",
    3940                "@wordpress/e2e-test-utils-playwright": "1.42.0",
  • trunk/tools/gutenberg/download.js

    r62021 r62422  
    99 *
    1010 * The artifact is identified by the "gutenberg.sha" value in the root
    11  * package.json, which is used as the OCI image tag for the gutenberg-build
    12  * package on GitHub Container Registry.
     11 * package.json, which is used as the OCI tag for the gutenberg-wp-develop-build
     12 * package on GitHub Container Registry. The value is normally a Git SHA, but
     13 * may also be a mutable tag (e.g. "trunk", "pr-12345") in a pull request that
     14 * wants to track the latest build of a stream. When the ref is a mutable tag,
     15 * the script resolves it to the immutable SHA tag for the actual blob fetch
     16 * and falls back to the mutable tag's manifest when the immutable tag is
     17 * unavailable.
    1318 *
    1419 * @package WordPress
     
    1722const { spawn } = require( 'child_process' );
    1823const fs = require( 'fs' );
    19 const { Writable } = require( 'stream' );
     24const { Readable } = require( 'stream' );
    2025const { pipeline } = require( 'stream/promises' );
    2126const zlib = require( 'zlib' );
    22 const { gutenbergDir, readGutenbergConfig } = require( './utils' );
     27const {
     28        gutenbergDir,
     29        readGutenbergConfig,
     30        fetchGhcrToken,
     31        fetchManifest,
     32} = require( './utils' );
     33
     34/**
     35 * Resolve the manifest to use for downloading.
     36 *
     37 * For immutable refs (SHA values), the ref is used directly.
     38 *
     39 * For mutable refs, the mutable tag's manifest is fetched first and the
     40 * `image.revision` annotation is read. The corresponding immutable SHA tag is
     41 * then preferred. If the immutable SHA tag is unavailable, fall back to the
     42 * manifest already fetched via the mutable tag.
     43 *
     44 * @param {{ ref: string, ghcrRepo: string, isMutable: boolean }} config
     45 * @param {string} token
     46 * @return {Promise<{ manifest: Record<string, any>, resolvedRef: string }>}
     47 */
     48async function resolveDownloadManifest( config, token ) {
     49        const { ref, ghcrRepo, isMutable } = config;
     50
     51        const initialManifest = await fetchManifest( ref, ghcrRepo, token );
     52
     53        if ( ! isMutable ) {
     54                return { manifest: initialManifest, resolvedRef: ref };
     55        }
     56
     57        const revision =
     58                initialManifest?.annotations?.[ 'org.opencontainers.image.revision' ];
     59        if ( ! revision ) {
     60                console.log(
     61                        `ℹ️  No image.revision annotation on "${ ref }"; using mutable tag for download.`
     62                );
     63                return { manifest: initialManifest, resolvedRef: ref };
     64        }
     65
     66        try {
     67                const immutableManifest = await fetchManifest( revision, ghcrRepo, token );
     68                return { manifest: immutableManifest, resolvedRef: revision };
     69        } catch ( error ) {
     70                if ( /** @type {{ status?: number }} */ ( error ).status === 404 ) {
     71                        console.log(
     72                                `ℹ️  Immutable SHA tag ${ revision } unavailable; falling back to mutable tag "${ ref }".`
     73                        );
     74                        return { manifest: initialManifest, resolvedRef: ref };
     75                }
     76                throw error;
     77        }
     78}
    2379
    2480/**
     
    3288         *
    3389         * Note: ghcr stands for GitHub Container Registry where wordpress-develop ready builds of the Gutenberg plugin
    34          * are published on every repository push event.
     90         * are published by the Gutenberg build-plugin-zip workflow.
    3591         */
    36         let sha, ghcrRepo;
    37         try {
    38                 ( { sha, ghcrRepo } = readGutenbergConfig() );
    39                 console.log( `   SHA: ${ sha }` );
    40                 console.log( `   GHCR repository: ${ ghcrRepo }` );
    41         } catch ( error ) {
    42                 console.error( '❌ Error reading package.json:', error.message );
     92        let config;
     93        try {
     94                config = readGutenbergConfig();
     95                console.log(
     96                        `   Ref: ${ config.ref }${
     97                                config.isMutable ? ' (mutable tag)' : ''
     98                        }`
     99                );
     100                console.log( `   GHCR repository: ${ config.ghcrRepo }` );
     101        } catch ( error ) {
     102                console.error( '❌ Error reading package.json:', /** @type {Error} */ ( error ).message );
    43103                process.exit( 1 );
    44104        }
     
    48108        let token;
    49109        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                 }
     110                token = await fetchGhcrToken( config.ghcrRepo );
    59111                console.log( '✅ Token acquired' );
    60112        } catch ( error ) {
    61                 console.error( '❌ Failed to fetch token:', error.message );
    62                 process.exit( 1 );
    63         }
    64 
    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         }
     113                console.error( '❌ Failed to fetch token:', /** @type {Error} */ ( error ).message );
     114                process.exit( 1 );
     115        }
     116
     117        // Step 2: Resolve the manifest to use for download.
     118        console.log( `\n📋 Fetching manifest for ${ config.ref }...` );
     119        let manifest, resolvedRef;
     120        try {
     121                ( { manifest, resolvedRef } = await resolveDownloadManifest(
     122                        config,
     123                        token
     124                ) );
     125                if ( resolvedRef !== config.ref ) {
     126                        console.log( `   Resolved to immutable SHA tag: ${ resolvedRef }` );
     127                }
     128        } catch ( error ) {
     129                console.error( '❌ Failed to fetch manifest:', /** @type {Error} */ ( error ).message );
     130                process.exit( 1 );
     131        }
     132
     133        const digest = manifest?.layers?.[ 0 ]?.digest;
     134        if ( ! digest ) {
     135                console.error( '❌ No layer digest found in manifest' );
     136                process.exit( 1 );
     137        }
     138        console.log( `✅ Blob digest: ${ digest }` );
    88139
    89140        // Remove existing gutenberg directory so the extraction is clean.
     
    101152        console.log( `\n📥 Downloading and extracting artifact...` );
    102153        try {
    103                 const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, {
     154                const response = await fetch( `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, {
    104155                        headers: {
    105156                                Authorization: `Bearer ${ token }`,
     
    108159                if ( ! response.ok ) {
    109160                        throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` );
     161                }
     162                if ( ! response.body ) {
     163                        throw new Error( 'Blob response has no body' );
    110164                }
    111165
     
    118172                } );
    119173
     174                /** @type {Promise<void>} */
    120175                const tarDone = new Promise( ( resolve, reject ) => {
    121176                        tar.on( 'close', ( code ) => {
     
    135190                 */
    136191                await pipeline(
    137                         response.body,
     192                        Readable.fromWeb(
     193                                /** @type {import('stream/web').ReadableStream} */ ( response.body )
     194                        ),
    138195                        zlib.createGunzip(),
    139                         Writable.toWeb( tar.stdin ),
     196                        tar.stdin,
    140197                );
    141198
     
    144201                console.log( '✅ Download and extraction complete' );
    145202        } catch ( error ) {
    146                 console.error( '❌ Download/extraction failed:', error.message );
     203                console.error( '❌ Download/extraction failed:', /** @type {Error} */ ( error ).message );
    147204                process.exit( 1 );
    148205        }
  • trunk/tools/gutenberg/utils.js

    r62052 r62422  
    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,
    8  * and automatically downloads the correct version when needed.
     7 * verifies that the installed Gutenberg build matches the value in
     8 * package.json and automatically downloads the correct version when needed.
    99 *
    1010 * @package WordPress
     
    2020const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' );
    2121
     22// A 40-character lowercase hex string is treated as an immutable Git SHA tag.
     23// Anything else (e.g. "trunk", "release-19.5", "pr-12345") is treated as a
     24// mutable tag published by the Gutenberg build-plugin-zip workflow.
     25const SHA_PATTERN = /^[a-f0-9]{40}$/i;
     26
     27const MANIFEST_ACCEPT = 'application/vnd.oci.image.manifest.v1+json';
     28
    2229/**
    2330 * Read Gutenberg configuration from package.json.
    2431 *
    25  * @return {{ sha: string, ghcrRepo: string }} The Gutenberg configuration.
     32 * `gutenberg.sha` is always committed as a pinned SHA, but a contributor
     33 * may temporarily set it to a mutable tag published by the Gutenberg repository
     34 * (e.g. "trunk", "release-19.5", "pr-12345") to track the latest build of that
     35 * stream or test changes before merging.
     36 *
     37 * @return {{ ref: string, ghcrRepo: string, isMutable: boolean }} The
     38 *     resolved configuration. `ref` is the OCI tag to look up; `isMutable`
     39 *     is true when the value is not a SHA-shaped string.
    2640 * @throws {Error} If the configuration is missing or invalid.
    2741 */
    2842function readGutenbergConfig() {
    2943        const packageJson = require( path.join( rootDir, 'package.json' ) );
    30         const sha = packageJson.gutenberg?.sha;
     44        const ref = packageJson.gutenberg?.sha;
    3145        const ghcrRepo = packageJson.gutenberg?.ghcrRepo;
    3246
    33         if ( ! sha ) {
     47        if ( ! ref ) {
    3448                throw new Error( 'Missing "gutenberg.sha" in package.json' );
    3549        }
     
    3953        }
    4054
    41         return { sha, ghcrRepo };
     55        const isMutable = ! SHA_PATTERN.test( ref );
     56
     57        return { ref, ghcrRepo, isMutable };
     58}
     59
     60/**
     61 * Fetch an anonymous pull token for the given GHCR repository.
     62 *
     63 * @param {string} ghcrRepo The "owner/repo/package" path on ghcr.io.
     64 * @return {Promise<string>} The bearer token.
     65 */
     66async function fetchGhcrToken( ghcrRepo ) {
     67        const response = await fetch(
     68                `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io`
     69        );
     70        if ( ! response.ok ) {
     71                throw new Error(
     72                        `Failed to fetch GHCR token: ${ response.status } ${ response.statusText }`
     73                );
     74        }
     75        const data = await response.json();
     76        if ( ! data.token ) {
     77                throw new Error( 'No token in GHCR response' );
     78        }
     79        return data.token;
     80}
     81
     82/**
     83 * Fetch a manifest from GHCR by tag.
     84 *
     85 * @param {string} ref      The tag (SHA or mutable tag).
     86 * @param {string} ghcrRepo The "owner/repo/package" path on ghcr.io.
     87 * @param {string} token    Bearer token from fetchGhcrToken.
     88 * @return {Promise<Record<string, any>>} Parsed manifest JSON.
     89 */
     90async function fetchManifest( ref, ghcrRepo, token ) {
     91        const response = await fetch(
     92                `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ ref }`,
     93                {
     94                        headers: {
     95                                Authorization: `Bearer ${ token }`,
     96                                Accept: MANIFEST_ACCEPT,
     97                        },
     98                }
     99        );
     100        if ( ! response.ok ) {
     101                const error = /** @type {Error & { status?: number }} */ (
     102                        new Error(
     103                                `Failed to fetch manifest for "${ ref }": ${ response.status } ${ response.statusText }`
     104                        )
     105                );
     106                error.status = response.status;
     107                throw error;
     108        }
     109        return response.json();
     110}
     111
     112/**
     113 * Resolve the expected source SHA for the configured ref.
     114 *
     115 * For immutable refs (SHA), the expected SHA is the ref itself and no network
     116 * call is required. For mutable refs, the manifest's
     117 * `org.opencontainers.image.revision` annotation is fetched and returned,
     118 * which reflects the SHA value published to the mutable tag most recently.
     119 *
     120 * @param {{ ref: string, ghcrRepo: string, isMutable: boolean }} config
     121 * @return {Promise<string>} The expected SHA.
     122 */
     123async function resolveExpectedSha( { ref, ghcrRepo, isMutable } ) {
     124        if ( ! isMutable ) {
     125                return ref;
     126        }
     127
     128        const token = await fetchGhcrToken( ghcrRepo );
     129        const manifest = await fetchManifest( ref, ghcrRepo, token );
     130        const revision =
     131                manifest?.annotations?.[ 'org.opencontainers.image.revision' ];
     132        if ( ! revision ) {
     133                throw new Error(
     134                        `Manifest for "${ ref }" has no org.opencontainers.image.revision annotation`
     135                );
     136        }
     137        return revision;
    42138}
    43139
     
    60156
    61157/**
    62  * Verify that the installed Gutenberg version matches the expected SHA in
    63  * package.json. Automatically downloads the correct version when the directory
    64  * is missing, the hash file is absent, or the hash does not match. Logs
    65  * progress to the console and exits with a non-zero code on failure.
    66  */
    67 function verifyGutenbergVersion() {
     158 * Verify that the installed Gutenberg version matches the expected SHA.
     159 *
     160 * For SHA refs, the expected SHA is the configured value. For mutable refs,
     161 * the expected SHA is whatever the mutable tag currently points to in GHCR
     162 * (read from the manifest's image.revision annotation). The installed
     163 * `.gutenberg-hash` is compared against the expected SHA; on mismatch, a
     164 * fresh download is triggered.
     165 */
     166async function verifyGutenbergVersion() {
    68167        console.log( '\n🔍 Verifying Gutenberg version...' );
    69168
    70         let sha;
     169        let config;
    71170        try {
    72                 ( { sha } = readGutenbergConfig() );
     171                config = readGutenbergConfig();
    73172        } catch ( error ) {
    74                 console.error( '❌ Error reading package.json:', error.message );
    75                 process.exit( 1 );
     173                console.error( '❌ Error reading package.json:', /** @type {Error} */ ( error ).message );
     174                process.exit( 1 );
     175        }
     176
     177        const { ref, isMutable } = config;
     178        console.log(
     179                `   Ref: ${ ref }${ isMutable ? ' (mutable tag)' : '' }`
     180        );
     181
     182        let expectedSha;
     183        try {
     184                expectedSha = await resolveExpectedSha( config );
     185        } catch ( error ) {
     186                console.error( '❌ Failed to resolve expected SHA:', /** @type {Error} */ ( error ).message );
     187                process.exit( 1 );
     188        }
     189
     190        if ( isMutable ) {
     191                console.log( `   Latest build for "${ ref }": ${ expectedSha }` );
    76192        }
    77193
     
    85201                        installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim();
    86202                } catch ( error ) {
    87                         if ( error.code !== 'ENOENT' ) {
    88                                 console.error( `❌ ${ error.message }` );
     203                        const err = /** @type {NodeJS.ErrnoException} */ ( error );
     204                        if ( err.code !== 'ENOENT' ) {
     205                                console.error( `❌ ${ err.message }` );
    89206                                process.exit( 1 );
    90207                        }
     
    94211                        console.log( 'ℹ️  Hash file not found. Downloading expected version...' );
    95212                        downloadGutenberg();
    96                 } else if ( installedHash !== sha ) {
    97                         console.log( `ℹ️  Hash mismatch (found ${ installedHash }, expected ${ sha }). Downloading expected version...` );
     213                } else if ( installedHash !== expectedSha ) {
     214                        console.log( `ℹ️  Hash mismatch (found ${ installedHash }, expected ${ expectedSha }). Downloading expected version...` );
    98215                        downloadGutenberg();
    99216                }
     
    103220        try {
    104221                const installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim();
    105                 if ( installedHash !== sha ) {
    106                         console.error( `❌ SHA mismatch after download: expected ${ sha } but found ${ installedHash }.` );
     222                if ( installedHash !== expectedSha ) {
     223                        console.error( `❌ SHA mismatch after download: expected ${ expectedSha } but found ${ installedHash }.` );
    107224                        process.exit( 1 );
    108225                }
    109226        } catch ( error ) {
    110                 if ( error.code === 'ENOENT' ) {
     227                const err = /** @type {NodeJS.ErrnoException} */ ( error );
     228                if ( err.code === 'ENOENT' ) {
    111229                        console.error( '❌ .gutenberg-hash not found after download. This is unexpected.' );
    112230                } else {
    113                         console.error( `❌ ${ error.message }` );
     231                        console.error( `❌ ${ err.message }` );
    114232                }
    115233                process.exit( 1 );
     
    119237}
    120238
    121 module.exports = { rootDir, gutenbergDir, readGutenbergConfig, verifyGutenbergVersion };
     239module.exports = {
     240        rootDir,
     241        gutenbergDir,
     242        readGutenbergConfig,
     243        verifyGutenbergVersion,
     244        fetchGhcrToken,
     245        fetchManifest,
     246        resolveExpectedSha,
     247};
    122248
    123249if ( require.main === module ) {
    124         verifyGutenbergVersion();
    125 }
     250        verifyGutenbergVersion().catch( ( error ) => {
     251                console.error( '❌ Unexpected error:', error );
     252                process.exit( 1 );
     253        } );
     254}
  • trunk/tsconfig.json

    r61800 r62422  
    2020                "typeRoots": [ "./typings", "./node_modules/@types" ],
    2121                "types": [
     22                        "node",
    2223                        "wp-globals",
    2324                        "codemirror/addon/lint/lint",
     
    2930                "src/js/_enqueues/wp/code-editor.js",
    3031                "src/js/_enqueues/lib/codemirror/javascript-lint.js",
    31                 "src/js/_enqueues/lib/codemirror/htmlhint-kses.js"
     32                "src/js/_enqueues/lib/codemirror/htmlhint-kses.js",
     33                "tools/gutenberg/download.js",
     34                "tools/gutenberg/utils.js"
    3235        ]
    3336}
Note: See TracChangeset for help on using the changeset viewer.