From aba5982487f2adeb5e643eb1f506a98f72dce168 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 25 May 2026 13:00:50 +1000 Subject: [PATCH 01/15] Theme Directory: Add a 24-hour release cooldown between approval and serving to users. Mirrors the plugin-side cooldown (PR #650) for the themes directory. When a theme version is approved (by a reviewer closing the Trac ticket live, or via the auto-approval path for theme updates), it now enters a new internal 'approved' status that holds the version back from the themes API for WPORG_THEMES_RELEASE_COOL_DOWN_DELAY (24h) before being promoted to 'live'. The previous live version (if any) continues to be served from the existing _status meta during the window. Implementation: - A new 'approved' value alongside new/live/old in the _status meta. The redirect from 'live' to 'approved' happens inside wporg_themes_update_version_status() so all entry points (trac-sync, auto-approved updates in the upload class, rollback) flow through the same gate. The cron handler re-enters with old_status='approved' and writes through. - Per-version _approval_time and _release_delay meta capture the cooldown active at approval time, so future constant changes don't retroactively affect in-flight cooldowns. Reviewers force-release by zeroing _release_delay. - A new wporg_themes_release_to_live:{slug} cron event is scheduled at approval; the colon-based hook (matching the plugin directory pattern) lets wp_clear_scheduled_hook() target a single theme's pending event without args lookup. The themes jobs Manager picks up a wildcard handler matching the plugin directory's mechanism. - A reviewer Force-release control (requires suspend_themes cap) on the Theme Versions metabox, gated by an audit-logged reason via wp_insert_comment(). - Authors receive an "approved, going live in 24h" email when the cooldown starts, in addition to the existing "now live" email when it actually elapses, so the gap between Trac-approved and serving-to-users is explained. - Rollbacks and manual admin metabox saves pass bypass_cooldown=true -- the operator is explicitly pushing a version live and shouldn't wait. Note: this implementation does not require any Trac workflow changes. Trac continues to close tickets with resolution=live; the WordPress.org side translates that to the internal 'approved' state. A follow-up could add an 'approved' resolution to Trac itself for parity on the reviewer UI, but it is not required for this gate to work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/theme-directory/admin-edit.php | 84 +++++- .../class-wporg-themes-upload.php | 6 +- .../theme-directory/jobs/class-manager.php | 78 ++++++ .../theme-directory/jobs/class-trac-sync.php | 8 +- .../theme-directory/theme-directory.php | 263 +++++++++++++++++- 5 files changed, 428 insertions(+), 11 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php index 865a313182..24ee611475 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php @@ -581,12 +581,76 @@ function wporg_themes_meta_box_callback( $post ) {

-

status. + */ +function wporg_themes_meta_box_cooldown_section( $post, $versions ) { + if ( ! WPORG_THEMES_RELEASE_COOL_DOWN_DELAY ) { + return; + } + + $approved_version = array_search( 'approved', (array) $versions, true ); + if ( ! $approved_version ) { + return; + } + + $cooldown_until = wporg_themes_get_cooldown_until( $post->ID, $approved_version ); + if ( ! $cooldown_until || $cooldown_until <= time() ) { + return; + } + + ?> +
+

+ +

+ ID ) ) : ?> +

+ + +

+

+ +

+ $status ) { // We could check of the passed status is valid, but wporg_themes_update_version_status() handles that beautifully. @@ -625,9 +704,10 @@ function wporg_themes_save_meta_box_data( $post_id ) { } uksort( $new_status, 'version_compare' ); - // Update the statuses. + // Update the statuses. Manual admin saves bypass the release cooldown — when an + // operator explicitly selects 'Live' here they want it to take effect immediately. foreach ( $new_status as $version => $status ) { - wporg_themes_update_version_status( $post_id, $version, $status ); + wporg_themes_update_version_status( $post_id, $version, $status, true ); } } add_action( 'save_post', 'wporg_themes_save_meta_box_data' ); diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php index d2390a37f4..ea1c53a056 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php @@ -1158,8 +1158,10 @@ public function prepare_trac_ticket() { $this->trac_ticket->priority = 'new theme'; if ( ! empty( $this->theme_post->_status ) ) { - // Is this an update to an existing, approved theme? - if ( 'live' === $this->theme_post->_status[ $this->theme_post->max_version ] ) { + // Is this an update to an existing, approved theme? An 'approved' status + // (live version still in release cooldown) counts as approved for ticket + // priority — the previous live version is still being served. + if ( in_array( $this->theme_post->_status[ $this->theme_post->max_version ], [ 'live', 'approved' ], true ) ) { $this->trac_ticket->priority = 'theme update'; // Apparently not, it must be a new upload for previously unapproved theme. diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php index 523a680f1c..1ce5397caa 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php @@ -14,6 +14,17 @@ */ class Manager { + /** + * Colon-based hook names mapped to their handlers. The slug is encoded into the + * hook name so wp_clear_scheduled_hook() can target a single theme's pending + * event without args lookup. See `register_colon_based_hook_handlers()`. + * + * @var array + */ + public static $wildcard_cron_tasks = array( + 'wporg_themes_release_to_live' => 'wporg_themes_cron_release_to_live', + ); + /** * Add all the actions for cron tasks and schedules. */ @@ -31,6 +42,73 @@ public function __construct() { // Import from SVN tasks. add_action( 'theme_directory_svn_import_watcher', [ __NAMESPACE__ . '\SVN_Import', 'watcher_trigger' ] ); add_action( 'theme_directory_svn_import', [ __NAMESPACE__ . '\SVN_Import', 'import_trigger' ] ); + + // Register the colon-based cron handlers (wporg_themes_release_to_live:{slug}, etc). + if ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { + // This must run after plugins_loaded so Cavalcade has had a chance to hook in. + add_action( 'init', [ $this, 'register_colon_based_hook_handlers' ] ); + } + } + + /** + * The WordPress Cron implementation can't easily check whether a job is already + * enqueued by args, so we encode the theme slug into the hook name (matching the + * plugin directory's pattern). Hooks like `wporg_themes_release_to_live:my-theme` + * don't auto-resolve to a handler — this method scans pending cron entries and + * attaches the matching handler so the event runs when fired. + */ + public function register_colon_based_hook_handlers() { + $add_callback = static function ( $hook ) { + if ( ! str_contains( $hook, ':' ) ) { + return; + } + + $partial_hook = explode( ':', $hook )[0]; + $callback = self::$wildcard_cron_tasks[ $partial_hook ] ?? false; + + if ( ! $callback ) { + return; + } + + if ( ! has_action( $hook, $callback ) ) { + add_action( $hook, $callback, 10, PHP_INT_MAX ); + } + }; + + // Flush the Cavalcade jobs cache so we see fresh entries from the database. + wp_cache_delete( 'jobs', 'cavalcade-jobs' ); + + foreach ( _get_cron_array() as $timestamp => $handlers ) { + if ( ! is_numeric( $timestamp ) ) { + continue; + } + + foreach ( $handlers as $hook => $jobs ) { + $add_callback( $hook ); + } + } + + /* + * When jobs are run manually or after-the-fact, we also need to find the current + * job by id, since it may not be in the pending cron array yet. + */ + if ( + class_exists( '\HM\Cavalcade\Plugin\Job' ) && + ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) + ) { + $job_id = $GLOBALS['job_id'] ?? false; + + if ( ! $job_id && in_array( 'run', $GLOBALS['argv'] ?? [], true ) ) { + $job_id = $GLOBALS['argv'][ array_search( 'run', $GLOBALS['argv'] ) + 1 ] ?? false; + } + + if ( $job_id && is_numeric( $job_id ) ) { + $job = \HM\Cavalcade\Plugin\Job::get( $job_id ); + if ( $job ) { + $add_callback( $job->hook ); + } + } + } } /** diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php index a5fe2de825..1f3ac2e492 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php @@ -81,7 +81,10 @@ public static function cron_trigger() { * * For approved and rejected themes, we bail if the current status is not * 'new' That can happen when there are additional ticket updates (like - * comments) after the ticket was closed. + * comments) after the ticket was closed. 'approved' (a version that the + * directory has already accepted and is holding in the release cooldown) + * counts the same as 'live' here — we shouldn't reprocess it on every + * subsequent ticket comment. * * For reopened tickets we bail if the version is already marked as 'new'. * This should only be the case if the ticket was closed and reopened before @@ -93,7 +96,8 @@ public static function cron_trigger() { } // We don't need to set an already approved live version to live again. - if ( 'live' === $current_status && 'live' === $new_status ) { + // 'approved' (in cooldown) likewise needs no re-processing. + if ( in_array( $current_status, [ 'live', 'approved' ], true ) && 'live' === $new_status ) { continue; } diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php index 79047e5aee..fa3279e4e7 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php @@ -43,6 +43,17 @@ define( 'WPORG_THEMES_E2E_REPO', 'WordPress/theme-review-e2e' ); +/** + * Delay between a theme version being approved (by a reviewer on Trac, or via the + * auto-approval path for theme updates) and it becoming the live version served to + * sites by the themes API. The previous live version (if any) continues to be served + * until the cooldown elapses. Mitigates supply-chain risks by giving scanners and + * humans a window to flag bad releases. Reviewers can bypass the cooldown via the + * wp-admin force-release control on the Theme Versions metabox; see + * wporg_themes_force_release_version(). + */ +define( 'WPORG_THEMES_RELEASE_COOL_DOWN_DELAY', 24 * HOUR_IN_SECONDS ); + /** * Things to change on activation. */ @@ -301,6 +312,23 @@ function wporg_themes_get_version_status( $post_id, $version ) { return wporg_themes_get_version_meta( $post_id, '_status', $version ); } +/** + * Sets the specified meta value for a version of a theme. + * + * @param int $post_id Post ID. + * @param string $meta_key Post meta key holding the version => value map. + * @param string $version The theme version to set the meta value for. + * @param mixed $meta_value The value to store for that version. + * @return int|bool Meta ID if the key didn't exist, true on update, false on failure. + */ +function wporg_themes_set_version_meta( $post_id, $meta_key, $version, $meta_value ) { + $meta = (array) get_post_meta( $post_id, $meta_key, true ); + + $meta[ $version ] = $meta_value; + + return update_post_meta( $post_id, $meta_key, $meta ); +} + /** * Replacement for the Author meta box on theme pages */ @@ -400,13 +428,17 @@ function wporg_themes_author_lookup() { /** * Handles updating the status of theme versions. * - * @param int $post_id Post ID. - * @param string $current_version The theme version to update. - * @param string $new_status The status to update the current version to. + * @param int $post_id Post ID. + * @param string $current_version The theme version to update. + * @param string $new_status The status to update the current version to. + * @param bool $bypass_cooldown Whether to bypass the release cooldown for a 'live' transition. + * Used by rollbacks, the force-release control, and manual admin + * metabox saves where the operator is explicitly pushing a version + * live without waiting for the cooldown window. * @return int|bool Meta ID if the key didn't exist, true on successful update, * false on failure. */ -function wporg_themes_update_version_status( $post_id, $current_version, $new_status ) { +function wporg_themes_update_version_status( $post_id, $current_version, $new_status, $bypass_cooldown = false ) { $meta = get_post_meta( $post_id, '_status', true ) ?: array(); $old_status = false; @@ -419,10 +451,29 @@ function wporg_themes_update_version_status( $post_id, $current_version, $new_st return; } + /* + * Redirect a 'live' transition into the 'approved' holding state when a cooldown is + * in effect. The previous live version continues to be served by the API while the + * cooldown elapses; a scheduled cron then transitions 'approved' -> 'live' (which + * re-enters this function with `$old_status === 'approved'`, falling through). + * + * Callers that need to push a version live immediately pass `$bypass_cooldown = true` + * (rollbacks, the reviewer force-release control, and direct admin metabox saves). + */ + if ( + 'live' === $new_status && + WPORG_THEMES_RELEASE_COOL_DOWN_DELAY && + ! $bypass_cooldown && + 'approved' !== $old_status + ) { + $new_status = 'approved'; + } + switch ( $new_status ) { // There can only be one version with these statuses: case 'new': case 'live': + case 'approved': // Discard all previous versions with that status. foreach ( array_keys( $meta, $new_status ) as $version ) { if ( version_compare( $version, $current_version, '<' ) ) { @@ -690,10 +741,212 @@ function wporg_themes_rollback_version( $post_id, $current_version, $old_status $ticket_ids = get_post_meta( $post_id, '_ticket_id', true ); $prev_version = array_search( $ticket, $ticket_ids ); - wporg_themes_update_version_status( $post_id, $prev_version, 'live' ); + // Bypass the release cooldown: a rollback is an explicit reviewer action + // to restore a previously-live version, not a fresh approval. + wporg_themes_update_version_status( $post_id, $prev_version, 'live', true ); } add_action( 'wporg_themes_update_version_new', 'wporg_themes_rollback_version', 10, 3 ); +/** + * Compute the timestamp when a version's release cooldown will elapse, allowing it + * to transition from 'approved' to 'live'. + * + * Returns 0 when no cooldown is active (no approval timestamp recorded, or no delay + * captured for this version). Reading the delay from per-version meta — set at approval + * time — means future changes to WPORG_THEMES_RELEASE_COOL_DOWN_DELAY don't retroactively + * affect in-flight cooldowns. Reviewers bypass the cooldown by setting the delay to 0. + * + * @param int $post_id Theme post ID. + * @param string $version The theme version to check. + * @return int Unix timestamp when the cooldown elapses, or 0 if no cooldown applies. + */ +function wporg_themes_get_cooldown_until( $post_id, $version ) { + $approval_time = (int) wporg_themes_get_version_meta( $post_id, '_approval_time', $version ); + if ( ! $approval_time ) { + return 0; + } + + $release_delay = (int) wporg_themes_get_version_meta( $post_id, '_release_delay', $version ); + if ( ! $release_delay ) { + return 0; + } + + return $approval_time + $release_delay; +} + +/** + * Handle a version transitioning into the 'approved' holding state. + * + * Records the per-version approval time and the cooldown delay active at approval + * (so future constant changes don't retroactively affect in-flight cooldowns), + * schedules the cron that will promote it to 'live' once the cooldown elapses, + * and emails the theme author so the gap between Trac-approved and serving-to-users + * is explained. + * + * @param int $post_id Theme post ID. + * @param string $version The theme version that was just approved. + * @param string $old_status The status the version had before approval. + */ +function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return; + } + + $approval_time = time(); + $release_delay = (int) WPORG_THEMES_RELEASE_COOL_DOWN_DELAY; + + wporg_themes_set_version_meta( $post_id, '_approval_time', $version, $approval_time ); + wporg_themes_set_version_meta( $post_id, '_release_delay', $version, $release_delay ); + + // Replace any earlier scheduled release so a re-approval (e.g. a new version + // uploaded mid-cooldown) fully resets the window for the latest 'approved' version. + $hook = "wporg_themes_release_to_live:{$post->post_name}"; + wp_clear_scheduled_hook( $hook ); + wp_schedule_single_event( $approval_time + $release_delay, $hook ); + + // Notify the theme author. The "now live" email from wporg_themes_approve_version() + // still fires when the cooldown elapses; this fills the gap between Trac-approved + // and serving-to-users so the author isn't left wondering why their theme isn't live yet. + $ticket_id = wporg_themes_get_version_meta( $post_id, '_ticket_id', $version ); + + $subject = sprintf( + /* translators: 1: theme name, 2: theme version, 3: hours until live */ + __( '[WordPress Themes] %1$s %2$s approved — going live in %3$d hours', 'wporg-themes' ), + $post->post_title, + $version, + $release_delay / HOUR_IN_SECONDS + ); + + $content = sprintf( + /* translators: 1: theme version, 2: theme name, 3: hours until live */ + __( 'Version %1$s of %2$s has been approved and will be served to WordPress users in about %3$d hours.', 'wporg-themes' ), + $version, + $post->post_title, + $release_delay / HOUR_IN_SECONDS + ) . "\n\n"; + $content .= __( 'WordPress.org delays new theme releases by 24 hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, please contact themes@wordpress.org.', 'wporg-themes' ) . "\n\n"; + + if ( $ticket_id ) { + $content .= sprintf( __( 'The review ticket is at %s.', 'wporg-themes' ), "https://themes.trac.wordpress.org/ticket/{$ticket_id}" ) . "\n\n"; + } + + $content .= "--\n"; + $content .= __( 'The WordPress.org Themes Team', 'wporg-themes' ) . "\n"; + $content .= 'https://make.wordpress.org/themes'; + + wp_mail( get_user_by( 'id', $post->post_author )->user_email, $subject, $content, 'From: "WordPress Theme Directory" ' ); +} +add_action( 'wporg_themes_update_version_approved', 'wporg_themes_handle_approval_cooldown', 10, 3 ); + +/** + * Clear the scheduled release-to-live cron whenever a version transitions out of + * 'approved': promoted to 'live' by the cron firing (no-op — the event has already + * dequeued itself), force-released to 'live' by a reviewer (leftover schedule needs + * removing), reopened back to 'new', or marked 'old' because a newer version was + * uploaded mid-cooldown. + * + * @param int $post_id Theme post ID. + * @param string $version The theme version that was just updated. + * @param string $new_status The new status. + * @param string $old_status The previous status. + */ +function wporg_themes_clear_cooldown_cron_on_transition( $post_id, $version, $new_status, $old_status ) { + if ( 'approved' !== $old_status ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return; + } + + wp_clear_scheduled_hook( "wporg_themes_release_to_live:{$post->post_name}" ); +} +add_action( 'wporg_themes_update_version_status', 'wporg_themes_clear_cooldown_cron_on_transition', 10, 4 ); + +/** + * Cron handler for `wporg_themes_release_to_live:{slug}`. Fires when a version's release + * cooldown elapses; promotes the currently-'approved' version to 'live' so the existing + * wporg_themes_approve_version() handler can publish the post, update wp-themes.com, + * push translations to GlotPress, and email the author. The slug is recovered from the + * dynamic hook name so no args flow through cron. + */ +function wporg_themes_cron_release_to_live() { + list( , $theme_slug ) = explode( ':', current_filter(), 2 ); + + $theme = get_posts( array( + 'name' => $theme_slug, + 'post_type' => 'repopackage', + 'post_status' => 'any', + 'numberposts' => 1, + ) ); + if ( ! $theme ) { + return; + } + + $post = $theme[0]; + $status = (array) get_post_meta( $post->ID, '_status', true ); + $version = array_search( 'approved', $status ); + if ( ! $version ) { + // Nothing in 'approved' — the cooldown was already resolved (force-released, + // rolled back, or a newer version superseded this one). + return; + } + + wporg_themes_update_version_status( $post->ID, $version, 'live' ); +} + +/** + * Reviewer force-release: clear the cooldown for a theme's currently-approved version and + * transition it to 'live' immediately. Logs the action via the internal-notes audit trail. + * + * Capability checks must be performed by the caller. + * + * @param int $post_id Theme post ID. + * @param string $reason Free-text reason recorded in the audit log. + * @return bool True on success. + */ +function wporg_themes_force_release_version( $post_id, $reason ) { + $post = get_post( $post_id ); + if ( ! $post || 'repopackage' !== $post->post_type ) { + return false; + } + + $status = (array) get_post_meta( $post_id, '_status', true ); + $version = array_search( 'approved', $status ); + if ( ! $version ) { + return false; + } + + $release_delay = (int) wporg_themes_get_version_meta( $post_id, '_release_delay', $version ); + + // Zero the per-version delay so any future re-entry through the cooldown gate + // (e.g. if `update_version_status` is called again before the cron fires) writes through. + wporg_themes_set_version_meta( $post_id, '_release_delay', $version, 0 ); + + // Log to the internal notes via a private comment, matching the audit pattern used + // elsewhere in the theme directory for moderator actions. + wp_insert_comment( array( + 'comment_post_ID' => $post_id, + 'user_id' => get_current_user_id(), + 'comment_author' => wp_get_current_user()->user_login, + 'comment_type' => 'internal-note', + 'comment_approved' => 0, + 'comment_content' => sprintf( + /* translators: 1: version, 2: hours of cooldown bypassed, 3: reason */ + __( 'Force-released version %1$s, bypassing the %2$d-hour release cooldown. Reason: %3$s', 'wporg-themes' ), + $version, + $release_delay / HOUR_IN_SECONDS, + $reason + ), + ) ); + + wporg_themes_update_version_status( $post_id, $version, 'live', true ); + + return true; +} + /** * Updates wp-themes.com with the latest version of a theme. * From 00b99788ca41f7e6c8aea176a724f85b533824dd Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 25 May 2026 13:04:36 +1000 Subject: [PATCH 02/15] Theme Directory: Cooldown: Guard the 0-hour disabled path so no "live in 0 hours" emails. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When WPORG_THEMES_RELEASE_COOL_DOWN_DELAY is 0 (feature off), the redirect from 'live' to 'approved' is already skipped — but nothing prevented an admin from manually selecting 'approved' in the Theme Versions metabox dropdown. That would have stored a 0-second cooldown, scheduled a same-second cron, and emailed the author "approved, going live in 0 hours". - wporg_themes_update_version_status() now folds an explicit 'approved' into 'live' when the cooldown is disabled at the constant level. - The approval handler bails defensively if it sees a 0 release_delay (so even non-default code paths don't email or schedule). - The 'Approved (in cooldown)' option is hidden from the admin metabox dropdown unless the feature is on (or the version is already in that state, so historical 'approved' rows remain transitionable). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/theme-directory/admin-edit.php | 4 +++- .../theme-directory/theme-directory.php | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php index 24ee611475..53bc9d6e0f 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php @@ -581,7 +581,9 @@ function wporg_themes_meta_box_callback( $post ) {

- diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php index fa3279e4e7..c730f4d06d 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php @@ -469,6 +469,16 @@ function wporg_themes_update_version_status( $post_id, $current_version, $new_st $new_status = 'approved'; } + /* + * Conversely: if a caller explicitly sets 'approved' (e.g. admin selecting it from + * the metabox dropdown) while the cooldown is disabled at the constant level, treat + * it as 'live'. Holding a version in 'approved' with no scheduled promotion would + * strand it indefinitely and trigger a "going live in 0 hours" notification. + */ + if ( 'approved' === $new_status && ! WPORG_THEMES_RELEASE_COOL_DOWN_DELAY ) { + $new_status = 'live'; + } + switch ( $new_status ) { // There can only be one version with these statuses: case 'new': @@ -793,8 +803,16 @@ function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status return; } - $approval_time = time(); $release_delay = (int) WPORG_THEMES_RELEASE_COOL_DOWN_DELAY; + if ( ! $release_delay ) { + // Defensive: wporg_themes_update_version_status() already converts an explicit + // 'approved' into 'live' when the cooldown is disabled, so this branch shouldn't + // fire in normal flow. Bail rather than schedule a same-second cron and email + // the author about a "0 hours until live" wait. + return; + } + + $approval_time = time(); wporg_themes_set_version_meta( $post_id, '_approval_time', $version, $approval_time ); wporg_themes_set_version_meta( $post_id, '_release_delay', $version, $release_delay ); From 17d6e7b79fa042f7cd05ceb03ad5f9dd2b2349ee Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 25 May 2026 13:09:22 +1000 Subject: [PATCH 03/15] Theme Directory: Cooldown: Interpolate the delay duration and retry Job::get on HyperDB lag. Two findings from the PR review on #651: - The "release cooldown" email body hard-coded "delays by 24 hours". Replace the literal with the per-version release_delay so the message stays accurate if WPORG_THEMES_RELEASE_COOL_DOWN_DELAY is ever tuned. - Mirror the plugin-directory's Cavalcade Job::get() fallback: if the lookup returns nothing because a HyperDB read replica hasn't caught up yet, retry against the master server before giving up. Without it, colon-based hooks can run with no handler attached when triggered manually via wp cavalcade run while replication is lagging. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../theme-directory/jobs/class-manager.php | 15 +++++++++++++++ .../plugins/theme-directory/theme-directory.php | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php index 1ce5397caa..b4138be5ca 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php @@ -58,6 +58,8 @@ public function __construct() { * attaches the matching handler so the event runs when fired. */ public function register_colon_based_hook_handlers() { + global $wpdb; + $add_callback = static function ( $hook ) { if ( ! str_contains( $hook, ':' ) ) { return; @@ -104,6 +106,19 @@ class_exists( '\HM\Cavalcade\Plugin\Job' ) && if ( $job_id && is_numeric( $job_id ) ) { $job = \HM\Cavalcade\Plugin\Job::get( $job_id ); + + // Retry against a master DB if HyperDB's read-replica lag returned no row. + if ( + ! $job && + isset( $wpdb->srtm ) && + is_callable( [ $wpdb, 'send_reads_to_masters' ] ) + ) { + $srtm = $wpdb->srtm; + $wpdb->send_reads_to_masters(); + $job = \HM\Cavalcade\Plugin\Job::get( $job_id ); + $wpdb->srtm = $srtm; + } + if ( $job ) { $add_callback( $job->hook ); } diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php index c730f4d06d..65e7690277 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php @@ -843,7 +843,11 @@ function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status $post->post_title, $release_delay / HOUR_IN_SECONDS ) . "\n\n"; - $content .= __( 'WordPress.org delays new theme releases by 24 hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, please contact themes@wordpress.org.', 'wporg-themes' ) . "\n\n"; + $content .= sprintf( + /* translators: %d: cooldown duration in hours */ + __( 'WordPress.org delays new theme releases by %d hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, please contact themes@wordpress.org.', 'wporg-themes' ), + $release_delay / HOUR_IN_SECONDS + ) . "\n\n"; if ( $ticket_id ) { $content .= sprintf( __( 'The review ticket is at %s.', 'wporg-themes' ), "https://themes.trac.wordpress.org/ticket/{$ticket_id}" ) . "\n\n"; From 65d865a2841005c7ff5cc962d7019b51efb906aa Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 25 May 2026 13:11:45 +1000 Subject: [PATCH 04/15] Theme Directory: Cooldown: Switch the release-to-live cron to args-based. Drop the colon-based hook name (wporg_themes_release_to_live:{slug}) and the wildcard-cron-handler registration that went with it in jobs/class-manager.php. The handler now lives in theme-directory.php and is hooked directly with add_action(), with $post_id passed as the single cron arg. Scheduling / clearing use the same args tuple so they target one theme's pending event without the dedicated dispatch machinery. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../theme-directory/jobs/class-manager.php | 93 ------------------- .../theme-directory/theme-directory.php | 39 +++----- 2 files changed, 11 insertions(+), 121 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php index b4138be5ca..523a680f1c 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php @@ -14,17 +14,6 @@ */ class Manager { - /** - * Colon-based hook names mapped to their handlers. The slug is encoded into the - * hook name so wp_clear_scheduled_hook() can target a single theme's pending - * event without args lookup. See `register_colon_based_hook_handlers()`. - * - * @var array - */ - public static $wildcard_cron_tasks = array( - 'wporg_themes_release_to_live' => 'wporg_themes_cron_release_to_live', - ); - /** * Add all the actions for cron tasks and schedules. */ @@ -42,88 +31,6 @@ public function __construct() { // Import from SVN tasks. add_action( 'theme_directory_svn_import_watcher', [ __NAMESPACE__ . '\SVN_Import', 'watcher_trigger' ] ); add_action( 'theme_directory_svn_import', [ __NAMESPACE__ . '\SVN_Import', 'import_trigger' ] ); - - // Register the colon-based cron handlers (wporg_themes_release_to_live:{slug}, etc). - if ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { - // This must run after plugins_loaded so Cavalcade has had a chance to hook in. - add_action( 'init', [ $this, 'register_colon_based_hook_handlers' ] ); - } - } - - /** - * The WordPress Cron implementation can't easily check whether a job is already - * enqueued by args, so we encode the theme slug into the hook name (matching the - * plugin directory's pattern). Hooks like `wporg_themes_release_to_live:my-theme` - * don't auto-resolve to a handler — this method scans pending cron entries and - * attaches the matching handler so the event runs when fired. - */ - public function register_colon_based_hook_handlers() { - global $wpdb; - - $add_callback = static function ( $hook ) { - if ( ! str_contains( $hook, ':' ) ) { - return; - } - - $partial_hook = explode( ':', $hook )[0]; - $callback = self::$wildcard_cron_tasks[ $partial_hook ] ?? false; - - if ( ! $callback ) { - return; - } - - if ( ! has_action( $hook, $callback ) ) { - add_action( $hook, $callback, 10, PHP_INT_MAX ); - } - }; - - // Flush the Cavalcade jobs cache so we see fresh entries from the database. - wp_cache_delete( 'jobs', 'cavalcade-jobs' ); - - foreach ( _get_cron_array() as $timestamp => $handlers ) { - if ( ! is_numeric( $timestamp ) ) { - continue; - } - - foreach ( $handlers as $hook => $jobs ) { - $add_callback( $hook ); - } - } - - /* - * When jobs are run manually or after-the-fact, we also need to find the current - * job by id, since it may not be in the pending cron array yet. - */ - if ( - class_exists( '\HM\Cavalcade\Plugin\Job' ) && - ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) - ) { - $job_id = $GLOBALS['job_id'] ?? false; - - if ( ! $job_id && in_array( 'run', $GLOBALS['argv'] ?? [], true ) ) { - $job_id = $GLOBALS['argv'][ array_search( 'run', $GLOBALS['argv'] ) + 1 ] ?? false; - } - - if ( $job_id && is_numeric( $job_id ) ) { - $job = \HM\Cavalcade\Plugin\Job::get( $job_id ); - - // Retry against a master DB if HyperDB's read-replica lag returned no row. - if ( - ! $job && - isset( $wpdb->srtm ) && - is_callable( [ $wpdb, 'send_reads_to_masters' ] ) - ) { - $srtm = $wpdb->srtm; - $wpdb->send_reads_to_masters(); - $job = \HM\Cavalcade\Plugin\Job::get( $job_id ); - $wpdb->srtm = $srtm; - } - - if ( $job ) { - $add_callback( $job->hook ); - } - } - } } /** diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php index 65e7690277..2e6a6de9b9 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php @@ -819,9 +819,8 @@ function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status // Replace any earlier scheduled release so a re-approval (e.g. a new version // uploaded mid-cooldown) fully resets the window for the latest 'approved' version. - $hook = "wporg_themes_release_to_live:{$post->post_name}"; - wp_clear_scheduled_hook( $hook ); - wp_schedule_single_event( $approval_time + $release_delay, $hook ); + wp_clear_scheduled_hook( 'wporg_themes_release_to_live', array( $post_id ) ); + wp_schedule_single_event( $approval_time + $release_delay, 'wporg_themes_release_to_live', array( $post_id ) ); // Notify the theme author. The "now live" email from wporg_themes_approve_version() // still fires when the cooldown elapses; this fills the gap between Trac-approved @@ -878,37 +877,20 @@ function wporg_themes_clear_cooldown_cron_on_transition( $post_id, $version, $ne return; } - $post = get_post( $post_id ); - if ( ! $post ) { - return; - } - - wp_clear_scheduled_hook( "wporg_themes_release_to_live:{$post->post_name}" ); + wp_clear_scheduled_hook( 'wporg_themes_release_to_live', array( $post_id ) ); } add_action( 'wporg_themes_update_version_status', 'wporg_themes_clear_cooldown_cron_on_transition', 10, 4 ); /** - * Cron handler for `wporg_themes_release_to_live:{slug}`. Fires when a version's release + * Cron handler for `wporg_themes_release_to_live`. Fires when a version's release * cooldown elapses; promotes the currently-'approved' version to 'live' so the existing * wporg_themes_approve_version() handler can publish the post, update wp-themes.com, - * push translations to GlotPress, and email the author. The slug is recovered from the - * dynamic hook name so no args flow through cron. + * push translations to GlotPress, and email the author. + * + * @param int $post_id Theme post ID whose 'approved' version should be promoted. */ -function wporg_themes_cron_release_to_live() { - list( , $theme_slug ) = explode( ':', current_filter(), 2 ); - - $theme = get_posts( array( - 'name' => $theme_slug, - 'post_type' => 'repopackage', - 'post_status' => 'any', - 'numberposts' => 1, - ) ); - if ( ! $theme ) { - return; - } - - $post = $theme[0]; - $status = (array) get_post_meta( $post->ID, '_status', true ); +function wporg_themes_cron_release_to_live( $post_id ) { + $status = (array) get_post_meta( $post_id, '_status', true ); $version = array_search( 'approved', $status ); if ( ! $version ) { // Nothing in 'approved' — the cooldown was already resolved (force-released, @@ -916,8 +898,9 @@ function wporg_themes_cron_release_to_live() { return; } - wporg_themes_update_version_status( $post->ID, $version, 'live' ); + wporg_themes_update_version_status( $post_id, $version, 'live' ); } +add_action( 'wporg_themes_release_to_live', 'wporg_themes_cron_release_to_live' ); /** * Reviewer force-release: clear the cooldown for a theme's currently-approved version and From 8c7488e365af64c59ce2ff97e734fa5da306b3e4 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 25 May 2026 13:35:23 +1000 Subject: [PATCH 05/15] Theme Directory: Cooldown: Gate force-release on a theme_review meta cap. Replace the suspend_theme check at the two cooldown call sites (the metabox force-release control + its save_post handler) with theme_review, mapped via map_meta_cap to the same suspend_themes primitive the moderator roles already carry. Same audience as before; the name now reads as a reviewer action rather than a moderation/take-down action. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/theme-directory/admin-edit.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php index 53bc9d6e0f..66c640f09b 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php @@ -85,6 +85,14 @@ function wporg_themes_map_meta_cap( $caps, $cap, $user_id, $context ) { unset( $caps[ array_search( $cap, $caps ) ] ); break; + case 'theme_review': + // Theme reviewers are the same role audience as theme moderators today, so + // the meta cap maps onto the existing 'suspend_themes' primitive — the name + // is for intent ("this is a reviewer action") rather than to gate a new group. + $caps[] = 'suspend_themes'; + unset( $caps[ array_search( $cap, $caps ) ] ); + break; + case 'theme_configure_categorization_options': // Protect against a cap call without a theme context. $post = $context ? get_post( $context[0] ) : false; @@ -630,7 +638,7 @@ function wporg_themes_meta_box_cooldown_section( $post, $versions ) { ); ?>

- ID ) ) : ?> + ID ) ) : ?>

-

-

- -

- $status ) { // We could check of the passed status is valid, but wporg_themes_update_version_status() handles that beautifully. @@ -716,10 +629,9 @@ function wporg_themes_save_meta_box_data( $post_id ) { } uksort( $new_status, 'version_compare' ); - // Update the statuses. Manual admin saves bypass the release cooldown — when an - // operator explicitly selects 'Live' here they want it to take effect immediately. + // Update the statuses. foreach ( $new_status as $version => $status ) { - wporg_themes_update_version_status( $post_id, $version, $status, true ); + wporg_themes_update_version_status( $post_id, $version, $status ); } } add_action( 'save_post', 'wporg_themes_save_meta_box_data' ); diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php index ea1c53a056..6101a90324 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php @@ -1332,13 +1332,24 @@ public function create_or_update_trac_ticket() { 'owner' => '', ) ); - // Themes team auto-approves theme-updates, so mark the theme as live immediately. - // Note that this only applies to new ticket creation, so it won't happen on themes with existing outstanding tickets. + // Themes team auto-approves theme-updates. Note that this only applies to new + // ticket creation, so it won't happen on themes with existing outstanding tickets. if ( $this->trac_ticket->priority == 'theme update' ) { - $this->trac->ticket_update( $ticket_id, 'Theme Update for existing Live theme - automatically approved', array( 'action' => 'new_no_review' ), false ); + if ( WPORG_THEMES_RELEASE_COOL_DOWN_DELAY ) { + // Land the update in the `approved` status; the release-to-live cron + // promotes it to live once the cooldown elapses. The previous live + // version continues to be served in the meantime. + $cooldown_hours = (int) round( WPORG_THEMES_RELEASE_COOL_DOWN_DELAY / HOUR_IN_SECONDS ); + $this->trac->ticket_update( $ticket_id, sprintf( 'Theme Update for existing Live theme - automatically approved, will be marked live in %dhrs.', $cooldown_hours ), array( 'action' => 'new_no_review_delay' ), false ); + + $this->version_status = 'approved'; + } else { + // Cooldown disabled: mark the theme live immediately. + $this->trac->ticket_update( $ticket_id, 'Theme Update for existing Live theme - automatically approved', array( 'action' => 'new_no_review' ), false ); - $this->trac_ticket->resolution = 'live'; - $this->version_status = 'live'; + $this->trac_ticket->resolution = 'live'; + $this->version_status = 'live'; + } } } @@ -1563,10 +1574,12 @@ public function send_email_notification() { * Skip sending an email when.. * - The theme is to be made live immediately. * `wporg_themes_approve_version()` will send a "Congratulations! It's live!" shortly. + * - The theme was auto-approved into the release cooldown. + * `wporg_themes_notify_release_cooldown()` sends a "going live in N hours" email. * - No Trac ticket was created, so there's nothing to reference about where feedback is. */ if ( - 'live' === $this->version_status || + in_array( $this->version_status, [ 'live', 'approved' ], true ) || ! $this->trac_ticket->id ) { return; diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php index 523a680f1c..e2455a37ec 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-manager.php @@ -25,6 +25,9 @@ public function __construct() { // The actual cron hooks. add_action( 'theme_directory_trac_sync', [ __NAMESPACE__ . '\Trac_Sync', 'cron_trigger' ] ); + // Promote themes out of the release cooldown once it has elapsed. + add_action( 'theme_directory_release_to_live', [ __NAMESPACE__ . '\Trac_Sync', 'release_to_live' ] ); + // A cronjob to check cronjobs. add_action( 'theme_directory_check_cronjobs', [ $this, 'register_cron_tasks' ] ); @@ -65,6 +68,10 @@ public function register_cron_tasks() { wp_schedule_event( time() + 60, 'every_15m', 'theme_directory_trac_sync' ); } + if ( ! wp_next_scheduled( 'theme_directory_release_to_live' ) ) { + wp_schedule_event( time() + 60, 'every_15m', 'theme_directory_release_to_live' ); + } + if ( ! wp_next_scheduled( 'theme_directory_check_cronjobs' ) ) { wp_schedule_event( time() + 60, 'every_15m', 'theme_directory_check_cronjobs' ); } diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php index 1f3ac2e492..e1a63aa00c 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php @@ -20,14 +20,20 @@ class Trac_Sync { * @var array */ protected static $stati = [ - 'new' => [ + 'new' => [ 'status' => 'reopened', ], - 'live' => [ + 'approved' => [ + // `approved` is an open Trac status (not a resolution): a reviewer has + // approved the ticket, or themetracbot auto-approved an update, and it's + // waiting out the release cooldown before being marked live. + 'status' => 'approved', + ], + 'live' => [ 'status' => 'closed', 'resolution' => 'live', ], - 'old' => [ + 'old' => [ 'status' => 'closed', 'resolution' => 'not-approved', ], @@ -76,29 +82,51 @@ public static function cron_trigger() { continue; } + $current_status = wporg_themes_get_version_status( $theme_id, $version ); + /* - * Bail if the the theme has the wrong status. - * - * For approved and rejected themes, we bail if the current status is not - * 'new' That can happen when there are additional ticket updates (like - * comments) after the ticket was closed. 'approved' (a version that the - * directory has already accepted and is holding in the release cooldown) - * counts the same as 'live' here — we shouldn't reprocess it on every - * subsequent ticket comment. - * - * For reopened tickets we bail if the version is already marked as 'new'. - * This should only be the case if the ticket was closed and reopened before - * this script was able to sync the closed status. + * Skip if the version is already in the target status. This is the common + * case for additional ticket activity (e.g. comments) after a ticket has + * reached a resolved state. */ - $current_status = wporg_themes_get_version_status( $theme_id, $version ); - if ( ( 'new' !== $new_status && 'new' !== $current_status ) || ( 'new' === $new_status && 'new' === $current_status ) ) { + if ( $current_status === $new_status ) { continue; } - // We don't need to set an already approved live version to live again. - // 'approved' (in cooldown) likewise needs no re-processing. - if ( in_array( $current_status, [ 'live', 'approved' ], true ) && 'live' === $new_status ) { - continue; + /* + * Only act on transitions that make sense in the directory's lifecycle + * (new -> approved -> live, with branches to old or back to new). This + * guards against ticket activity that arrives out of order or after the + * version has already moved on. + */ + switch ( $new_status ) { + case 'new': + // Reopened: always sync back to 'new' from any resolved state. + break; + + case 'approved': + // Newly approved (reviewer or auto-approved update): only valid + // coming from 'new'. + if ( 'new' !== $current_status ) { + continue 2; + } + break; + + case 'live': + // Going live: straight from review (approve_and_live / new_no_review, + // current 'new') or out of the release cooldown (current 'approved', + // via the release-to-live cron or a reviewer force-release on Trac). + if ( ! in_array( $current_status, [ 'new', 'approved' ], true ) ) { + continue 2; + } + break; + + case 'old': + // Rejected during review: only valid coming from 'new'. + if ( 'new' !== $current_status ) { + continue 2; + } + break; } wporg_themes_update_version_status( $theme_id, $version, $new_status ); @@ -106,6 +134,95 @@ public static function cron_trigger() { } } + /** + * Cron handler that promotes auto-approved theme updates out of the release cooldown. + * + * Finds `theme update` tickets that have been sitting in the `approved` status for at + * least WPORG_THEMES_RELEASE_COOL_DOWN_DELAY, marks the associated theme version live + * (firing the usual publish / wp-themes.com / GlotPress / author-email machinery), and + * closes the Trac ticket as resolution=live so it leaves the `approved` state. + * + * Scoped to the `theme update` priority on purpose: first-time theme submissions also + * pass through the `approved` status, but a trusted reviewer marks those live by hand, + * so they should not be promoted on a timer. + */ + public static function release_to_live() { + if ( ! defined( 'THEME_TRACBOT_PASSWORD' ) || ! THEME_TRACBOT_PASSWORD ) { + return; + } + + if ( ! class_exists( 'Trac' ) ) { + require_once ABSPATH . WPINC . '/class-IXR.php'; + require_once ABSPATH . WPINC . '/class-wp-http-ixr-client.php'; + require_once dirname( __DIR__ ) . '/lib/class-trac.php'; + } + + $trac = new \Trac( 'themetracbot', THEME_TRACBOT_PASSWORD, 'https://themes.trac.wordpress.org/login/xmlrpc' ); + $delay = (int) ( defined( 'WPORG_THEMES_RELEASE_COOL_DOWN_DELAY' ) ? WPORG_THEMES_RELEASE_COOL_DOWN_DELAY : 0 ); + $cutoff = time() - $delay; + + /* + * Auto-approved theme updates currently in the `approved` status. We check each + * ticket's changetime in PHP rather than filtering server-side, so a quirk in + * Trac's date-range query syntax can't silently strand themes in the cooldown. + */ + $tickets = (array) $trac->ticket_query( add_query_arg( [ + 'status' => 'approved', + 'priority' => 'theme update', + 'order' => 'changetime', + ] ) ); + + foreach ( $tickets as $ticket_id ) { + $ticket = $trac->ticket_get( $ticket_id ); + if ( ! $ticket ) { + continue; + } + + // The ticket may have been force-released or reopened since the query. + if ( 'approved' !== ( $ticket['status'] ?? '' ) ) { + continue; + } + + // The cooldown must have elapsed: the ticket has to have been sitting in + // `approved` since at least $cutoff. + $changed = $ticket[2] instanceof \IXR_Date ? $ticket[2]->getTimestamp() : strtotime( (string) $ticket[2] ); + if ( ! $changed || $changed > $cutoff ) { + continue; + } + + $theme_id = self::get_theme_id( $ticket_id ); + if ( ! $theme_id ) { + continue; + } + + // If there was a newer-version-uploaded, we have more than one version per ticket. + $versions = array_keys( (array) get_post_meta( $theme_id, '_ticket_id', true ), $ticket_id, true ); + usort( $versions, 'version_compare' ); + $version = end( $versions ); + if ( ! $version ) { + continue; + } + + /* + * Mark the version live if it's still the approved one. A newer version + * uploaded mid-cooldown will have demoted this version to 'old'; we still + * close the ticket below so the cron stops revisiting it. + */ + if ( 'approved' === wporg_themes_get_version_status( $theme_id, $version ) ) { + wporg_themes_update_version_status( $theme_id, $version, 'live' ); + } + + // Advance the ticket out of `approved` (closed, resolution=live). Pass the + // concurrency token we just read to avoid a second ticket.get. + $trac->ticket_update( + $ticket_id, + 'Release cooldown elapsed — marking this theme version live.', + [ 'action' => 'new_no_review', '_ts' => $ticket['_ts'] ], + false + ); + } + } + /** * Returns the ID of a theme associated with the passed ticket number. * diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php index 7a06cef926..22d7a2e56f 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php @@ -46,11 +46,12 @@ /** * Delay between a theme version being approved (by a reviewer on Trac, or via the * auto-approval path for theme updates) and it becoming the live version served to - * sites by the themes API. The previous live version (if any) continues to be served - * until the cooldown elapses. Mitigates supply-chain risks by giving scanners and - * humans a window to flag bad releases. Reviewers can bypass the cooldown via the - * wp-admin force-release control on the Theme Versions metabox; see - * wporg_themes_force_release_version(). + * sites by the themes API. Approved versions are held in Trac's `approved` status and + * promoted to live by the theme_directory_release_to_live cron once this delay elapses; + * the previous live version (if any) continues to be served in the meantime. Mitigates + * supply-chain risks by giving scanners and humans a window to flag bad releases. + * Reviewers can bypass the cooldown with Trac's `approve and mark` / `mark this theme` + * actions, which close the ticket as live immediately. * * Defers to the shared WPORG_PLUGIN_THEME_RELEASE_DELAY constant when it's defined * so the plugin and theme directories can be tuned (or disabled) in lockstep from a @@ -316,23 +317,6 @@ function wporg_themes_get_version_status( $post_id, $version ) { return wporg_themes_get_version_meta( $post_id, '_status', $version ); } -/** - * Sets the specified meta value for a version of a theme. - * - * @param int $post_id Post ID. - * @param string $meta_key Post meta key holding the version => value map. - * @param string $version The theme version to set the meta value for. - * @param mixed $meta_value The value to store for that version. - * @return int|bool Meta ID if the key didn't exist, true on update, false on failure. - */ -function wporg_themes_set_version_meta( $post_id, $meta_key, $version, $meta_value ) { - $meta = (array) get_post_meta( $post_id, $meta_key, true ); - - $meta[ $version ] = $meta_value; - - return update_post_meta( $post_id, $meta_key, $meta ); -} - /** * Replacement for the Author meta box on theme pages */ @@ -435,14 +419,10 @@ function wporg_themes_author_lookup() { * @param int $post_id Post ID. * @param string $current_version The theme version to update. * @param string $new_status The status to update the current version to. - * @param bool $bypass_cooldown Whether to bypass the release cooldown for a 'live' transition. - * Used by rollbacks, the force-release control, and manual admin - * metabox saves where the operator is explicitly pushing a version - * live without waiting for the cooldown window. * @return int|bool Meta ID if the key didn't exist, true on successful update, * false on failure. */ -function wporg_themes_update_version_status( $post_id, $current_version, $new_status, $bypass_cooldown = false ) { +function wporg_themes_update_version_status( $post_id, $current_version, $new_status ) { $meta = get_post_meta( $post_id, '_status', true ) ?: array(); $old_status = false; @@ -455,34 +435,6 @@ function wporg_themes_update_version_status( $post_id, $current_version, $new_st return; } - /* - * Redirect a 'live' transition into the 'approved' holding state when a cooldown is - * in effect. The previous live version continues to be served by the API while the - * cooldown elapses; a scheduled cron then transitions 'approved' -> 'live' (which - * re-enters this function with `$old_status === 'approved'`, falling through). - * - * Callers that need to push a version live immediately pass `$bypass_cooldown = true` - * (rollbacks, the reviewer force-release control, and direct admin metabox saves). - */ - if ( - 'live' === $new_status && - WPORG_THEMES_RELEASE_COOL_DOWN_DELAY && - ! $bypass_cooldown && - 'approved' !== $old_status - ) { - $new_status = 'approved'; - } - - /* - * Conversely: if a caller explicitly sets 'approved' (e.g. admin selecting it from - * the metabox dropdown) while the cooldown is disabled at the constant level, treat - * it as 'live'. Holding a version in 'approved' with no scheduled promotion would - * strand it indefinitely and trigger a "going live in 0 hours" notification. - */ - if ( 'approved' === $new_status && ! WPORG_THEMES_RELEASE_COOL_DOWN_DELAY ) { - $new_status = 'live'; - } - switch ( $new_status ) { // There can only be one version with these statuses: case 'new': @@ -755,88 +707,50 @@ function wporg_themes_rollback_version( $post_id, $current_version, $old_status $ticket_ids = get_post_meta( $post_id, '_ticket_id', true ); $prev_version = array_search( $ticket, $ticket_ids ); - // Bypass the release cooldown: a rollback is an explicit reviewer action - // to restore a previously-live version, not a fresh approval. - wporg_themes_update_version_status( $post_id, $prev_version, 'live', true ); + wporg_themes_update_version_status( $post_id, $prev_version, 'live' ); } add_action( 'wporg_themes_update_version_new', 'wporg_themes_rollback_version', 10, 3 ); /** - * Compute the timestamp when a version's release cooldown will elapse, allowing it - * to transition from 'approved' to 'live'. - * - * Returns 0 when no cooldown is active (no approval timestamp recorded, or no delay - * captured for this version). Reading the delay from per-version meta — set at approval - * time — means future changes to WPORG_THEMES_RELEASE_COOL_DOWN_DELAY don't retroactively - * affect in-flight cooldowns. Reviewers bypass the cooldown by setting the delay to 0. - * - * @param int $post_id Theme post ID. - * @param string $version The theme version to check. - * @return int Unix timestamp when the cooldown elapses, or 0 if no cooldown applies. - */ -function wporg_themes_get_cooldown_until( $post_id, $version ) { - $approval_time = (int) wporg_themes_get_version_meta( $post_id, '_approval_time', $version ); - if ( ! $approval_time ) { - return 0; - } - - $release_delay = (int) wporg_themes_get_version_meta( $post_id, '_release_delay', $version ); - if ( ! $release_delay ) { - return 0; - } - - return $approval_time + $release_delay; -} - -/** - * Handle a version transitioning into the 'approved' holding state. - * - * Records the per-version approval time and the cooldown delay active at approval - * (so future constant changes don't retroactively affect in-flight cooldowns), - * schedules the cron that will promote it to 'live' once the cooldown elapses, - * and emails the theme author so the gap between Trac-approved and serving-to-users - * is explained. + * Notifies the theme author when a version enters the release cooldown (Trac's + * `approved` status), explaining the gap between approval and the version being served + * to users. The "now live" email from wporg_themes_approve_version() still fires once + * the release-to-live cron promotes the version. * * @param int $post_id Theme post ID. * @param string $version The theme version that was just approved. * @param string $old_status The status the version had before approval. */ -function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status ) { - $post = get_post( $post_id ); - if ( ! $post ) { - return; - } - +function wporg_themes_notify_release_cooldown( $post_id, $version, $old_status ) { $release_delay = (int) WPORG_THEMES_RELEASE_COOL_DOWN_DELAY; if ( ! $release_delay ) { - // Defensive: wporg_themes_update_version_status() already converts an explicit - // 'approved' into 'live' when the cooldown is disabled, so this branch shouldn't - // fire in normal flow. Bail rather than schedule a same-second cron and email - // the author about a "0 hours until live" wait. + // Cooldown disabled; the version will be promoted on the next cron run, so + // there's no meaningful wait to explain. return; } - $approval_time = time(); - - wporg_themes_set_version_meta( $post_id, '_approval_time', $version, $approval_time ); - wporg_themes_set_version_meta( $post_id, '_release_delay', $version, $release_delay ); + // Only auto-approved updates are promoted on a timer (see Trac_Sync::release_to_live, + // scoped to the 'theme update' priority). First-time submissions also pass through the + // 'approved' status, but a reviewer marks those live by hand, so a "going live in N + // hours" notice would be misleading. A theme with no prior live version is a first-timer. + if ( ! get_post_meta( $post_id, '_live_version', true ) ) { + return; + } - // Replace any earlier scheduled release so a re-approval (e.g. a new version - // uploaded mid-cooldown) fully resets the window for the latest 'approved' version. - wp_clear_scheduled_hook( 'wporg_themes_release_to_live', array( $post_id ) ); - wp_schedule_single_event( $approval_time + $release_delay, 'wporg_themes_release_to_live', array( $post_id ) ); + $post = get_post( $post_id ); + if ( ! $post ) { + return; + } - // Notify the theme author. The "now live" email from wporg_themes_approve_version() - // still fires when the cooldown elapses; this fills the gap between Trac-approved - // and serving-to-users so the author isn't left wondering why their theme isn't live yet. + $hours = (int) round( $release_delay / HOUR_IN_SECONDS ); $ticket_id = wporg_themes_get_version_meta( $post_id, '_ticket_id', $version ); $subject = sprintf( /* translators: 1: theme name, 2: theme version, 3: hours until live */ - __( '[WordPress Themes] %1$s %2$s approved — going live in %3$d hours', 'wporg-themes' ), + __( '[WordPress Themes] %1$s %2$s approved — going live in about %3$d hours', 'wporg-themes' ), $post->post_title, $version, - $release_delay / HOUR_IN_SECONDS + $hours ); $content = sprintf( @@ -844,12 +758,12 @@ function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status __( 'Version %1$s of %2$s has been approved and will be served to WordPress users in about %3$d hours.', 'wporg-themes' ), $version, $post->post_title, - $release_delay / HOUR_IN_SECONDS + $hours ) . "\n\n"; $content .= sprintf( /* translators: %d: cooldown duration in hours */ __( 'WordPress.org delays new theme releases by %d hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, please contact themes@wordpress.org.', 'wporg-themes' ), - $release_delay / HOUR_IN_SECONDS + $hours ) . "\n\n"; if ( $ticket_id ) { @@ -862,99 +776,7 @@ function wporg_themes_handle_approval_cooldown( $post_id, $version, $old_status wp_mail( get_user_by( 'id', $post->post_author )->user_email, $subject, $content, 'From: "WordPress Theme Directory" ' ); } -add_action( 'wporg_themes_update_version_approved', 'wporg_themes_handle_approval_cooldown', 10, 3 ); - -/** - * Clear the scheduled release-to-live cron whenever a version transitions out of - * 'approved': promoted to 'live' by the cron firing (no-op — the event has already - * dequeued itself), force-released to 'live' by a reviewer (leftover schedule needs - * removing), reopened back to 'new', or marked 'old' because a newer version was - * uploaded mid-cooldown. - * - * @param int $post_id Theme post ID. - * @param string $version The theme version that was just updated. - * @param string $new_status The new status. - * @param string $old_status The previous status. - */ -function wporg_themes_clear_cooldown_cron_on_transition( $post_id, $version, $new_status, $old_status ) { - if ( 'approved' !== $old_status ) { - return; - } - - wp_clear_scheduled_hook( 'wporg_themes_release_to_live', array( $post_id ) ); -} -add_action( 'wporg_themes_update_version_status', 'wporg_themes_clear_cooldown_cron_on_transition', 10, 4 ); - -/** - * Cron handler for `wporg_themes_release_to_live`. Fires when a version's release - * cooldown elapses; promotes the currently-'approved' version to 'live' so the existing - * wporg_themes_approve_version() handler can publish the post, update wp-themes.com, - * push translations to GlotPress, and email the author. - * - * @param int $post_id Theme post ID whose 'approved' version should be promoted. - */ -function wporg_themes_cron_release_to_live( $post_id ) { - $status = (array) get_post_meta( $post_id, '_status', true ); - $version = array_search( 'approved', $status ); - if ( ! $version ) { - // Nothing in 'approved' — the cooldown was already resolved (force-released, - // rolled back, or a newer version superseded this one). - return; - } - - wporg_themes_update_version_status( $post_id, $version, 'live' ); -} -add_action( 'wporg_themes_release_to_live', 'wporg_themes_cron_release_to_live' ); - -/** - * Reviewer force-release: clear the cooldown for a theme's currently-approved version and - * transition it to 'live' immediately. Logs the action via the internal-notes audit trail. - * - * Capability checks must be performed by the caller. - * - * @param int $post_id Theme post ID. - * @param string $reason Free-text reason recorded in the audit log. - * @return bool True on success. - */ -function wporg_themes_force_release_version( $post_id, $reason ) { - $post = get_post( $post_id ); - if ( ! $post || 'repopackage' !== $post->post_type ) { - return false; - } - - $status = (array) get_post_meta( $post_id, '_status', true ); - $version = array_search( 'approved', $status ); - if ( ! $version ) { - return false; - } - - $release_delay = (int) wporg_themes_get_version_meta( $post_id, '_release_delay', $version ); - - // Zero the per-version delay so any future re-entry through the cooldown gate - // (e.g. if `update_version_status` is called again before the cron fires) writes through. - wporg_themes_set_version_meta( $post_id, '_release_delay', $version, 0 ); - - // Log to the internal notes via a private comment, matching the audit pattern used - // elsewhere in the theme directory for moderator actions. - wp_insert_comment( array( - 'comment_post_ID' => $post_id, - 'user_id' => get_current_user_id(), - 'comment_author' => wp_get_current_user()->user_login, - 'comment_type' => 'internal-note', - 'comment_approved' => 0, - 'comment_content' => sprintf( - /* translators: 1: version, 2: hours of cooldown bypassed, 3: reason */ - __( 'Force-released version %1$s, bypassing the %2$d-hour release cooldown. Reason: %3$s', 'wporg-themes' ), - $version, - $release_delay / HOUR_IN_SECONDS, - $reason - ), - ) ); - - wporg_themes_update_version_status( $post_id, $version, 'live', true ); - - return true; -} +add_action( 'wporg_themes_update_version_approved', 'wporg_themes_notify_release_cooldown', 10, 3 ); /** * Updates wp-themes.com with the latest version of a theme. From 428d3dccbca785a6af1c22ceb13ccfb38f297acf Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 28 May 2026 06:59:19 +0000 Subject: [PATCH 11/15] Theme Directory: Cooldown: Reuse the approved ticket on re-upload; supersede-close stale tickets; drop the approval email. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-uploading while the previous version is still on an open ticket (`new` or `approved`) now updates that same ticket instead of opening a new one, so an approved update's release delay restarts from the latest upload and the superseded version is simply demoted to `old`. - The release-to-live cron now closes a superseded `approved` ticket (whose version was demoted to `old`) as closed-newer-version-uploaded via a new themetracbot `new_no_review_superseded` action, rather than mislabelling it live — keeping rollback's resolution=live lookup correct. - Drop the "approved, going live in N hours" author email. - Remove "cooldown" from user-facing strings (metabox label; the cron's Trac comment is now "Marking live."). Co-Authored-By: Claude Opus 4.7 (1M context) --- trac.wordpress.org/conf/workflow-themes.ini | 10 +++ .../plugins/theme-directory/admin-edit.php | 4 +- .../class-wporg-themes-upload.php | 30 ++++++--- .../theme-directory/jobs/class-trac-sync.php | 32 +++++++-- .../theme-directory/theme-directory.php | 67 ------------------- 5 files changed, 58 insertions(+), 85 deletions(-) diff --git a/trac.wordpress.org/conf/workflow-themes.ini b/trac.wordpress.org/conf/workflow-themes.ini index c8a298bc88..e444bdd532 100644 --- a/trac.wordpress.org/conf/workflow-themes.ini +++ b/trac.wordpress.org/conf/workflow-themes.ini @@ -93,3 +93,13 @@ new_no_review.operations = set_resolution, set_owner_to_self new_no_review.set_resolution = live new_no_review.permissions = TICKET_CREATE new_no_review.default = -41 + +# Automated close by themetracbot when an `approved` ticket was superseded by a newer +# version uploaded during the release cooldown. Driven by the release-to-live cron so the +# stale ticket leaves `approved` without being mislabelled live. +new_no_review_superseded = approved -> closed +new_no_review_superseded.name = supersede +new_no_review_superseded.operations = set_resolution +new_no_review_superseded.set_resolution = closed-newer-version-uploaded +new_no_review_superseded.permissions = TICKET_CREATE +new_no_review_superseded.default = -42 diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php index 7c8604cfa8..b0348ef3b8 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php @@ -582,8 +582,8 @@ function wporg_themes_meta_box_callback( $post ) {