Thanks to visit codestin.com
Credit goes to core.trac.wordpress.org

Make WordPress Core

Changeset 62422


Ignore:
Timestamp:
05/27/2026 06:18:14 PM (4 weeks 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.