diff --git a/trac.wordpress.org/conf/workflow-themes.ini b/trac.wordpress.org/conf/workflow-themes.ini index 1615383fca..c8a298bc88 100644 --- a/trac.wordpress.org/conf/workflow-themes.ini +++ b/trac.wordpress.org/conf/workflow-themes.ini @@ -76,8 +76,18 @@ approve_and_live.set_resolution = live approve_and_live.permissions = TRUSTED_REVIEWER approve_and_live.default = -40 -# this is for automated ticket closing by themetracbot -new_no_review = new -> closed +# Automated approval of a theme update by themetracbot. Lands the ticket in the +# `approved` status (rather than closing it live) so the release-cooldown cron can +# promote it to live once the cooldown has elapsed. +new_no_review_delay = new -> approved +new_no_review_delay.name = approve and delay +new_no_review_delay.operations = set_owner_to_self +new_no_review_delay.permissions = TICKET_CREATE +new_no_review_delay.default = -41 + +# Automated marking-live by themetracbot, either straight from review or once the +# release cooldown on an `approved` ticket has elapsed (driven by the release-to-live cron). +new_no_review = new,approved -> closed new_no_review.name = approve and mark new_no_review.operations = set_resolution, set_owner_to_self new_no_review.set_resolution = live 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..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 @@ -581,6 +581,10 @@ function wporg_themes_meta_box_callback( $post ) {
- 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..fab2cfcbc0 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. @@ -1296,11 +1298,16 @@ public function create_or_update_trac_ticket() { */ $trac_ticket_reporter = wp_get_current_user()->user_login ?? $this->author->user_login; - // If there's a previous version and the most current version's status is `new`, we update. - if ( - ! empty( $this->theme_post->max_version ) && - 'new' == $this->theme_post->_status[ $this->theme_post->max_version ] - ) { + $prev_status = $this->theme_post->_status[ $this->theme_post->max_version ?? '' ] ?? ''; + + /* + * If the previous version is still on an open ticket — either awaiting review + * (`new`) or approved and waiting out the release delay (`approved`) — update that + * same ticket rather than opening a new one. Re-uploading resets the ticket's + * changetime, so an approved update's release delay restarts from this upload and + * the superseded version is simply demoted to `old`. + */ + if ( in_array( $prev_status, [ 'new', 'approved' ], true ) ) { $ticket_id = (int) $this->theme_post->_ticket_id[ $this->theme_post->max_version ]; $ticket = $this->trac->ticket_get( $ticket_id ); @@ -1308,6 +1315,12 @@ public function create_or_update_trac_ticket() { if ( $ticket && empty( $ticket[3]['resolution'] ) ) { $result = $this->trac->ticket_update( $ticket_id, $this->trac_ticket->description, array( 'summary' => $this->trac_ticket->summary, 'keywords' => implode( ' ', $this->trac_ticket->keywords ) ), true /* Trigger email notifications */ ); $ticket_id = $result ? $ticket_id : false; + + // Keep an approved update in the `approved` window; the release-to-live + // cron promotes it once the delay (measured from this upload) elapses. + if ( 'approved' === $prev_status ) { + $this->version_status = 'approved'; + } } else { $ticket_id = $this->trac->ticket_create( $this->trac_ticket->summary, $this->trac_ticket->description, array( 'type' => 'theme', @@ -1330,13 +1343,25 @@ 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 ); + $release_delay = wporg_themes_get_release_cooldown_delay( $this->theme_slug ); + if ( $release_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. + $delay_hours = (int) round( $release_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.', $delay_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'; + } } } @@ -1561,10 +1586,13 @@ 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. It's not awaiting + * review, so the "new version uploaded" feedback email doesn't apply; the + * "now live" email follows once the cooldown elapses. * - 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-trac-sync.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php index a5fe2de825..ddb185d152 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', ], @@ -51,6 +57,10 @@ public static function cron_trigger() { $last_request = get_option( 'wporg-themes-last-trac-sync', strtotime( '-2 days' ) ); update_option( 'wporg-themes-last-trac-sync', time() ); + // Migrate approved theme updates whose release delay has elapsed to live on Trac, + // so the sync below imports them as it would any other newly-live ticket. + self::release_to_live( $trac ); + foreach ( self::$stati as $new_status => $args ) { // Get array of tickets. $tickets = (array) $trac->ticket_query( add_query_arg( wp_parse_args( $args, [ @@ -76,25 +86,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. - * - * 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. - if ( 'live' === $current_status && '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 ); @@ -102,6 +138,65 @@ public static function cron_trigger() { } } + /** + * Migrates auto-approved theme updates out of the release delay, on Trac. + * + * Finds `theme update` tickets that have been in the `approved` status for at least + * the theme's release cooldown delay (wporg_themes_get_release_cooldown_delay()) and + * closes them as resolution=live. That's the only change made here — cron_trigger()'s + * normal sync, which runs straight after, imports the now-live ticket into WordPress + * like any other. + * + * 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. + * + * @param \Trac $trac An authenticated Trac client. + */ + public static function release_to_live( $trac ) { + /* + * 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 delay. + */ + $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 ); + + // Skip if the ticket was force-released or reopened since the query. + if ( ! $ticket || 'approved' !== ( $ticket['status'] ?? '' ) ) { + continue; + } + + // Resolve the theme slug so the release delay can be filtered per-theme. + $theme_slug = get_post_field( 'post_name', self::get_theme_id( $ticket_id ) ); + $cutoff = time() - wporg_themes_get_release_cooldown_delay( $theme_slug ); + + // Only once the release delay, measured from the ticket's changetime, has elapsed. + $changed = $ticket[2] instanceof \IXR_Date ? $ticket[2]->getTimestamp() : strtotime( (string) $ticket[2] ); + if ( ! $changed || $changed > $cutoff ) { + continue; + } + + // Close as live. Pass the concurrency token we just read to avoid a second + // ticket.get; cron_trigger()'s sync then imports it as a newly-live version. + $trac->ticket_update( + $ticket_id, + 'Marking 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 79047e5aee..c09dfbc306 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,52 @@ 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. Approved versions are held in Trac's `approved` status and + * migrated to live by the theme_directory_trac_sync cron once this delay elapses (see + * Trac_Sync::release_to_live()); 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 delay 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 + * single override point. + * + * Defaults to 0 (cooldown disabled, versions go live immediately) for now; this will be + * raised once the surrounding workflow is ready. Can be pre-defined in global config to + * override the default. + */ +if ( ! defined( 'WPORG_THEMES_RELEASE_COOL_DOWN_DELAY' ) ) { + define( 'WPORG_THEMES_RELEASE_COOL_DOWN_DELAY', defined( 'WPORG_PLUGIN_THEME_RELEASE_DELAY' ) ? WPORG_PLUGIN_THEME_RELEASE_DELAY : 0 ); +} + +/** + * Returns the release cooldown delay, in seconds, for a theme. + * + * The WPORG_THEMES_RELEASE_COOL_DOWN_DELAY constant provides the default, which is then + * passed through the `wporg_themes_release_cooldown_delay` filter so the delay can be + * shortened, extended, or removed (return 0 to disable the cooldown) on a per-theme basis. + * The theme slug is passed to the filter when it is known. + * + * @param string $theme_slug The slug of the theme being acted upon, if known. + * @return int Delay in seconds. 0 disables the cooldown (the version goes live immediately). + */ +function wporg_themes_get_release_cooldown_delay( $theme_slug = '' ) { + /** + * Filters the release cooldown delay for a theme. + * + * Return 0 to disable the cooldown (the approved version goes live immediately), or a + * larger/smaller number of seconds to lengthen or shorten the delay for this theme. + * + * @param int $delay The default delay in seconds (WPORG_THEMES_RELEASE_COOL_DOWN_DELAY). + * @param string $theme_slug The slug of the theme being acted upon, or '' when not known. + */ + return (int) apply_filters( 'wporg_themes_release_cooldown_delay', WPORG_THEMES_RELEASE_COOL_DOWN_DELAY, $theme_slug ); +} + /** * Things to change on activation. */ @@ -400,9 +446,9 @@ 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. * @return int|bool Meta ID if the key didn't exist, true on successful update, * false on failure. */ @@ -423,6 +469,7 @@ function wporg_themes_update_version_status( $post_id, $current_version, $new_st // 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, '<' ) ) {