diff --git a/package-lock.json b/package-lock.json index c043f24fa34c5..af0e9bc31a76b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@types/espree": "10.1.0", "@types/htmlhint": "1.1.5", "@types/jquery": "3.5.34", + "@types/node": "20.19.41", "@types/underscore": "1.13.0", "@wordpress/e2e-test-utils-playwright": "1.42.0", "@wordpress/prettier-config": "4.42.0", @@ -5634,10 +5635,14 @@ "dev": true }, "node_modules/@types/node": { - "version": "14.14.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", - "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", - "dev": true + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/node-forge": { "version": "1.3.11", @@ -32203,6 +32208,13 @@ "node": "*" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", diff --git a/package.json b/package.json index 396ae5589560b..03fc5f7df0700 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/espree": "10.1.0", "@types/htmlhint": "1.1.5", "@types/jquery": "3.5.34", + "@types/node": "20.19.41", "@types/underscore": "1.13.0", "@wordpress/e2e-test-utils-playwright": "1.42.0", "@wordpress/prettier-config": "4.42.0", diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js index dcca072df39bd..fd76c6c7a7836 100644 --- a/tools/gutenberg/download.js +++ b/tools/gutenberg/download.js @@ -8,18 +8,74 @@ * existing gutenberg directory is removed before extraction. * * The artifact is identified by the "gutenberg.sha" value in the root - * package.json, which is used as the OCI image tag for the gutenberg-build - * package on GitHub Container Registry. + * package.json, which is used as the OCI tag for the gutenberg-wp-develop-build + * package on GitHub Container Registry. The value is normally a Git SHA, but + * may also be a mutable tag (e.g. "trunk", "pr-12345") in a pull request that + * wants to track the latest build of a stream. When the ref is a mutable tag, + * the script resolves it to the immutable SHA tag for the actual blob fetch + * and falls back to the mutable tag's manifest when the immutable tag is + * unavailable. * * @package WordPress */ const { spawn } = require( 'child_process' ); const fs = require( 'fs' ); -const { Writable } = require( 'stream' ); +const { Readable } = require( 'stream' ); const { pipeline } = require( 'stream/promises' ); const zlib = require( 'zlib' ); -const { gutenbergDir, readGutenbergConfig } = require( './utils' ); +const { + gutenbergDir, + readGutenbergConfig, + fetchGhcrToken, + fetchManifest, +} = require( './utils' ); + +/** + * Resolve the manifest to use for downloading. + * + * For immutable refs (SHA values), the ref is used directly. + * + * For mutable refs, the mutable tag's manifest is fetched first and the + * `image.revision` annotation is read. The corresponding immutable SHA tag is + * then preferred. If the immutable SHA tag is unavailable, fall back to the + * manifest already fetched via the mutable tag. + * + * @param {{ ref: string, ghcrRepo: string, isMutable: boolean }} config + * @param {string} token + * @return {Promise<{ manifest: Record, resolvedRef: string }>} + */ +async function resolveDownloadManifest( config, token ) { + const { ref, ghcrRepo, isMutable } = config; + + const initialManifest = await fetchManifest( ref, ghcrRepo, token ); + + if ( ! isMutable ) { + return { manifest: initialManifest, resolvedRef: ref }; + } + + const revision = + initialManifest?.annotations?.[ 'org.opencontainers.image.revision' ]; + if ( ! revision ) { + console.log( + `ℹ️ No image.revision annotation on "${ ref }"; using mutable tag for download.` + ); + return { manifest: initialManifest, resolvedRef: ref }; + } + + try { + const immutableManifest = await fetchManifest( revision, ghcrRepo, token ); + return { manifest: immutableManifest, resolvedRef: revision }; + } catch ( error ) { + if ( /** @type {{ status?: number }} */ ( error ).status === 404 ) { + console.log( + `ℹ️ Immutable SHA tag ${ revision } unavailable; falling back to mutable tag "${ ref }".` + ); + return { manifest: initialManifest, resolvedRef: ref }; + } + throw error; + } +} /** * Main execution function. @@ -31,15 +87,19 @@ async function main() { * 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. + * are published by the Gutenberg build-plugin-zip workflow. */ - let sha, ghcrRepo; + let config; try { - ( { sha, ghcrRepo } = readGutenbergConfig() ); - console.log( ` SHA: ${ sha }` ); - console.log( ` GHCR repository: ${ ghcrRepo }` ); + config = readGutenbergConfig(); + console.log( + ` Ref: ${ config.ref }${ + config.isMutable ? ' (mutable tag)' : '' + }` + ); + console.log( ` GHCR repository: ${ config.ghcrRepo }` ); } catch ( error ) { - console.error( '❌ Error reading package.json:', error.message ); + console.error( '❌ Error reading package.json:', /** @type {Error} */ ( error ).message ); process.exit( 1 ); } @@ -47,45 +107,36 @@ async function main() { 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' ); - } + token = await fetchGhcrToken( config.ghcrRepo ); console.log( '✅ Token acquired' ); } catch ( error ) { - console.error( '❌ Failed to fetch token:', error.message ); + console.error( '❌ Failed to fetch token:', /** @type {Error} */ ( error ).message ); process.exit( 1 ); } - // Step 2: Get the manifest to find the blob digest. - console.log( `\n📋 Fetching manifest for ${ sha }...` ); - let digest; + // Step 2: Resolve the manifest to use for download. + console.log( `\n📋 Fetching manifest for ${ config.ref }...` ); + let manifest, resolvedRef; 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' ); + ( { manifest, resolvedRef } = await resolveDownloadManifest( + config, + token + ) ); + if ( resolvedRef !== config.ref ) { + console.log( ` Resolved to immutable SHA tag: ${ resolvedRef }` ); } - console.log( `✅ Blob digest: ${ digest }` ); } catch ( error ) { - console.error( '❌ Failed to fetch manifest:', error.message ); + console.error( '❌ Failed to fetch manifest:', /** @type {Error} */ ( error ).message ); process.exit( 1 ); } + const digest = manifest?.layers?.[ 0 ]?.digest; + if ( ! digest ) { + console.error( '❌ No layer digest found in manifest' ); + process.exit( 1 ); + } + console.log( `✅ Blob digest: ${ digest }` ); + // Remove existing gutenberg directory so the extraction is clean. if ( fs.existsSync( gutenbergDir ) ) { console.log( '\n🗑️ Removing existing gutenberg directory...' ); @@ -100,7 +151,7 @@ async function main() { */ console.log( `\n📥 Downloading and extracting artifact...` ); try { - const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, { + const response = await fetch( `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, { headers: { Authorization: `Bearer ${ token }`, }, @@ -108,6 +159,9 @@ async function main() { if ( ! response.ok ) { throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` ); } + if ( ! response.body ) { + throw new Error( 'Blob response has no body' ); + } /* * Spawn tar to read from stdin and extract into gutenbergDir. @@ -117,6 +171,7 @@ async function main() { stdio: [ 'pipe', 'inherit', 'inherit' ], } ); + /** @type {Promise} */ const tarDone = new Promise( ( resolve, reject ) => { tar.on( 'close', ( code ) => { if ( code !== 0 ) { @@ -134,16 +189,18 @@ async function main() { * consistent and means tar only sees plain tar data on stdin. */ await pipeline( - response.body, + Readable.fromWeb( + /** @type {import('stream/web').ReadableStream} */ ( response.body ) + ), zlib.createGunzip(), - Writable.toWeb( tar.stdin ), + tar.stdin, ); await tarDone; console.log( '✅ Download and extraction complete' ); } catch ( error ) { - console.error( '❌ Download/extraction failed:', error.message ); + console.error( '❌ Download/extraction failed:', /** @type {Error} */ ( error ).message ); process.exit( 1 ); } diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js index dc696d5e7bfd7..43047b5ee5dd7 100644 --- a/tools/gutenberg/utils.js +++ b/tools/gutenberg/utils.js @@ -4,8 +4,8 @@ * Gutenberg build utilities. * * Shared helpers used by the Gutenberg download script. When run directly, - * verifies that the installed Gutenberg build matches the SHA in package.json, - * and automatically downloads the correct version when needed. + * verifies that the installed Gutenberg build matches the value in + * package.json and automatically downloads the correct version when needed. * * @package WordPress */ @@ -19,18 +19,32 @@ const rootDir = path.resolve( __dirname, '../..' ); const gutenbergDir = path.join( rootDir, 'gutenberg' ); const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' ); +// A 40-character lowercase hex string is treated as an immutable Git SHA tag. +// Anything else (e.g. "trunk", "release-19.5", "pr-12345") is treated as a +// mutable tag published by the Gutenberg build-plugin-zip workflow. +const SHA_PATTERN = /^[a-f0-9]{40}$/i; + +const MANIFEST_ACCEPT = 'application/vnd.oci.image.manifest.v1+json'; + /** * Read Gutenberg configuration from package.json. * - * @return {{ sha: string, ghcrRepo: string }} The Gutenberg configuration. + * `gutenberg.sha` is always committed as a pinned SHA, but a contributor + * may temporarily set it to a mutable tag published by the Gutenberg repository + * (e.g. "trunk", "release-19.5", "pr-12345") to track the latest build of that + * stream or test changes before merging. + * + * @return {{ ref: string, ghcrRepo: string, isMutable: boolean }} The + * resolved configuration. `ref` is the OCI tag to look up; `isMutable` + * is true when the value is not a SHA-shaped string. * @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 ref = packageJson.gutenberg?.sha; const ghcrRepo = packageJson.gutenberg?.ghcrRepo; - if ( ! sha ) { + if ( ! ref ) { throw new Error( 'Missing "gutenberg.sha" in package.json' ); } @@ -38,7 +52,89 @@ function readGutenbergConfig() { throw new Error( 'Missing "gutenberg.ghcrRepo" in package.json' ); } - return { sha, ghcrRepo }; + const isMutable = ! SHA_PATTERN.test( ref ); + + return { ref, ghcrRepo, isMutable }; +} + +/** + * Fetch an anonymous pull token for the given GHCR repository. + * + * @param {string} ghcrRepo The "owner/repo/package" path on ghcr.io. + * @return {Promise} The bearer token. + */ +async function fetchGhcrToken( ghcrRepo ) { + const response = await fetch( + `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io` + ); + if ( ! response.ok ) { + throw new Error( + `Failed to fetch GHCR token: ${ response.status } ${ response.statusText }` + ); + } + const data = await response.json(); + if ( ! data.token ) { + throw new Error( 'No token in GHCR response' ); + } + return data.token; +} + +/** + * Fetch a manifest from GHCR by tag. + * + * @param {string} ref The tag (SHA or mutable tag). + * @param {string} ghcrRepo The "owner/repo/package" path on ghcr.io. + * @param {string} token Bearer token from fetchGhcrToken. + * @return {Promise>} Parsed manifest JSON. + */ +async function fetchManifest( ref, ghcrRepo, token ) { + const response = await fetch( + `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ ref }`, + { + headers: { + Authorization: `Bearer ${ token }`, + Accept: MANIFEST_ACCEPT, + }, + } + ); + if ( ! response.ok ) { + const error = /** @type {Error & { status?: number }} */ ( + new Error( + `Failed to fetch manifest for "${ ref }": ${ response.status } ${ response.statusText }` + ) + ); + error.status = response.status; + throw error; + } + return response.json(); +} + +/** + * Resolve the expected source SHA for the configured ref. + * + * For immutable refs (SHA), the expected SHA is the ref itself and no network + * call is required. For mutable refs, the manifest's + * `org.opencontainers.image.revision` annotation is fetched and returned, + * which reflects the SHA value published to the mutable tag most recently. + * + * @param {{ ref: string, ghcrRepo: string, isMutable: boolean }} config + * @return {Promise} The expected SHA. + */ +async function resolveExpectedSha( { ref, ghcrRepo, isMutable } ) { + if ( ! isMutable ) { + return ref; + } + + const token = await fetchGhcrToken( ghcrRepo ); + const manifest = await fetchManifest( ref, ghcrRepo, token ); + const revision = + manifest?.annotations?.[ 'org.opencontainers.image.revision' ]; + if ( ! revision ) { + throw new Error( + `Manifest for "${ ref }" has no org.opencontainers.image.revision annotation` + ); + } + return revision; } /** @@ -59,22 +155,42 @@ function downloadGutenberg() { } /** - * Verify that the installed Gutenberg version matches the expected SHA in - * package.json. Automatically downloads the correct version when the directory - * is missing, the hash file is absent, or the hash does not match. Logs - * progress to the console and exits with a non-zero code on failure. + * Verify that the installed Gutenberg version matches the expected SHA. + * + * For SHA refs, the expected SHA is the configured value. For mutable refs, + * the expected SHA is whatever the mutable tag currently points to in GHCR + * (read from the manifest's image.revision annotation). The installed + * `.gutenberg-hash` is compared against the expected SHA; on mismatch, a + * fresh download is triggered. */ -function verifyGutenbergVersion() { +async function verifyGutenbergVersion() { console.log( '\n🔍 Verifying Gutenberg version...' ); - let sha; + let config; try { - ( { sha } = readGutenbergConfig() ); + config = readGutenbergConfig(); } catch ( error ) { - console.error( '❌ Error reading package.json:', error.message ); + console.error( '❌ Error reading package.json:', /** @type {Error} */ ( error ).message ); process.exit( 1 ); } + const { ref, isMutable } = config; + console.log( + ` Ref: ${ ref }${ isMutable ? ' (mutable tag)' : '' }` + ); + + let expectedSha; + try { + expectedSha = await resolveExpectedSha( config ); + } catch ( error ) { + console.error( '❌ Failed to resolve expected SHA:', /** @type {Error} */ ( error ).message ); + process.exit( 1 ); + } + + if ( isMutable ) { + console.log( ` Latest build for "${ ref }": ${ expectedSha }` ); + } + // Check for conditions that require a fresh download. if ( ! fs.existsSync( gutenbergDir ) ) { console.log( 'ℹ️ Gutenberg directory not found. Downloading...' ); @@ -84,8 +200,9 @@ function verifyGutenbergVersion() { try { installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim(); } catch ( error ) { - if ( error.code !== 'ENOENT' ) { - console.error( `❌ ${ error.message }` ); + const err = /** @type {NodeJS.ErrnoException} */ ( error ); + if ( err.code !== 'ENOENT' ) { + console.error( `❌ ${ err.message }` ); process.exit( 1 ); } } @@ -93,8 +210,8 @@ function verifyGutenbergVersion() { if ( installedHash === null ) { console.log( 'ℹ️ Hash file not found. Downloading expected version...' ); downloadGutenberg(); - } else if ( installedHash !== sha ) { - console.log( `ℹ️ Hash mismatch (found ${ installedHash }, expected ${ sha }). Downloading expected version...` ); + } else if ( installedHash !== expectedSha ) { + console.log( `ℹ️ Hash mismatch (found ${ installedHash }, expected ${ expectedSha }). Downloading expected version...` ); downloadGutenberg(); } } @@ -102,15 +219,16 @@ function verifyGutenbergVersion() { // Final verification — confirms the download (if any) produced the correct version. try { const installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim(); - if ( installedHash !== sha ) { - console.error( `❌ SHA mismatch after download: expected ${ sha } but found ${ installedHash }.` ); + if ( installedHash !== expectedSha ) { + console.error( `❌ SHA mismatch after download: expected ${ expectedSha } but found ${ installedHash }.` ); process.exit( 1 ); } } catch ( error ) { - if ( error.code === 'ENOENT' ) { + const err = /** @type {NodeJS.ErrnoException} */ ( error ); + if ( err.code === 'ENOENT' ) { console.error( '❌ .gutenberg-hash not found after download. This is unexpected.' ); } else { - console.error( `❌ ${ error.message }` ); + console.error( `❌ ${ err.message }` ); } process.exit( 1 ); } @@ -118,8 +236,19 @@ function verifyGutenbergVersion() { console.log( '✅ Version verified' ); } -module.exports = { rootDir, gutenbergDir, readGutenbergConfig, verifyGutenbergVersion }; +module.exports = { + rootDir, + gutenbergDir, + readGutenbergConfig, + verifyGutenbergVersion, + fetchGhcrToken, + fetchManifest, + resolveExpectedSha, +}; if ( require.main === module ) { - verifyGutenbergVersion(); + verifyGutenbergVersion().catch( ( error ) => { + console.error( '❌ Unexpected error:', error ); + process.exit( 1 ); + } ); } diff --git a/tsconfig.json b/tsconfig.json index 23ed781dd69fd..87abe9fb7a42b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "lib": [ "es2020", "dom" ], "typeRoots": [ "./typings", "./node_modules/@types" ], "types": [ + "node", "wp-globals", "codemirror/addon/lint/lint", "codemirror/addon/hint/show-hint" @@ -28,6 +29,8 @@ "files": [ "src/js/_enqueues/wp/code-editor.js", "src/js/_enqueues/lib/codemirror/javascript-lint.js", - "src/js/_enqueues/lib/codemirror/htmlhint-kses.js" + "src/js/_enqueues/lib/codemirror/htmlhint-kses.js", + "tools/gutenberg/download.js", + "tools/gutenberg/utils.js" ] }