Making WordPress.org

Changeset 14930


Ignore:
Timestamp:
06/03/2026 05:26:25 AM (4 months ago)
Author:
dd32
Message:

Themes: Add a way to add a delay between theme submission / approval and making it live to users.

This allows for a way to reduce impacts from compromised accounts, supply chain attacks, improper theme release.
This can also be used to allow time for automated scanners to verify that the theme release is safe for distribution.

Closes https://github.com/WordPress/wordpress.org/pull/651.

Location:
sites/trunk
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/trac.wordpress.org/conf/workflow-themes.ini

    r10636 r14930  
    7777approve_and_live.default = -40
    7878
    79 # this is for automated ticket closing by themetracbot
    80 new_no_review = new -> closed
     79# Automated approval of a theme update by themetracbot. Lands the ticket in the
     80# `approved` status (rather than closing it live) so the release-cooldown cron can
     81# promote it to live once the cooldown has elapsed.
     82new_no_review_delay = new -> approved
     83new_no_review_delay.name = approve and delay
     84new_no_review_delay.operations = set_owner_to_self
     85new_no_review_delay.permissions = TICKET_CREATE
     86new_no_review_delay.default = -41
     87
     88# Automated marking-live by themetracbot, either straight from review or once the
     89# release cooldown on an `approved` ticket has elapsed (driven by the release-to-live cron).
     90new_no_review = new,approved -> closed
    8191new_no_review.name = approve and mark
    8292new_no_review.operations = set_resolution, set_owner_to_self
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/theme-directory/admin-edit.php

    r14464 r14930  
    582582                        <select name="wporg_themes_status[<?php echo base64_encode( $version ); // base64 because version numbers don't work so well as parts of keys ?>]">
    583583                                <option value="new" <?php selected( $status, 'new' ); ?>><?php esc_html_e( 'New', 'wporg-themes' ); ?></option>
     584                                <?php if ( 'approved' === $status ) : ?>
     585                                        <?php // `approved` is a transient Trac-driven pre-release state; only shown so the current value displays correctly. ?>
     586                                        <option value="approved" selected><?php esc_html_e( 'Approved (pending release)', 'wporg-themes' ); ?></option>
     587                                <?php endif; ?>
    584588                                <option value="live" <?php selected( $status, 'live' ); ?>><?php esc_html_e( 'Live', 'wporg-themes' ); ?></option>
    585589                                <option value="old" <?php selected( $status, 'old' ); ?>><?php esc_html_e( 'Old', 'wporg-themes' ); ?></option>
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php

    r14771 r14930  
    11591159                if ( ! empty( $this->theme_post->_status ) ) {
    11601160
    1161                         // Is this an update to an existing, approved theme?
    1162                         if ( 'live' === $this->theme_post->_status[ $this->theme_post->max_version ] ) {
     1161                        // Is this an update to an existing, approved theme? An 'approved' status
     1162                        // (live version still in release cooldown) counts as approved for ticket
     1163                        // priority — the previous live version is still being served.
     1164                        if ( in_array( $this->theme_post->_status[ $this->theme_post->max_version ], [ 'live', 'approved' ], true ) ) {
    11631165                                $this->trac_ticket->priority = 'theme update';
    11641166
     
    12971299                $trac_ticket_reporter = wp_get_current_user()->user_login ?? $this->author->user_login;
    12981300
    1299                 // If there's a previous version and the most current version's status is `new`, we update.
    1300                 if (
    1301                         ! empty( $this->theme_post->max_version ) &&
    1302                         'new' == $this->theme_post->_status[ $this->theme_post->max_version ]
    1303                 ) {
     1301                $prev_status = $this->theme_post->_status[ $this->theme_post->max_version ?? '' ] ?? '';
     1302
     1303                /*
     1304                 * If the previous version is still on an open ticket — either awaiting review
     1305                 * (`new`) or approved and waiting out the release delay (`approved`) — update that
     1306                 * same ticket rather than opening a new one. Re-uploading resets the ticket's
     1307                 * changetime, so an approved update's release delay restarts from this upload and
     1308                 * the superseded version is simply demoted to `old`.
     1309                 */
     1310                if ( in_array( $prev_status, [ 'new', 'approved' ], true ) ) {
    13041311                        $ticket_id = (int) $this->theme_post->_ticket_id[ $this->theme_post->max_version ];
    13051312                        $ticket    = $this->trac->ticket_get( $ticket_id );
     
    13091316                                $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 */ );
    13101317                                $ticket_id = $result ? $ticket_id : false;
     1318
     1319                                // Keep an approved update in the `approved` window; the release-to-live
     1320                                // cron promotes it once the delay (measured from this upload) elapses.
     1321                                if ( 'approved' === $prev_status ) {
     1322                                        $this->version_status = 'approved';
     1323                                }
    13111324                        } else {
    13121325                                $ticket_id = $this->trac->ticket_create( $this->trac_ticket->summary, $this->trac_ticket->description, array(
     
    13311344                        ) );
    13321345
    1333                         // Themes team auto-approves theme-updates, so mark the theme as live immediately.
    1334                         // Note that this only applies to new ticket creation, so it won't happen on themes with existing outstanding tickets.
     1346                        // Themes team auto-approves theme-updates. Note that this only applies to new
     1347                        // ticket creation, so it won't happen on themes with existing outstanding tickets.
    13351348                        if ( $this->trac_ticket->priority == 'theme update' ) {
    1336                                 $this->trac->ticket_update( $ticket_id, 'Theme Update for existing Live theme - automatically approved', array( 'action' => 'new_no_review' ), false );
    1337 
    1338                                 $this->trac_ticket->resolution = 'live';
    1339                                 $this->version_status          = 'live';
     1349                                $release_delay = wporg_themes_get_release_cooldown_delay( $this->theme_slug );
     1350                                if ( $release_delay ) {
     1351                                        // Land the update in the `approved` status; the release-to-live cron
     1352                                        // promotes it to live once the cooldown elapses. The previous live
     1353                                        // version continues to be served in the meantime.
     1354                                        $delay_hours = (int) round( $release_delay / HOUR_IN_SECONDS );
     1355                                        $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 );
     1356
     1357                                        $this->version_status = 'approved';
     1358                                } else {
     1359                                        // Cooldown disabled: mark the theme live immediately.
     1360                                        $this->trac->ticket_update( $ticket_id, 'Theme Update for existing Live theme - automatically approved', array( 'action' => 'new_no_review' ), false );
     1361
     1362                                        $this->trac_ticket->resolution = 'live';
     1363                                        $this->version_status          = 'live';
     1364                                }
    13401365                        }
    13411366
     
    15621587                 *  - The theme is to be made live immediately.
    15631588                 *    `wporg_themes_approve_version()` will send a "Congratulations! It's live!" shortly.
     1589                 *  - The theme was auto-approved into the release cooldown. It's not awaiting
     1590                 *    review, so the "new version uploaded" feedback email doesn't apply; the
     1591                 *    "now live" email follows once the cooldown elapses.
    15641592                 *  - No Trac ticket was created, so there's nothing to reference about where feedback is.
    15651593                 */
    15661594                if (
    1567                         'live' === $this->version_status ||
     1595                        in_array( $this->version_status, [ 'live', 'approved' ], true ) ||
    15681596                        ! $this->trac_ticket->id
    15691597                ) {
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/theme-directory/jobs/class-trac-sync.php

    r11175 r14930  
    2121         */
    2222        protected static $stati = [
    23                 'new'  => [
     23                'new'      => [
    2424                        'status' => 'reopened',
    2525                ],
    26                 'live' => [
     26                'approved' => [
     27                        // `approved` is an open Trac status (not a resolution): a reviewer has
     28                        // approved the ticket, or themetracbot auto-approved an update, and it's
     29                        // waiting out the release cooldown before being marked live.
     30                        'status' => 'approved',
     31                ],
     32                'live'     => [
    2733                        'status'     => 'closed',
    2834                        'resolution' => 'live',
    2935                ],
    30                 'old'  => [
     36                'old'      => [
    3137                        'status'     => 'closed',
    3238                        'resolution' => 'not-approved',
     
    5157                $last_request = get_option( 'wporg-themes-last-trac-sync', strtotime( '-2 days' ) );
    5258                update_option( 'wporg-themes-last-trac-sync', time() );
     59
     60                // Migrate approved theme updates whose release delay has elapsed to live on Trac,
     61                // so the sync below imports them as it would any other newly-live ticket.
     62                self::release_to_live( $trac );
    5363
    5464                foreach ( self::$stati as $new_status => $args ) {
     
    7787                                }
    7888
     89                                $current_status = wporg_themes_get_version_status( $theme_id, $version );
     90
    7991                                /*
    80                                  * Bail if the the theme has the wrong status.
    81                                  *
    82                                  * For approved and rejected themes, we bail if the current status is not
    83                                  * 'new' That can happen when there are additional ticket updates (like
    84                                  * comments) after the ticket was closed.
    85                                  *
    86                                  * For reopened tickets we bail if the version is already marked as 'new'.
    87                                  * This should only be the case if the ticket was closed and reopened before
    88                                  * this script was able to sync the closed status.
     92                                 * Skip if the version is already in the target status. This is the common
     93                                 * case for additional ticket activity (e.g. comments) after a ticket has
     94                                 * reached a resolved state.
    8995                                 */
    90                                 $current_status = wporg_themes_get_version_status( $theme_id, $version );
    91                                 if ( ( 'new' !== $new_status && 'new' !== $current_status ) || ( 'new' === $new_status && 'new' === $current_status ) ) {
     96                                if ( $current_status === $new_status ) {
    9297                                        continue;
    9398                                }
    9499
    95                                 // We don't need to set an already approved live version to live again.
    96                                 if ( 'live' === $current_status && 'live' === $new_status ) {
    97                                         continue;
     100                                /*
     101                                 * Only act on transitions that make sense in the directory's lifecycle
     102                                 * (new -> approved -> live, with branches to old or back to new). This
     103                                 * guards against ticket activity that arrives out of order or after the
     104                                 * version has already moved on.
     105                                 */
     106                                switch ( $new_status ) {
     107                                        case 'new':
     108                                                // Reopened: always sync back to 'new' from any resolved state.
     109                                                break;
     110
     111                                        case 'approved':
     112                                                // Newly approved (reviewer or auto-approved update): only valid
     113                                                // coming from 'new'.
     114                                                if ( 'new' !== $current_status ) {
     115                                                        continue 2;
     116                                                }
     117                                                break;
     118
     119                                        case 'live':
     120                                                // Going live: straight from review (approve_and_live / new_no_review,
     121                                                // current 'new') or out of the release cooldown (current 'approved',
     122                                                // via the release-to-live cron or a reviewer force-release on Trac).
     123                                                if ( ! in_array( $current_status, [ 'new', 'approved' ], true ) ) {
     124                                                        continue 2;
     125                                                }
     126                                                break;
     127
     128                                        case 'old':
     129                                                // Rejected during review: only valid coming from 'new'.
     130                                                if ( 'new' !== $current_status ) {
     131                                                        continue 2;
     132                                                }
     133                                                break;
    98134                                }
    99135
    100136                                wporg_themes_update_version_status( $theme_id, $version, $new_status );
    101137                        }
     138                }
     139        }
     140
     141        /**
     142         * Migrates auto-approved theme updates out of the release delay, on Trac.
     143         *
     144         * Finds `theme update` tickets that have been in the `approved` status for at least
     145         * the theme's release cooldown delay (wporg_themes_get_release_cooldown_delay()) and
     146         * closes them as resolution=live. That's the only change made here — cron_trigger()'s
     147         * normal sync, which runs straight after, imports the now-live ticket into WordPress
     148         * like any other.
     149         *
     150         * Scoped to the `theme update` priority on purpose: first-time theme submissions also
     151         * pass through the `approved` status, but a trusted reviewer marks those live by hand,
     152         * so they should not be promoted on a timer.
     153         *
     154         * @param \Trac $trac An authenticated Trac client.
     155         */
     156        public static function release_to_live( $trac ) {
     157                /*
     158                 * Auto-approved theme updates currently in the `approved` status. We check each
     159                 * ticket's changetime in PHP rather than filtering server-side, so a quirk in
     160                 * Trac's date-range query syntax can't silently strand themes in the delay.
     161                 */
     162                $tickets = (array) $trac->ticket_query( add_query_arg( [
     163                        'status'   => 'approved',
     164                        'priority' => 'theme update',
     165                        'order'    => 'changetime',
     166                ] ) );
     167
     168                foreach ( $tickets as $ticket_id ) {
     169                        $ticket = $trac->ticket_get( $ticket_id );
     170
     171                        // Skip if the ticket was force-released or reopened since the query.
     172                        if ( ! $ticket || 'approved' !== ( $ticket['status'] ?? '' ) ) {
     173                                continue;
     174                        }
     175
     176                        // Resolve the theme slug so the release delay can be filtered per-theme.
     177                        $theme_slug = get_post_field( 'post_name', self::get_theme_id( $ticket_id ) );
     178                        $cutoff     = time() - wporg_themes_get_release_cooldown_delay( $theme_slug );
     179
     180                        // Only once the release delay, measured from the ticket's changetime, has elapsed.
     181                        $changed = $ticket[2] instanceof \IXR_Date ? $ticket[2]->getTimestamp() : strtotime( (string) $ticket[2] );
     182                        if ( ! $changed || $changed > $cutoff ) {
     183                                continue;
     184                        }
     185
     186                        // Close as live. Pass the concurrency token we just read to avoid a second
     187                        // ticket.get; cron_trigger()'s sync then imports it as a newly-live version.
     188                        $trac->ticket_update(
     189                                $ticket_id,
     190                                'Marking live.',
     191                                [
     192                                        'action' => 'new_no_review',
     193                                        '_ts'    => $ticket['_ts'],
     194                                ],
     195                                false
     196                        );
    102197                }
    103198        }
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/theme-directory/theme-directory.php

    r14590 r14930  
    4343
    4444define( 'WPORG_THEMES_E2E_REPO', 'WordPress/theme-review-e2e' );
     45
     46/**
     47 * Delay between a theme version being approved (by a reviewer on Trac, or via the
     48 * auto-approval path for theme updates) and it becoming the live version served to
     49 * sites by the themes API. Approved versions are held in Trac's `approved` status and
     50 * migrated to live by the theme_directory_trac_sync cron once this delay elapses (see
     51 * Trac_Sync::release_to_live()); the previous live version (if any) continues to be
     52 * served in the meantime. Mitigates supply-chain risks by giving scanners and humans a
     53 * window to flag bad releases. Reviewers can bypass the delay with Trac's `approve and
     54 * mark` / `mark this theme` actions, which close the ticket as live immediately.
     55 *
     56 * Defers to the shared WPORG_PLUGIN_THEME_RELEASE_DELAY constant when it's defined
     57 * so the plugin and theme directories can be tuned (or disabled) in lockstep from a
     58 * single override point.
     59 *
     60 * Defaults to 0 (cooldown disabled, versions go live immediately) for now; this will be
     61 * raised once the surrounding workflow is ready. Can be pre-defined in global config to
     62 * override the default.
     63 */
     64if ( ! defined( 'WPORG_THEMES_RELEASE_COOL_DOWN_DELAY' ) ) {
     65        define( 'WPORG_THEMES_RELEASE_COOL_DOWN_DELAY', defined( 'WPORG_PLUGIN_THEME_RELEASE_DELAY' ) ? WPORG_PLUGIN_THEME_RELEASE_DELAY : 0 );
     66}
     67
     68/**
     69 * Returns the release cooldown delay, in seconds, for a theme.
     70 *
     71 * The WPORG_THEMES_RELEASE_COOL_DOWN_DELAY constant provides the default, which is then
     72 * passed through the `wporg_themes_release_cooldown_delay` filter so the delay can be
     73 * shortened, extended, or removed (return 0 to disable the cooldown) on a per-theme basis.
     74 * The theme slug is passed to the filter when it is known.
     75 *
     76 * @param string $theme_slug The slug of the theme being acted upon, if known.
     77 * @return int Delay in seconds. 0 disables the cooldown (the version goes live immediately).
     78 */
     79function wporg_themes_get_release_cooldown_delay( $theme_slug = '' ) {
     80        /**
     81         * Filters the release cooldown delay for a theme.
     82         *
     83         * Return 0 to disable the cooldown (the approved version goes live immediately), or a
     84         * larger/smaller number of seconds to lengthen or shorten the delay for this theme.
     85         *
     86         * @param int    $delay      The default delay in seconds (WPORG_THEMES_RELEASE_COOL_DOWN_DELAY).
     87         * @param string $theme_slug The slug of the theme being acted upon, or '' when not known.
     88         */
     89        return (int) apply_filters( 'wporg_themes_release_cooldown_delay', WPORG_THEMES_RELEASE_COOL_DOWN_DELAY, $theme_slug );
     90}
    4591
    4692/**
     
    401447 * Handles updating the status of theme versions.
    402448 *
    403  * @param int       $post_id         Post ID.
    404  * @param string    $current_version The theme version to update.
    405  * @param string    $new_status      The status to update the current version to.
     449 * @param int    $post_id         Post ID.
     450 * @param string $current_version The theme version to update.
     451 * @param string $new_status      The status to update the current version to.
    406452 * @return int|bool Meta ID if the key didn't exist, true on successful update,
    407453 *                  false on failure.
     
    424470                case 'new':
    425471                case 'live':
     472                case 'approved':
    426473                        // Discard all previous versions with that status.
    427474                        foreach ( array_keys( $meta, $new_status ) as $version ) {
Note: See TracChangeset for help on using the changeset viewer.