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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,39 @@ dry-run, no diff and no revert.
which lets WordPress rewrite its own `.htaccess` block exactly as Settings →
Permalinks does. A soft flush would leave Apache without rules after a move
off plain permalinks, so it stays a hard flush.
- **Updates are applied only through WordPress's own updater, and only for
what WordPress already offers.** `saddle/update-plugin` and
`saddle/update-theme` (admin tier, gated, at most 10 items) queue a
background run; `Saddle_Update_Runner` hands each item to
`WP_Automatic_Updater::update()`, which downloads the package core listed,
keeps the temporary backup, and for an active plugin runs the loopback fatal
check and restores the backup on failure. Saddle never writes a file, never
chooses a package URL, never calls the upgrader inside a request (a test
pins this), never installs or deletes a plugin or theme, and never updates
core. It refuses, naming the reason, wherever core would (`DISALLOW_FILE_MODS`,
- **Updates and installs run only through WordPress's own updater and
installer, and only for what WordPress already offers.** Six tools, all
admin tier and gated, queue a background run and never call the upgrader
inside a request (a test pins this): `saddle/update-plugin` and
`saddle/update-theme` (at most 10 items), `saddle/update-core`,
`saddle/update-translations`, `saddle/install-plugin` and
`saddle/install-theme` (one slug each). `Saddle_Update_Runner` hands each
update to `WP_Automatic_Updater::update()`, which downloads the package core
listed, keeps the temporary backup, and for an active plugin runs the
loopback fatal check and restores the backup on failure;
`Saddle_Install_Runner` hands an install to `Plugin_Upgrader::install()` or
`Theme_Upgrader::install()`.
Saddle installs a plugin or theme only from the WordPress.org directory, by
slug, and only through WordPress's own installer, out of band, after a
confirmed preview. It never takes a zip, a URL or a file. A new install
arrives inactive. Saddle applies core updates only for releases WordPress
already offers, through core's automatic updater: minor and security
releases, and a major release only when WordPress would apply it in the
background itself: its own "automatic updates for all new versions" switch
in Dashboard → Updates, which no tool can flip (on by default for sites
installed since WordPress 5.6). Translation updates go through
core's language pack upgrader. Saddle never deletes a plugin or theme. It
still never writes a file and never picks a package URL: the package is the
one the WordPress.org API names, exactly as on the Add New screen, and a
package from anywhere else is refused.
It refuses, naming the reason, wherever core would (`DISALLOW_FILE_MODS`,
`AUTOMATIC_UPDATER_DISABLED`, a VCS checkout, a non-direct filesystem). The
auto-update switch is WordPress's own option. Decided 2026-09-27 evening on
Fahim's "research, then take the decision", replacing the same morning's
"reported, never run"; applies to Pro too.
auto-update switch is WordPress's own option. Updates decided 2026-09-27
evening on Fahim's "research, then take the decision", replacing the same
morning's "reported, never run"; installs, core and translations added
2026-10-12 (#347, Fahim: majors only behind WordPress's own switch; #351
found that the switch defaults to on for new installs). Applies
to Pro too.

If a feature seems to need any of these, that is a signal to redesign the
feature. Two reasons, both load-bearing: WordPress.org will not accept arbitrary
Expand Down Expand Up @@ -460,6 +479,12 @@ change. `Tested up to:` moves only when Fahim has verified against a newer WP.
all three competitors that were checked.
- Don't add PHP execution, filesystem writes or shell WP-CLI as a power-user
convenience. See the hard line.
- Don't install a plugin or theme from a zip upload, a URL or a file path, and
don't add a tool that takes one. Installs come only from the WordPress.org
directory, by slug, with the package its API names. Don't activate anything
as part of an install either; activation stays its own confirmed step.
- Don't add `delete-plugin` or `delete-theme`. Removing a plugin or theme
stays in wp-admin.
- Don't change the default tier away from `read`, even temporarily for testing.
- Don't remove the Application Password path now that OAuth exists. Every
header-capable client uses it, it needs no consent round-trip, and it is the
Expand Down
15 changes: 13 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,24 @@ when it is picked up; this file names direction, not tickets.
**Added 2026-09-27 (evening), in this order, all free:** updates and health
(`list-updates`, `update-plugin`, `update-theme`, `set-auto-update`,
`get-site-health`; applied through core's own updater with its temporary
backup and fatal-check rollback, admin tier, gated, never an install or a
core update); `undo-changes` (#181) and rehearsal mode (#180); custom post
backup and fatal-check rollback, admin tier, gated; installs and core
updates came later, below); `undo-changes` (#181) and rehearsal mode (#180); custom post
types (#137); menus (#244); template, part and pattern writes (#172). Ordered
by what people use and what saves time, then by what keeps "safe on a live
site" true as rivals ship undo and snapshots. The research is in the
2026-09-27 plan; the updates decision revises the morning's "reported, never
run" and is written into `CLAUDE.md`'s hard line with the code.

**Added 2026-10-12, in this order, all free, next to the updates (Fahim):**
`search-plugins` (#348, built), `install-plugin` (#349, built), `install-theme` (#350, built),
`update-core` (#351, built), `update-translations` (#352). Installs come only from
the WordPress.org directory, by slug, through WordPress's own installer in
the background runner, and arrive inactive; activating stays its own
confirmed step, and nothing deletes. Core updates are minor and security
releases, plus a major release only when WordPress's own switch for all
new versions is on (the default since WordPress 5.6 for new installs). No zip or URL
installs, no file editors, no delete tools. The hard line was rewritten
first (#347).
3. **The agency workflow.** Divi 5 page editing moves to free (2026-09-26 split:
free edits, Pro operates the site). Divi design-system export/import as JSON (Pro) —
global colors, fonts, variables, presets, Theme Builder templates, library
Expand Down
268 changes: 268 additions & 0 deletions includes/abilities/core-updates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
<?php
/**
* WordPress core updates, through core's own automatic updater.
*
* Decided 2026-10-12 (#347, #351): Saddle applies a core update only for a
* release WordPress already offers for background updates, through
* {@see WP_Automatic_Updater::update()} in {@see Saddle_Update_Runner}, out of
* band, after a confirmed preview. Minor and security releases, always. A
* major release only when WordPress itself would apply it in the background
* (Fahim, 2026-10-12): its own switch in Dashboard → Updates, "automatic
* updates for all new versions", which no tool can flip. That switch is on by
* default for sites installed since WordPress 5.6 and "unset" (minor only)
* for sites upgraded from before; an owner who chose "maintenance and
* security releases only" gets every major refused. A major has no automatic
* rollback and upgrades the database, so Saddle never goes past that switch.
*
* @package Saddle
*/

defined( 'ABSPATH' ) || exit;

/**
* Register the core update abilities. Hooked to `wp_abilities_api_init`.
*
* The permission capability is `activate_plugins`, as for update-plugin:
* `update_core` maps to `do_not_allow` on a site that forbids file changes.
* The handler names that reason first, then checks `update_core` itself.
*/
function saddle_register_core_update_abilities() {
wp_register_ability(
'saddle/update-core',
array(
'label' => __( 'Update WordPress', 'saddle' ),
'description' => __( 'Updates WordPress itself to a release WordPress already offers for background updates (see list-updates). Minor and security releases (7.1.2 to 7.1.3) are always allowed. A major release (7.1 to 7.2) is allowed only when WordPress is set to update itself to all new versions (Dashboard → Updates; the default on sites installed since WordPress 5.6); otherwise it is refused, and the owner updates it there. When both are offered, the minor release comes first. It takes two calls. The first changes nothing: it returns a preview with the installed and new version, whether it is minor or major, the PHP and MySQL versions it needs against this site\'s, and for a major release the active plugins not tested with it and what cannot be undone, plus a confirm_token. Call again with that token to queue the update. WordPress\'s own updater then runs it in the background, with a short maintenance message for visitors; check list-updates a minute later for the result under "runs". A core update cannot be undone: WordPress has no rollback for one, and a major release also upgrades the database. Refuses development versions, a site where the last automatic core update failed critically, and sites where WordPress cannot change files.', 'saddle' ),
'category' => 'saddle',
'input_schema' => array(
'type' => 'object',
'default' => (object) array(),
'properties' => array(
'confirm_token' => array(
'type' => 'string',
'description' => __( 'Token from the preview step, required to queue the update.', 'saddle' ),
),
),
),
'execute_callback' => array( 'Saddle_Core_Update_Abilities', 'update_core' ),
'permission_callback' => Saddle_Capabilities::permission( 'admin', 'activate_plugins', 'update-core' ),
'meta' => saddle_ability_meta( false, true, false, 'admin', true ),
)
);
}

/**
* Execute callbacks for the core update abilities.
*/
class Saddle_Core_Update_Abilities {

/**
* saddle/update-core.
*
* @param mixed $input Ability input.
* @return array|WP_Error
*/
public static function update_core( $input = null ) {
$input = is_array( $input ) ? $input : array();
Saddle_Update_Runner::load_update_api();

// Refuse before a preview exists: a token must never be issued for an
// update this site cannot perform.
$blocked = Saddle_Update_Runner::blocked_by( 'core' );
if ( ! empty( $blocked ) ) {
return new WP_Error( 'saddle_updates_unavailable', implode( ' ', $blocked ), array( 'status' => 409 ) );
}
if ( ! current_user_can( 'update_core' ) ) {
return new WP_Error( 'saddle_cannot_update_core', __( 'The WordPress account this app is connected as cannot update WordPress here (on a multisite network, only a network administrator can). No Saddle setting changes that.', 'saddle' ), array( 'status' => 403 ) );
}

$offer = self::choose();
if ( is_wp_error( $offer ) ) {
return $offer;
}

$from = get_bloginfo( 'version' );
$to = (string) $offer->current;
$major = 'major' === self::kind( $to );
$risks = $major ? array(
__( 'WordPress has no automatic rollback for a core update. If the new version breaks a plugin or the theme, the site stays on it until someone fixes it.', 'saddle' ),
__( 'A major release upgrades the database, so going back means restoring a database backup.', 'saddle' ),
__( 'Saddle runs on WordPress\'s own APIs. If the release changes them, this app may lose its connection before it can report the result.', 'saddle' ),
) : array();

return Saddle_Approval::gate(
array(
'action' => 'update-core',
'target' => 'core',
// Bind the exact release: a preview shown for one version must
// not be confirmable into applying another offered in between.
'bind' => $to . '|' . ( isset( $offer->locale ) ? $offer->locale : '' ),
'summary' => $major
/* translators: 1: installed WordPress version, 2: new version. */
? sprintf( __( 'Update WordPress from %1$s to %2$s, a major release, in the background. There is no automatic rollback.', 'saddle' ), $from, $to )
/* translators: 1: installed WordPress version, 2: new version. */
: sprintf( __( 'Update WordPress from %1$s to %2$s, a minor release, in the background.', 'saddle' ), $from, $to ),
'preview' => array(
'from' => $from,
'to' => $to,
'kind' => $major ? 'major' : 'minor',
'locale' => isset( $offer->locale ) ? (string) $offer->locale : null,
'requires_php' => isset( $offer->php_version ) ? (string) $offer->php_version : null,
'php' => PHP_VERSION,
'requires_mysql' => isset( $offer->mysql_version ) ? (string) $offer->mysql_version : null,
'untested_plugins' => $major ? self::untested_plugins( $to ) : array(),
'cannot_be_undone' => $risks,
'runs_in_background' => true,
'maintenance' => __( 'Visitors see a short maintenance message while WordPress updates.', 'saddle' ),
),
// The gate logs the confirmed call with this line, once.
/* translators: 1: installed WordPress version, 2: new version. */
'done' => sprintf( __( 'Queued update of WordPress from %1$s to %2$s.', 'saddle' ), $from, $to ),
'input' => $input,
'execute' => static function () use ( $from, $to ) {
$run_id = Saddle_Update_Runner::queue(
'core',
array(
array(
'id' => 'core',
'name' => 'WordPress',
'from' => $from,
'to' => $to,
),
)
);
return array(
'queued' => true,
'run_id' => $run_id,
'type' => 'core',
'from' => $from,
'to' => $to,
'check_with' => 'list-updates',
'note' => __( 'The update starts within about a minute and reports applied, failed or skipped under "runs" in list-updates.', 'saddle' ),
);
},
)
);
}

/* ------------------------------------------------------------------ */

/**
* The release to offer: the newest minor one, else a major one WordPress
* would apply in the background itself, or a named refusal.
*
* @return object|WP_Error An offer from the update_core transient.
*/
private static function choose() {
$current = get_bloginfo( 'version' );
if ( false !== strpos( $current, '-' ) ) {
return new WP_Error(
'saddle_core_development',
/* translators: %s: installed WordPress version. */
sprintf( __( 'This site runs a development version of WordPress (%s). Saddle does not update development versions; update it in Dashboard → Updates.', 'saddle' ), $current ),
array( 'status' => 409 )
);
}
$failed = get_site_option( 'auto_core_update_failed' );
if ( is_array( $failed ) && ! empty( $failed['critical'] ) ) {
return new WP_Error( 'saddle_core_update_failed_before', __( 'The last automatic WordPress update on this site failed critically. WordPress stops updating itself until someone updates it in Dashboard → Updates, and Saddle does the same.', 'saddle' ), array( 'status' => 409 ) );
}

$best = array(
'minor' => null,
'major' => null,
);
foreach ( Saddle_Offer_Runner::core_offers() as $offer ) {
if ( version_compare( $offer->current, $current, '<=' ) ) {
continue;
}
$kind = self::kind( $offer->current );
if ( ! $best[ $kind ] || version_compare( $offer->current, $best[ $kind ]->current, '>' ) ) {
$best[ $kind ] = $offer;
}
}

$offer = $best['minor'];
if ( ! $offer && $best['major'] ) {
if ( ! Core_Upgrader::should_update_to_version( $best['major']->current ) ) {
return new WP_Error(
'saddle_major_core_update',
/* translators: %s: new WordPress version. */
sprintf( __( 'WordPress %s is a major release. Saddle applies one only when WordPress is set to update itself to all new versions, and this site updates itself only for maintenance and security releases. Ask the owner to update it in Dashboard → Updates, or to change that setting there. Do not retry.', 'saddle' ), $best['major']->current ),
array( 'status' => 409 )
);
}
$offer = $best['major'];
}
if ( ! $offer ) {
return new WP_Error( 'saddle_no_update_offered', __( 'WordPress is not offering a core update this site can apply in the background. list-updates shows what is offered; a release offered only in Dashboard → Updates is applied there.', 'saddle' ), array( 'status' => 409 ) );
}

global $wpdb;
if ( ! empty( $offer->php_version ) && version_compare( PHP_VERSION, $offer->php_version, '<' ) ) {
return new WP_Error(
'saddle_php_too_old',
/* translators: 1: new WordPress version, 2: required PHP version, 3: this site's PHP version. */
sprintf( __( 'WordPress %1$s needs PHP %2$s or newer; this site runs PHP %3$s. The host has to update PHP first.', 'saddle' ), $offer->current, $offer->php_version, PHP_VERSION ),
array( 'status' => 409 )
);
}
$sqlite = file_exists( WP_CONTENT_DIR . '/db.php' ) && empty( $wpdb->is_mysql );
if ( ! $sqlite && ! empty( $offer->mysql_version ) && version_compare( $wpdb->db_version(), $offer->mysql_version, '<' ) ) {
return new WP_Error(
'saddle_mysql_too_old',
/* translators: 1: new WordPress version, 2: required MySQL version, 3: this site's MySQL version. */
sprintf( __( 'WordPress %1$s needs MySQL %2$s or newer; this site runs %3$s. The host has to update the database first.', 'saddle' ), $offer->current, $offer->mysql_version, $wpdb->db_version() ),
array( 'status' => 409 )
);
}
return $offer;
}

/**
* Minor or major, by core's own rule: the same X.Y branch is minor.
*
* @param string $version Offered version.
* @return string 'minor' or 'major'.
*/
private static function kind( $version ) {
$branch = static function ( $v ) {
return implode( '.', array_slice( preg_split( '/[.-]/', (string) $v ), 0, 2 ) );
};
return $branch( $version ) === $branch( get_bloginfo( 'version' ) ) ? 'minor' : 'major';
}

/**
* Active plugins whose author has not tested them with a release's X.Y,
* from what WordPress.org last told this site. Plugins from elsewhere carry
* no "tested" and are not listed.
*
* @param string $version New WordPress version.
* @return array[] {plugin, name, tested}
*/
private static function untested_plugins( $version ) {
$known = get_site_transient( 'update_plugins' );
$tested = array();
foreach ( array( 'no_update', 'response' ) as $list ) {
foreach ( isset( $known->$list ) ? (array) $known->$list : array() as $file => $item ) {
$item = (object) $item;
if ( ! empty( $item->tested ) ) {
$tested[ $file ] = (string) $item->tested;
}
}
}

$new = implode( '.', array_slice( explode( '.', $version ), 0, 2 ) );
$all = Saddle_Context::get_plugins_quietly();
$found = array();
foreach ( (array) get_option( 'active_plugins', array() ) as $file ) {
if ( isset( $tested[ $file ] ) && version_compare( $tested[ $file ], $new, '<' ) ) {
$found[] = array(
'plugin' => $file,
'name' => isset( $all[ $file ]['Name'] ) ? $all[ $file ]['Name'] : $file,
'tested' => $tested[ $file ],
);
}
}
return $found;
}
}
Loading
Loading