Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/js/_enqueues/wp/updates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
40 changes: 40 additions & 0 deletions tests/qunit/wp-admin/js/updates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $(
'<div class="notice notice-warning notice-alt notice-large" data-slug="twentyeleven">' +
'<h3 class="notice-title">Update Available</h3>' +
'<p><strong>There is a new version of Twenty Eleven available. <a id="update-theme" data-slug="twentyeleven" href="#">update now</a>.</strong></p>' +
'</div>'
).appendTo( '#qunit-fixture' ),
eventTarget = overlayNotice.find( '#update-theme' ),
rowNotice = $(
'<div class="theme" data-slug="twentyeleven">' +
'<div class="update-message notice inline notice-warning notice-alt">' +
'<p><strong>There is a new version of Twenty Eleven available. <a href="#">update now</a>.</strong></p>' +
'</div>' +
'</div>'
).appendTo( '#qunit-fixture' );

$( '<div id="request-filesystem-credentials-dialog"><form id="request-filesystem-credentials-form"></form></div>' )
.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 );
Expand Down
13 changes: 9 additions & 4 deletions tools/gutenberg/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const zlib = require( 'zlib' );
const {
gutenbergDir,
readGutenbergConfig,
fetchWithRetry,
fetchGhcrToken,
fetchManifest,
} = require( './utils' );
Expand Down Expand Up @@ -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 }` );
}
Expand Down
88 changes: 84 additions & 4 deletions tools/gutenberg/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
*/
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<Response>} 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.
Expand Down Expand Up @@ -64,8 +140,10 @@ function readGutenbergConfig() {
* @return {Promise<string>} 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(
Expand All @@ -88,14 +166,15 @@ async function fetchGhcrToken( ghcrRepo ) {
* @return {Promise<Record<string, any>>} 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 }} */ (
Expand Down Expand Up @@ -241,6 +320,7 @@ module.exports = {
gutenbergDir,
readGutenbergConfig,
verifyGutenbergVersion,
fetchWithRetry,
fetchGhcrToken,
fetchManifest,
resolveExpectedSha,
Expand Down
4 changes: 2 additions & 2 deletions tools/local-env/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 46 additions & 12 deletions tools/local-env/scripts/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => {
Expand Down Expand Up @@ -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 ) {
Expand Down
Loading