diff --git a/src/js/_enqueues/wp/updates.js b/src/js/_enqueues/wp/updates.js
index ef4b47e66093e..ea0039fd4512f 100644
--- a/src/js/_enqueues/wp/updates.js
+++ b/src/js/_enqueues/wp/updates.js
@@ -2515,6 +2515,10 @@
} else if ( 'themes' === pagenow || 'themes-network' === pagenow ) {
if ( 'update-theme' === job.action ) {
$message = $( '[data-slug="' + job.data.slug + '"]' ).find( '.update-message' );
+
+ if ( 'themes' === pagenow ) {
+ $message = $message.add( $( '#update-theme' ).closest( '.notice' ) );
+ }
} else if ( 'delete-theme' === job.action && 'themes-network' === pagenow ) {
$message = $( '[data-slug="' + job.data.slug + '"]' ).find( '.row-actions a.delete' );
} else if ( 'delete-theme' === job.action && 'themes' === pagenow ) {
diff --git a/tests/qunit/wp-admin/js/updates.js b/tests/qunit/wp-admin/js/updates.js
index 9d3948811abfd..6c10a8fea868f 100644
--- a/tests/qunit/wp-admin/js/updates.js
+++ b/tests/qunit/wp-admin/js/updates.js
@@ -158,6 +158,46 @@ jQuery( function( $ ) {
assert.equal( jQuery.ajax.getCall( 0 ).args[0].data.slug, 'twentyeleven' );
} );
+ QUnit.test( 'Canceling the credentials modal restores the theme details notice', function( assert ) {
+ var overlayNotice = $(
+ '
' +
+ '
Update Available
' +
+ '
There is a new version of Twenty Eleven available. update now.
' +
+ '
'
+ ).appendTo( '#qunit-fixture' ),
+ eventTarget = overlayNotice.find( '#update-theme' ),
+ rowNotice = $(
+ '' +
+ '
' +
+ '
There is a new version of Twenty Eleven available. update now.
' +
+ '
' +
+ '
'
+ ).appendTo( '#qunit-fixture' );
+
+ $( '' )
+ .appendTo( '#qunit-fixture' );
+
+ wp.updates.shouldRequestFilesystemCredentials = true;
+ wp.updates.filesystemCredentials.available = false;
+
+ wp.updates.maybeRequestFilesystemCredentials( $.Event( 'click', {
+ target: eventTarget[0]
+ } ) );
+
+ wp.updates.updateTheme( { slug: 'twentyeleven' } );
+
+ assert.strictEqual( wp.updates.queue.length, 1, 'Theme update waits for credentials.' );
+ assert.true( overlayNotice.hasClass( 'updating-message' ), 'Overlay notice is marked as updating.' );
+ assert.true( rowNotice.find( '.update-message' ).hasClass( 'updating-message' ), 'Theme row notice is marked as updating.' );
+
+ wp.updates.requestForCredentialsModalCancel();
+
+ assert.false( overlayNotice.hasClass( 'updating-message' ), 'Overlay notice resets after cancel.' );
+ assert.false( rowNotice.find( '.update-message' ).hasClass( 'updating-message' ), 'Theme row notice resets after cancel.' );
+ assert.notStrictEqual( overlayNotice.text().indexOf( 'Updating...' ), 0, 'Overlay notice no longer shows the updating text.' );
+ assert.notStrictEqual( rowNotice.text().indexOf( 'Updating...' ), 0, 'Theme row notice no longer shows the updating text.' );
+ } );
+
QUnit.test( 'Installing a theme should call the API', function( assert ) {
wp.updates.installTheme( { slug: 'twentyeleven' } );
assert.ok( jQuery.ajax.calledOnce );
diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js
index fd76c6c7a7836..687552c66ccb5 100644
--- a/tools/gutenberg/download.js
+++ b/tools/gutenberg/download.js
@@ -27,6 +27,7 @@ const zlib = require( 'zlib' );
const {
gutenbergDir,
readGutenbergConfig,
+ fetchWithRetry,
fetchGhcrToken,
fetchManifest,
} = require( './utils' );
@@ -151,11 +152,15 @@ async function main() {
*/
console.log( `\n📥 Downloading and extracting artifact...` );
try {
- const response = await fetch( `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`, {
- headers: {
- Authorization: `Bearer ${ token }`,
+ const response = await fetchWithRetry(
+ `https://ghcr.io/v2/${ config.ghcrRepo }/blobs/${ digest }`,
+ {
+ headers: {
+ Authorization: `Bearer ${ token }`,
+ },
},
- } );
+ `blob ${ digest }`
+ );
if ( ! response.ok ) {
throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` );
}
diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js
index 43047b5ee5dd7..763ed800ef53f 100644
--- a/tools/gutenberg/utils.js
+++ b/tools/gutenberg/utils.js
@@ -25,6 +25,82 @@ const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' );
const SHA_PATTERN = /^[a-f0-9]{40}$/i;
const MANIFEST_ACCEPT = 'application/vnd.oci.image.manifest.v1+json';
+const RETRYABLE_HTTP_STATUSES = new Set( [ 429, 500, 502, 503, 504 ] );
+const MAX_FETCH_RETRIES = 3;
+const RETRY_DELAY_MS = 1000;
+
+/**
+ * Pause execution for a short duration between transient fetch retries.
+ *
+ * @param {number} delayMs Delay in milliseconds.
+ * @return {Promise}
+ */
+function sleep( delayMs ) {
+ return new Promise( ( resolve ) => {
+ setTimeout( resolve, delayMs );
+ } );
+}
+
+/**
+ * Determine whether a fetch failure should be retried.
+ *
+ * @param {Response | undefined} response Fetch response when available.
+ * @param {unknown} error Fetch error when the request failed before a response.
+ * @return {boolean} Whether the failure appears transient.
+ */
+function shouldRetryFetch( response, error ) {
+ if ( response ) {
+ return RETRYABLE_HTTP_STATUSES.has( response.status );
+ }
+
+ return error instanceof TypeError;
+}
+
+/**
+ * Fetch a URL with retries for transient GHCR and network failures.
+ *
+ * @param {string} url Request URL.
+ * @param {RequestInit} options Fetch options.
+ * @param {string} resourceLabel Human-readable resource name for logs.
+ * @return {Promise} The successful or final response.
+ */
+async function fetchWithRetry( url, options, resourceLabel ) {
+ let lastError;
+
+ for ( let attempt = 1; attempt <= MAX_FETCH_RETRIES; attempt++ ) {
+ let response;
+
+ try {
+ response = await fetch( url, options );
+ } catch ( error ) {
+ lastError = error;
+
+ if ( attempt === MAX_FETCH_RETRIES || ! shouldRetryFetch( undefined, error ) ) {
+ throw error;
+ }
+
+ console.warn(
+ `Retrying ${ resourceLabel } after network failure (${ attempt }/${ MAX_FETCH_RETRIES })...`
+ );
+ await sleep( RETRY_DELAY_MS * attempt );
+ continue;
+ }
+
+ if ( response.ok || ! shouldRetryFetch( response, undefined ) || attempt === MAX_FETCH_RETRIES ) {
+ return response;
+ }
+
+ lastError = new Error(
+ `Failed to fetch ${ resourceLabel }: ${ response.status } ${ response.statusText }`
+ );
+ console.warn(
+ `Retrying ${ resourceLabel } after ${ response.status } ${ response.statusText } (${ attempt }/${ MAX_FETCH_RETRIES })...`
+ );
+ await sleep( RETRY_DELAY_MS * attempt );
+ }
+
+ throw lastError;
+}
/**
* Read Gutenberg configuration from package.json.
@@ -64,8 +140,10 @@ 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`
+ const response = await fetchWithRetry(
+ `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io`,
+ {},
+ 'GHCR token'
);
if ( ! response.ok ) {
throw new Error(
@@ -88,14 +166,15 @@ async function fetchGhcrToken( ghcrRepo ) {
* @return {Promise>} Parsed manifest JSON.
*/
async function fetchManifest( ref, ghcrRepo, token ) {
- const response = await fetch(
+ const response = await fetchWithRetry(
`https://ghcr.io/v2/${ ghcrRepo }/manifests/${ ref }`,
{
headers: {
Authorization: `Bearer ${ token }`,
Accept: MANIFEST_ACCEPT,
},
- }
+ },
+ `manifest for "${ ref }"`
);
if ( ! response.ok ) {
const error = /** @type {Error & { status?: number }} */ (
@@ -241,6 +320,7 @@ module.exports = {
gutenbergDir,
readGutenbergConfig,
verifyGutenbergVersion,
+ fetchWithRetry,
fetchGhcrToken,
fetchManifest,
resolveExpectedSha,
diff --git a/tools/local-env/scripts/install.js b/tools/local-env/scripts/install.js
index 038ecc3a67d5e..cba7f1169e3ad 100644
--- a/tools/local-env/scripts/install.js
+++ b/tools/local-env/scripts/install.js
@@ -9,8 +9,8 @@ const local_env_utils = require( './utils' );
dotenvExpand.expand( dotenv.config() );
-// Create wp-config.php.
-wp_cli( `config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --force --config-file="wp-config.php"` );
+// Create wp-config.php without waiting on the database to accept connections.
+wp_cli( `config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --skip-check --force --config-file="wp-config.php"` );
// Add the debug settings to wp-config.php.
// Windows requires this to be done as an additional step, rather than using the --extra-php option in the previous step.
diff --git a/tools/local-env/scripts/start.js b/tools/local-env/scripts/start.js
index 66559d4c10b85..caef49de2bf14 100644
--- a/tools/local-env/scripts/start.js
+++ b/tools/local-env/scripts/start.js
@@ -5,6 +5,12 @@ const dotenvExpand = require( 'dotenv-expand' );
const { execSync, spawnSync } = require( 'child_process' );
const local_env_utils = require( './utils' );
const { constants, copyFile } = require( 'node:fs' );
+const RETRYABLE_DOCKER_PULL_ERRORS = [
+ 'context deadline exceeded',
+ 'Client.Timeout exceeded while awaiting headers',
+ 'request canceled while waiting for connection',
+];
+const MAX_DOCKER_START_ATTEMPTS = 3;
// Copy the default .env file when one is not present.
copyFile( '.env.example', '.env', constants.COPYFILE_EXCL, () => {
@@ -32,18 +38,46 @@ if ( process.env.LOCAL_PHP_MEMCACHED === 'true' ) {
containers.push( 'memcached' );
}
-spawnSync(
- 'docker',
- [
- 'compose',
- ...composeFiles.map( ( composeFile ) => [ '-f', composeFile ] ).flat(),
- 'up',
- '--quiet-pull',
- '-d',
- ...containers,
- ],
- { stdio: 'inherit' }
-);
+let dockerUpResult;
+for ( let attempt = 1; attempt <= MAX_DOCKER_START_ATTEMPTS; attempt++ ) {
+ dockerUpResult = spawnSync(
+ 'docker',
+ [
+ 'compose',
+ ...composeFiles.map( ( composeFile ) => [ '-f', composeFile ] ).flat(),
+ 'up',
+ '--quiet-pull',
+ '-d',
+ ...containers,
+ ],
+ { encoding: 'utf8' }
+ );
+
+ if ( dockerUpResult.stdout ) {
+ process.stdout.write( dockerUpResult.stdout );
+ }
+
+ if ( dockerUpResult.stderr ) {
+ process.stderr.write( dockerUpResult.stderr );
+ }
+
+ if ( dockerUpResult.status === 0 ) {
+ break;
+ }
+
+ const output = `${ dockerUpResult.stdout || '' }\n${ dockerUpResult.stderr || '' }`;
+ const isRetryable = RETRYABLE_DOCKER_PULL_ERRORS.some( ( errorText ) =>
+ output.includes( errorText )
+ );
+
+ if ( ! isRetryable || attempt === MAX_DOCKER_START_ATTEMPTS ) {
+ process.exit( dockerUpResult.status || 1 );
+ }
+
+ console.warn(
+ `Retrying Docker environment startup after transient registry failure (${ attempt }/${ MAX_DOCKER_START_ATTEMPTS })...`
+ );
+}
// If Docker Toolbox is being used, we need to manually forward LOCAL_PORT to the Docker VM.
if ( process.env.DOCKER_TOOLBOX_INSTALL_PATH ) {