From 144271fd23df488a4d6cad5e9b77597b913d76ed Mon Sep 17 00:00:00 2001 From: Lance Willett Date: Sun, 26 Jul 2026 16:34:58 -0700 Subject: [PATCH 1/4] Build/Test Tools: Download and verify the Gutenberg build once per run. Each matrix job independently streams the same ~36 MB Gutenberg archive from the GitHub Container Registry, so any one flaky stream can redden a run. Add a prepare-gutenberg reusable workflow that fetches and verifies the build once (SHA-256 and size) and uploads it as a run artifact; every PHPUnit caller consumes the shared artifact and no longer contacts the registry. The prep job runs only when a consumer runs. Test coverage is unchanged. See #65721. Props lance.willett@automattic.com --- .github/workflows/phpunit-tests.yml | 32 +- .../workflows/reusable-phpunit-tests-v3.yml | 20 + .../workflows/reusable-prepare-gutenberg.yml | 60 +++ .github/workflows/test-coverage.yml | 20 +- tools/gutenberg/download.js | 344 ++++++++++++++---- tools/gutenberg/utils.js | 21 +- 6 files changed, 416 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/reusable-prepare-gutenberg.yml diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 81afd70449141..727a428e27859 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -37,6 +37,7 @@ on: # Confirm any changes to relevant workflow files. - '.github/workflows/phpunit-tests.yml' - '.github/workflows/reusable-phpunit-tests-*.yml' + - '.github/workflows/reusable-prepare-gutenberg.yml' workflow_dispatch: # Once weekly On Sundays at 00:00 UTC. schedule: @@ -54,6 +55,20 @@ concurrency: permissions: {} jobs: + # Downloads and verifies the Gutenberg build once for all PHPUnit jobs. + # + # This condition is the union of the conditions on the jobs that need it, reduced. + # The org matrices require `WordPress/wordpress-develop` or a pull request, and the + # fork matrix requires a pull request, so `wordpress-develop` or a pull request + # covers every case. Keep it in step with those jobs: a narrower condition orphans + # them, because a job that needs a skipped job is skipped too, and a broader one + # downloads a build that nothing consumes. + prepare-gutenberg: + uses: ./.github/workflows/reusable-prepare-gutenberg.yml + permissions: + contents: read + if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} + # # Creates a PHPUnit test job for each PHP/MySQL combination. # @@ -64,6 +79,7 @@ jobs: test-with-mysql: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -130,6 +146,8 @@ jobs: phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} tests-domain: ${{ matrix.tests-domain }} report: ${{ matrix.report || false }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Creates a PHPUnit test job for each PHP/MariaDB combination. @@ -143,6 +161,7 @@ jobs: test-with-mariadb: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -182,6 +201,8 @@ jobs: memcached: ${{ matrix.memcached }} phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} report: false + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Creates PHPUnit test jobs to test MariaDB and MySQL innovation releases. @@ -197,6 +218,7 @@ jobs: test-innovation-releases: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -228,6 +250,8 @@ jobs: memcached: ${{ matrix.memcached }} phpunit-config: ${{ matrix.multisite && 'tests/phpunit/multisite.xml' || 'phpunit.xml.dist' }} report: false + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Runs the HTML API test group. @@ -240,6 +264,7 @@ jobs: html-api-test-groups: name: ${{ matrix.label }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -260,6 +285,8 @@ jobs: db-type: ${{ matrix.db-type }} db-version: ${{ matrix.db-version }} phpunit-test-groups: ${{ matrix.phpunit-test-groups }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} # # Runs unit tests for forks. @@ -271,6 +298,7 @@ jobs: limited-matrix-for-forks: name: PHP ${{ matrix.php }} uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read secrets: @@ -320,6 +348,8 @@ jobs: db-type: ${{ matrix.db-type }} memcached: ${{ matrix.memcached || false }} phpunit-test-groups: ${{ matrix.phpunit-test-groups || '' }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} slack-notifications: name: Slack Notifications @@ -327,7 +357,7 @@ jobs: permissions: actions: read contents: read - needs: [ test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] + needs: [ prepare-gutenberg, test-with-mysql, test-with-mariadb, test-innovation-releases, html-api-test-groups, limited-matrix-for-forks ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index dfe678e82a0da..f8376c22402af 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -72,6 +72,14 @@ on: required: false type: boolean default: false + gutenberg-artifact: + description: 'The name of a same-workflow artifact containing the prepared Gutenberg build.' + required: true + type: string + gutenberg-sha: + description: 'The immutable Gutenberg source SHA verified by the calling workflow.' + required: true + type: string secrets: CODECOV_TOKEN: description: 'The Codecov token required for uploading reports.' @@ -101,6 +109,7 @@ jobs: # Performs the following steps: # - Sets environment variables. # - Checks out the repository. + # - Downloads the prepared Gutenberg build when provided by the calling workflow. # - Sets up Node.js. # - Sets up PHP. # - Installs Composer dependencies. @@ -135,6 +144,14 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} persist-credentials: false + - name: Download prepared Gutenberg build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + # Without a run ID, this action reads only from the caller's current workflow run. + name: ${{ inputs.gutenberg-artifact }} + path: gutenberg + digest-mismatch: error + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -166,6 +183,9 @@ jobs: - name: Build WordPress run: npm run build:dev + env: + # The producer resolved this once. Matrix jobs must not re-resolve mutable GHCR tags. + GUTENBERG_EXPECTED_SHA: ${{ inputs.gutenberg-sha }} - name: General debug information run: | diff --git a/.github/workflows/reusable-prepare-gutenberg.yml b/.github/workflows/reusable-prepare-gutenberg.yml new file mode 100644 index 0000000000000..70f66f44f610b --- /dev/null +++ b/.github/workflows/reusable-prepare-gutenberg.yml @@ -0,0 +1,60 @@ +## +# A reusable workflow that downloads and verifies the Gutenberg build once per +# calling workflow run. +## +name: Prepare Gutenberg build + +on: + workflow_call: + outputs: + gutenberg-sha: + description: 'The immutable Gutenberg source SHA verified by this workflow.' + value: ${{ jobs.prepare-gutenberg.outputs.gutenberg-sha }} + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + prepare-gutenberg: + name: Gutenberg + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + gutenberg-sha: ${{ steps.download.outputs.gutenberg-sha }} + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Resolve the Gutenberg ref from the exact commit that started this workflow run. + ref: ${{ github.sha }} + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + + - name: Download and verify Gutenberg build + id: download + run: | + node tools/gutenberg/download.js + gutenberg_sha="$(tr -d '\n' < gutenberg/.gutenberg-hash)" + if [[ ! "$gutenberg_sha" =~ ^[a-f0-9]{40}$ ]]; then + echo "Expected a 40-character Gutenberg SHA, received: $gutenberg_sha" >&2 + exit 1 + fi + echo "gutenberg-sha=$gutenberg_sha" >> "$GITHUB_OUTPUT" + + - name: Upload Gutenberg build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gutenberg-build + path: gutenberg/ + if-no-files-found: error + include-hidden-files: true + retention-days: 1 diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index d6fe09904a925..ffc650ec370fb 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -8,6 +8,10 @@ on: paths: - '.github/workflows/test-coverage.yml' - '.github/workflows/reusable-phpunit-tests-v3.yml' + - '.github/workflows/reusable-prepare-gutenberg.yml' + - 'tools/gutenberg/**' + - 'package*.json' + - '.nvmrc' - 'docker-compose.yml' - 'phpunit.xml.dist' - 'tests/phpunit/multisite.xml' @@ -17,6 +21,10 @@ on: paths: - '.github/workflows/test-coverage.yml' - '.github/workflows/reusable-phpunit-tests-v3.yml' + - '.github/workflows/reusable-prepare-gutenberg.yml' + - 'tools/gutenberg/**' + - 'package*.json' + - '.nvmrc' - 'docker-compose.yml' - 'phpunit.xml.dist' - 'tests/phpunit/multisite.xml' @@ -42,12 +50,20 @@ env: PUPPETEER_SKIP_DOWNLOAD: true jobs: + # Downloads and verifies the Gutenberg build once for all coverage jobs. + prepare-gutenberg: + uses: ./.github/workflows/reusable-prepare-gutenberg.yml + permissions: + contents: read + if: ${{ github.repository == 'WordPress/wordpress-develop' }} + # # Creates a PHPUnit test jobs for generating code coverage reports. # test-coverage-report: name: ${{ matrix.multisite && 'Multisite' || 'Single site' }} report uses: ./.github/workflows/reusable-phpunit-tests-v3.yml + needs: prepare-gutenberg permissions: contents: read if: ${{ github.repository == 'WordPress/wordpress-develop' }} @@ -60,6 +76,8 @@ jobs: php: '8.3' multisite: ${{ matrix.multisite }} coverage-report: ${{ matrix.coverage-report }} + gutenberg-artifact: gutenberg-build + gutenberg-sha: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -69,7 +87,7 @@ jobs: permissions: actions: read contents: read - needs: [ test-coverage-report ] + needs: [ prepare-gutenberg, test-coverage-report ] if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} with: calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js index fd76c6c7a7836..69101199e80a0 100644 --- a/tools/gutenberg/download.js +++ b/tools/gutenberg/download.js @@ -1,11 +1,13 @@ #!/usr/bin/env node +/* global AbortSignal */ /** * 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. Any - * existing gutenberg directory is removed before extraction. + * Container Registry and extracts it into the ./gutenberg directory. An + * existing gutenberg directory is replaced only after verification and + * extraction succeed. * * The artifact is identified by the "gutenberg.sha" value in the root * package.json, which is used as the OCI tag for the gutenberg-wp-develop-build @@ -20,10 +22,13 @@ */ const { spawn } = require( 'child_process' ); +const crypto = require( 'crypto' ); const fs = require( 'fs' ); +const os = require( 'os' ); +const path = require( 'path' ); const { Readable } = require( 'stream' ); const { pipeline } = require( 'stream/promises' ); -const zlib = require( 'zlib' ); +const { Transform } = require( 'stream' ); const { gutenbergDir, readGutenbergConfig, @@ -31,6 +36,241 @@ const { fetchManifest, } = require( './utils' ); +const MAX_DOWNLOAD_ATTEMPTS = 3; +const RETRY_DELAY_MS = 2000; +const DOWNLOAD_TIMEOUT_MS = 120000; + +/** + * Convert bytes into a readable string for download diagnostics. + * + * @param {number} bytes Number of bytes. + * @return {string} Formatted byte count. + */ +function formatBytes( bytes ) { + return `${ ( bytes / 1024 / 1024 ).toFixed( 2 ) } MiB (${ bytes } bytes)`; +} + +/** + * Wait before retrying a failed download. + * + * @param {number} milliseconds Time to wait in milliseconds. + * @return {Promise} Resolves after the requested delay. + */ +function delay( milliseconds ) { + return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); +} + +/** + * Create an error that retains the HTTP status code for retry decisions. + * + * @param {string} message Error message. + * @param {number} status HTTP status code. + * @return {Error & { status: number }} Error with status code. + */ +function createHttpError( message, status ) { + const error = /** @type {Error & { status: number }} */ ( new Error( message ) ); + error.status = status; + return error; +} + +/** + * Determine whether a failed download might succeed on a later attempt. + * + * @param {Error & { status?: number }} error Download error. + * @return {boolean} Whether the error is retryable. + */ +function isRetryableDownloadError( error ) { + return ! error.status || error.status === 408 || error.status === 429 || error.status >= 500; +} + +/** + * Extract a SHA-256 hash from an OCI layer digest. + * + * @param {string} digest OCI layer digest. + * @return {string} Expected SHA-256 hash. + * @throws {Error} If the digest is not a SHA-256 digest. + */ +function getExpectedSha256( digest ) { + const match = /^sha256:([a-f0-9]{64})$/i.exec( digest ); + if ( ! match ) { + throw new Error( `Unsupported OCI layer digest: ${ digest }` ); + } + + return match[ 1 ].toLowerCase(); +} + +/** + * Download a blob to disk and verify its SHA-256 digest and byte count. + * + * @param {string} url Blob URL. + * @param {string} token Bearer token for GHCR. + * @param {string} digest OCI layer digest. + * @param {number|undefined} expectedSize Expected layer size from the manifest. + * @param {string} destination Path where the compressed blob is written. + * @return {Promise} Resolves after the download is verified. + */ +async function downloadAndVerifyBlob( url, token, digest, expectedSize, destination ) { + const expectedSha256 = getExpectedSha256( digest ); + const response = await fetch( url, { + headers: { + Authorization: `Bearer ${ token }`, + }, + signal: AbortSignal.timeout( DOWNLOAD_TIMEOUT_MS ), + } ); + + console.log( + ` Response: ${ response.status } ${ response.statusText } from ${ new URL( response.url ).hostname }` + ); + + if ( ! response.ok ) { + throw createHttpError( + `Failed to download blob: ${ response.status } ${ response.statusText }`, + response.status + ); + } + + if ( ! response.body ) { + throw new Error( 'Blob response has no body' ); + } + + const contentLength = Number( response.headers.get( 'content-length' ) ); + if ( Number.isFinite( contentLength ) && contentLength > 0 ) { + console.log( ` Content-Length: ${ formatBytes( contentLength ) }` ); + } + if ( expectedSize ) { + console.log( ` Manifest size: ${ formatBytes( expectedSize ) }` ); + } + + let downloadedBytes = 0; + const hash = crypto.createHash( 'sha256' ); + const meter = new Transform( { + transform( chunk, _encoding, callback ) { + downloadedBytes += chunk.length; + hash.update( chunk ); + callback( null, chunk ); + }, + } ); + + try { + await pipeline( + Readable.fromWeb( + /** @type {import('stream/web').ReadableStream} */ ( response.body ) + ), + meter, + fs.createWriteStream( destination ) + ); + } catch ( error ) { + throw new Error( + `Download interrupted after ${ formatBytes( downloadedBytes ) }: ${ /** @type {Error} */ ( error ).message }` + ); + } + + if ( contentLength && downloadedBytes !== contentLength ) { + throw new Error( + `Downloaded ${ formatBytes( downloadedBytes ) }, but Content-Length was ${ formatBytes( contentLength ) }` + ); + } + + if ( expectedSize && downloadedBytes !== expectedSize ) { + throw new Error( + `Downloaded ${ formatBytes( downloadedBytes ) }, but manifest size was ${ formatBytes( expectedSize ) }` + ); + } + + const actualSha256 = hash.digest( 'hex' ); + if ( actualSha256 !== expectedSha256 ) { + throw new Error( + `SHA-256 mismatch: expected ${ expectedSha256 } but received ${ actualSha256 }` + ); + } + + console.log( `āœ… Downloaded ${ formatBytes( downloadedBytes ) } and verified SHA-256` ); +} + +/** + * Download a blob with bounded retries and remove incomplete files between attempts. + * + * @param {string} url Blob URL. + * @param {string} token Bearer token for GHCR. + * @param {string} digest OCI layer digest. + * @param {number|undefined} expectedSize Expected layer size from the manifest. + * @return {Promise} Path to the verified compressed blob. + */ +async function downloadBlobWithRetries( url, token, digest, expectedSize ) { + const destination = path.join( + os.tmpdir(), + `wordpress-gutenberg-${ process.pid }.tar.gz` + ); + + for ( let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++ ) { + console.log( `\nšŸ“„ Download attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }...` ); + fs.rmSync( destination, { force: true } ); + + try { + await downloadAndVerifyBlob( + url, + token, + digest, + expectedSize, + destination + ); + return destination; + } catch ( error ) { + const downloadError = /** @type {Error & { status?: number }} */ ( error ); + fs.rmSync( destination, { force: true } ); + console.error( `āŒ Download attempt ${ attempt } failed: ${ downloadError.message }` ); + + if ( + attempt === MAX_DOWNLOAD_ATTEMPTS || + ! isRetryableDownloadError( downloadError ) + ) { + throw downloadError; + } + + console.log( ` Retrying in ${ RETRY_DELAY_MS / 1000 } seconds...` ); + await delay( RETRY_DELAY_MS ); + } + } + + throw new Error( 'Download failed without an error' ); +} + +/** + * Extract a verified archive directly into the Gutenberg directory, removing + * any existing directory first. + * + * @param {string} archivePath Path to the verified compressed blob. + * @param {string} expectedSha Expected immutable Gutenberg source SHA. + * @return {Promise} Resolves after extraction completes. + */ +async function extractVerifiedArchive( archivePath, expectedSha ) { + fs.rmSync( gutenbergDir, { recursive: true, force: true } ); + fs.mkdirSync( gutenbergDir, { recursive: true } ); + + const tar = spawn( 'tar', [ '-xzf', archivePath, '-C', gutenbergDir ], { + stdio: [ 'ignore', 'inherit', 'inherit' ], + } ); + + await new Promise( ( resolve, reject ) => { + tar.on( 'close', ( code ) => { + if ( code !== 0 ) { + reject( new Error( `tar exited with code ${ code }` ) ); + return; + } + resolve( undefined ); + } ); + tar.on( 'error', reject ); + } ); + + const extractedHashPath = path.join( gutenbergDir, '.gutenberg-hash' ); + const extractedSha = fs.readFileSync( extractedHashPath, 'utf8' ).trim(); + if ( extractedSha !== expectedSha ) { + throw new Error( + `Extracted Gutenberg SHA mismatch: expected ${ expectedSha } but found ${ extractedSha }` + ); + } +} + /** * Resolve the manifest to use for downloading. * @@ -43,7 +283,7 @@ const { * * @param {{ ref: string, ghcrRepo: string, isMutable: boolean }} config * @param {string} token - * @return {Promise<{ manifest: Record, resolvedRef: string }>} + * @return {Promise<{ manifest: Record, resolvedRef: string, expectedSha: string }>} */ async function resolveDownloadManifest( config, token ) { const { ref, ghcrRepo, isMutable } = config; @@ -51,27 +291,26 @@ async function resolveDownloadManifest( config, token ) { const initialManifest = await fetchManifest( ref, ghcrRepo, token ); if ( ! isMutable ) { - return { manifest: initialManifest, resolvedRef: ref }; + return { manifest: initialManifest, resolvedRef: ref, expectedSha: ref }; } const revision = initialManifest?.annotations?.[ 'org.opencontainers.image.revision' ]; - if ( ! revision ) { - console.log( - `ā„¹ļø No image.revision annotation on "${ ref }"; using mutable tag for download.` + if ( ! revision || ! /^[a-f0-9]{40}$/i.test( revision ) ) { + throw new Error( + `Manifest for mutable ref "${ ref }" has no valid org.opencontainers.image.revision SHA` ); - return { manifest: initialManifest, resolvedRef: ref }; } try { const immutableManifest = await fetchManifest( revision, ghcrRepo, token ); - return { manifest: immutableManifest, resolvedRef: revision }; + return { manifest: immutableManifest, resolvedRef: revision, expectedSha: 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 }; + return { manifest: initialManifest, resolvedRef: ref, expectedSha: revision }; } throw error; } @@ -116,9 +355,9 @@ async function main() { // Step 2: Resolve the manifest to use for download. console.log( `\nšŸ“‹ Fetching manifest for ${ config.ref }...` ); - let manifest, resolvedRef; + let manifest, resolvedRef, expectedSha; try { - ( { manifest, resolvedRef } = await resolveDownloadManifest( + ( { manifest, resolvedRef, expectedSha } = await resolveDownloadManifest( config, token ) ); @@ -130,78 +369,35 @@ async function main() { process.exit( 1 ); } - const digest = manifest?.layers?.[ 0 ]?.digest; + const layer = manifest?.layers?.[ 0 ]; + const digest = layer?.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...' ); - 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...` ); + // Step 3: Download and verify the compressed blob before extraction. + let archivePath; try { - const response = await fetch( `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, { - headers: { - Authorization: `Bearer ${ token }`, - }, - } ); - 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. - * `tar` is available on macOS, Linux, and Windows 10+. - */ - const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], { - stdio: [ 'pipe', 'inherit', 'inherit' ], - } ); - - /** @type {Promise} */ - 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( - Readable.fromWeb( - /** @type {import('stream/web').ReadableStream} */ ( response.body ) - ), - zlib.createGunzip(), - tar.stdin, + archivePath = await downloadBlobWithRetries( + `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, + token, + digest, + layer.size ); - await tarDone; - - console.log( 'āœ… Download and extraction complete' ); + console.log( '\nšŸ“¦ Extracting verified artifact...' ); + await extractVerifiedArchive( archivePath, expectedSha ); + console.log( 'āœ… Extraction complete' ); } catch ( error ) { console.error( 'āŒ Download/extraction failed:', /** @type {Error} */ ( error ).message ); - process.exit( 1 ); + process.exitCode = 1; + return; + } finally { + if ( archivePath ) { + fs.rmSync( archivePath, { force: true } ); + } } console.log( '\nāœ… Gutenberg download complete!' ); diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js index 3ba95199578b4..6820a888bfe2c 100644 --- a/tools/gutenberg/utils.js +++ b/tools/gutenberg/utils.js @@ -121,6 +121,17 @@ async function fetchManifest( ref, ghcrRepo, token ) { * @return {Promise} The expected SHA. */ async function resolveExpectedSha( { ref, ghcrRepo, isMutable } ) { + const workflowSha = process.env.GUTENBERG_EXPECTED_SHA; + if ( workflowSha ) { + if ( ! SHA_PATTERN.test( workflowSha ) ) { + throw new Error( + `GUTENBERG_EXPECTED_SHA must be a 40-character Git SHA, received "${ workflowSha }"` + ); + } + + return workflowSha; + } + if ( ! isMutable ) { return ref; } @@ -157,11 +168,11 @@ function downloadGutenberg() { /** * 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. + * A calling workflow may supply GUTENBERG_EXPECTED_SHA after resolving a build + * once. This avoids re-resolving a mutable tag in every matrix job. Otherwise, + * SHA refs use the configured value and mutable refs resolve their current + * image.revision annotation. The installed `.gutenberg-hash` is compared + * against the expected SHA; on mismatch, a fresh download is triggered. */ async function verifyGutenbergVersion() { console.log( '\nšŸ” Verifying Gutenberg version...' ); From 8b008949830b838a778e046c3d3ee9852284e542 Mon Sep 17 00:00:00 2001 From: Lance Willett Date: Mon, 27 Jul 2026 10:52:18 -0700 Subject: [PATCH 2/4] Build/Test Tools: Address review feedback on the Gutenberg download. - Remove the Content-Length integrity check in downloadAndVerifyBlob(). It is redundant: the manifest size check and SHA-256 verification that follow already catch truncated or corrupted downloads. - Refresh the GHCR bearer token once when a blob download hits a 401 during a retry attempt. The token can age out during the multi-minute retry window, so downloadBlobWithRetries() now re-fetches a token via fetchGhcrToken() and retries with it before falling back to the existing non-retryable handling. - Correct the script's header comment: the existing gutenberg directory is removed before extraction (after the archive is verified), not only after extraction succeeds. - Make the Gutenberg SHA regex in reusable-prepare-gutenberg.yml case-insensitive, matching the `/i` flag used by the equivalent checks in tools/gutenberg/utils.js. - Fix a step-list comment in reusable-phpunit-tests-v3.yml: the Gutenberg build input is required, not optional, so drop "when provided". Props lance.willett@automattic.com. --- .../workflows/reusable-phpunit-tests-v3.yml | 2 +- .../workflows/reusable-prepare-gutenberg.yml | 2 +- tools/gutenberg/download.js | 42 +++++++++++++------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index f8376c22402af..0cc16f06dcc79 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -109,7 +109,7 @@ jobs: # Performs the following steps: # - Sets environment variables. # - Checks out the repository. - # - Downloads the prepared Gutenberg build when provided by the calling workflow. + # - Downloads the prepared Gutenberg build provided by the calling workflow. # - Sets up Node.js. # - Sets up PHP. # - Installs Composer dependencies. diff --git a/.github/workflows/reusable-prepare-gutenberg.yml b/.github/workflows/reusable-prepare-gutenberg.yml index 70f66f44f610b..c5e1f904a9680 100644 --- a/.github/workflows/reusable-prepare-gutenberg.yml +++ b/.github/workflows/reusable-prepare-gutenberg.yml @@ -44,7 +44,7 @@ jobs: run: | node tools/gutenberg/download.js gutenberg_sha="$(tr -d '\n' < gutenberg/.gutenberg-hash)" - if [[ ! "$gutenberg_sha" =~ ^[a-f0-9]{40}$ ]]; then + if [[ ! "$gutenberg_sha" =~ ^[a-fA-F0-9]{40}$ ]]; then echo "Expected a 40-character Gutenberg SHA, received: $gutenberg_sha" >&2 exit 1 fi diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js index 69101199e80a0..97df476161412 100644 --- a/tools/gutenberg/download.js +++ b/tools/gutenberg/download.js @@ -5,9 +5,10 @@ * 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. An - * existing gutenberg directory is replaced only after verification and - * extraction succeed. + * Container Registry and extracts it into the ./gutenberg directory. The + * archive is downloaded and verified (SHA-256 and manifest size) before + * extraction; the existing gutenberg directory is then removed and replaced + * with the extracted contents. * * The artifact is identified by the "gutenberg.sha" value in the root * package.json, which is used as the OCI tag for the gutenberg-wp-develop-build @@ -165,12 +166,6 @@ async function downloadAndVerifyBlob( url, token, digest, expectedSize, destinat ); } - if ( contentLength && downloadedBytes !== contentLength ) { - throw new Error( - `Downloaded ${ formatBytes( downloadedBytes ) }, but Content-Length was ${ formatBytes( contentLength ) }` - ); - } - if ( expectedSize && downloadedBytes !== expectedSize ) { throw new Error( `Downloaded ${ formatBytes( downloadedBytes ) }, but manifest size was ${ formatBytes( expectedSize ) }` @@ -190,18 +185,26 @@ async function downloadAndVerifyBlob( url, token, digest, expectedSize, destinat /** * Download a blob with bounded retries and remove incomplete files between attempts. * + * The GHCR bearer token can age out during a long retry window. If a 401 is + * encountered, a fresh token is fetched once (via `fetchGhcrToken`) and the + * download is retried with it. + * * @param {string} url Blob URL. * @param {string} token Bearer token for GHCR. * @param {string} digest OCI layer digest. * @param {number|undefined} expectedSize Expected layer size from the manifest. + * @param {string} ghcrRepo The "owner/repo/package" path on ghcr.io, used to refresh an expired token. * @return {Promise} Path to the verified compressed blob. */ -async function downloadBlobWithRetries( url, token, digest, expectedSize ) { +async function downloadBlobWithRetries( url, token, digest, expectedSize, ghcrRepo ) { const destination = path.join( os.tmpdir(), `wordpress-gutenberg-${ process.pid }.tar.gz` ); + let currentToken = token; + let hasRefetchedToken = false; + for ( let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++ ) { console.log( `\nšŸ“„ Download attempt ${ attempt }/${ MAX_DOWNLOAD_ATTEMPTS }...` ); fs.rmSync( destination, { force: true } ); @@ -209,7 +212,7 @@ async function downloadBlobWithRetries( url, token, digest, expectedSize ) { try { await downloadAndVerifyBlob( url, - token, + currentToken, digest, expectedSize, destination @@ -220,6 +223,20 @@ async function downloadBlobWithRetries( url, token, digest, expectedSize ) { fs.rmSync( destination, { force: true } ); console.error( `āŒ Download attempt ${ attempt } failed: ${ downloadError.message }` ); + if ( + downloadError.status === 401 && + ! hasRefetchedToken && + attempt < MAX_DOWNLOAD_ATTEMPTS + ) { + hasRefetchedToken = true; + console.log( ' Bearer token may have expired mid-retry; fetching a fresh token...' ); + currentToken = await fetchGhcrToken( ghcrRepo ); + + console.log( ` Retrying in ${ RETRY_DELAY_MS / 1000 } seconds...` ); + await delay( RETRY_DELAY_MS ); + continue; + } + if ( attempt === MAX_DOWNLOAD_ATTEMPTS || ! isRetryableDownloadError( downloadError ) @@ -384,7 +401,8 @@ async function main() { `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, token, digest, - layer.size + layer.size, + config.ghcrRepo ); console.log( '\nšŸ“¦ Extracting verified artifact...' ); From 4e2f78e5ac98bb7e4c3b9c489553726389b1a47a Mon Sep 17 00:00:00 2001 From: Lance Willett Date: Mon, 27 Jul 2026 11:02:59 -0700 Subject: [PATCH 3/4] Build/Test Tools: Add retry and timeout to the Gutenberg token and manifest fetches. The token and manifest requests run before the blob download and became a single point of failure once the whole test matrix was consolidated behind one prepare-gutenberg job, so they now share the blob download's bounded retry and timeout. Props lance.willett@automattic.com. --- tools/gutenberg/utils.js | 132 ++++++++++++++++++++++++++++++--------- 1 file changed, 102 insertions(+), 30 deletions(-) diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js index 6820a888bfe2c..b43e760735efe 100644 --- a/tools/gutenberg/utils.js +++ b/tools/gutenberg/utils.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* global AbortSignal */ /** * Gutenberg build utilities. @@ -26,6 +27,73 @@ const SHA_PATTERN = /^[a-f0-9]{40}$/i; const MANIFEST_ACCEPT = 'application/vnd.oci.image.manifest.v1+json'; +// Retry/timeout settings for the token and manifest requests. These run +// before the blob download and are now a single point of failure for the +// whole build, so they share the blob download's attempt count and backoff +// (see MAX_DOWNLOAD_ATTEMPTS and RETRY_DELAY_MS in download.js). +const MAX_METADATA_ATTEMPTS = 3; +const RETRY_DELAY_MS = 2000; +const METADATA_TIMEOUT_MS = 30000; + +/** + * Wait before retrying a failed metadata request. + * + * @param {number} milliseconds Time to wait in milliseconds. + * @return {Promise} Resolves after the requested delay. + */ +function delay( milliseconds ) { + return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); +} + +/** + * Create an error that retains the HTTP status code for retry decisions. + * + * @param {string} message Error message. + * @param {number} status HTTP status code. + * @return {Error & { status: number }} Error with status code. + */ +function createHttpError( message, status ) { + const error = /** @type {Error & { status: number }} */ ( new Error( message ) ); + error.status = status; + return error; +} + +/** + * Determine whether a failed metadata request might succeed on a later attempt. + * + * @param {Error & { status?: number }} error Request error. + * @return {boolean} Whether the error is retryable. + */ +function isRetryableMetadataError( error ) { + return ! error.status || error.status === 408 || error.status === 429 || error.status >= 500; +} + +/** + * Run a metadata request with bounded retries, matching the blob download's + * retry semantics. Non-retryable errors (e.g. a 404) are thrown immediately. + * + * @param {string} description Human-readable label for retry log messages. + * @param {() => Promise} request Function that performs one request attempt. + * @return {Promise} Resolves with the request's result. + */ +async function withMetadataRetries( description, request ) { + for ( let attempt = 1; attempt <= MAX_METADATA_ATTEMPTS; attempt++ ) { + try { + return await request(); + } catch ( error ) { + const requestError = /** @type {Error & { status?: number }} */ ( error ); + if ( attempt === MAX_METADATA_ATTEMPTS || ! isRetryableMetadataError( requestError ) ) { + throw requestError; + } + console.error( `āŒ ${ description } attempt ${ attempt } failed: ${ requestError.message }` ); + console.log( ` Retrying in ${ RETRY_DELAY_MS / 1000 } seconds...` ); + await delay( RETRY_DELAY_MS ); + } + } + + throw new Error( `${ description } failed without an error` ); +} + /** * Read Gutenberg configuration from package.json. * @@ -64,19 +132,23 @@ function readGutenbergConfig() { * @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 }` + return withMetadataRetries( 'Fetch GHCR token', async () => { + const response = await fetch( + `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io`, + { signal: AbortSignal.timeout( METADATA_TIMEOUT_MS ) } ); - } - const data = await response.json(); - if ( ! data.token ) { - throw new Error( 'No token in GHCR response' ); - } - return data.token; + if ( ! response.ok ) { + throw createHttpError( + `Failed to fetch GHCR token: ${ response.status } ${ response.statusText }`, + response.status + ); + } + const data = await response.json(); + if ( ! data.token ) { + throw new Error( 'No token in GHCR response' ); + } + return data.token; + } ); } /** @@ -88,25 +160,25 @@ async function fetchGhcrToken( ghcrRepo ) { * @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 }` - ) + return withMetadataRetries( `Fetch manifest for "${ ref }"`, async () => { + const response = await fetch( + `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ ref }`, + { + headers: { + Authorization: `Bearer ${ token }`, + Accept: MANIFEST_ACCEPT, + }, + signal: AbortSignal.timeout( METADATA_TIMEOUT_MS ), + } ); - error.status = response.status; - throw error; - } - return response.json(); + if ( ! response.ok ) { + throw createHttpError( + `Failed to fetch manifest for "${ ref }": ${ response.status } ${ response.statusText }`, + response.status + ); + } + return response.json(); + } ); } /** From eeee175acaa67dcce77c80904152d9fa618909fd Mon Sep 17 00:00:00 2001 From: Lance Willett Date: Mon, 27 Jul 2026 12:28:12 -0700 Subject: [PATCH 4/4] Build/Test Tools: Keep the reusable PHPUnit workflow backward-compatible for older branches. Branches >= 5.9 call reusable-phpunit-tests-v3.yml@trunk and do not pass the new Gutenberg inputs, so requiring them would fail every PHPUnit run on those branches with a missing-input error. Make gutenberg-artifact and gutenberg-sha optional (default empty) and guard the artifact download so those callers fall back to a per-job download, preserving current behavior. Props lance.willett@automattic.com. --- .github/workflows/reusable-phpunit-tests-v3.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 0cc16f06dcc79..7cbfe32a6a363 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -73,13 +73,15 @@ on: type: boolean default: false gutenberg-artifact: - description: 'The name of a same-workflow artifact containing the prepared Gutenberg build.' - required: true + description: 'The name of a same-workflow artifact containing the prepared Gutenberg build. Optional: callers that omit it download Gutenberg per job.' + required: false type: string + default: '' gutenberg-sha: description: 'The immutable Gutenberg source SHA verified by the calling workflow.' - required: true + required: false type: string + default: '' secrets: CODECOV_TOKEN: description: 'The Codecov token required for uploading reports.' @@ -144,7 +146,10 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} persist-credentials: false + # Branches >= 5.9 call this workflow at @trunk without the producer job, so they + # pass no artifact. They skip this step and fall back to a per-job download. - name: Download prepared Gutenberg build + if: inputs.gutenberg-artifact != '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: # Without a run ID, this action reads only from the caller's current workflow run.