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
17 changes: 10 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,13 @@ dry-run, no diff and no revert.
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
releases, and a major release only when the owner has turned on Saddle's
own "Major WordPress releases" setting in Saddle → Settings, which is off by
default and which no tool can change. WordPress's own "automatic updates
for all new versions" switch does not count: it is on by default for sites
installed since 5.6, and it decides only what WordPress does unattended.
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.
Expand All @@ -160,8 +162,9 @@ dry-run, no diff and no revert.
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
2026-10-12 (#347). Majors were first behind WordPress's own switch (#351);
that switch defaults to on for new installs, so on 2026-10-13 Fahim had
majors moved behind Saddle's own setting, off by default (#359). Applies
to Pro too.

If a feature seems to need any of these, that is a signal to redesign the
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ when it is picked up; this file names direction, not tickets.
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
releases, plus a major release only when the owner turns on "Major
WordPress releases" in Saddle → Settings, off by default (#359). 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:
Expand Down
2 changes: 1 addition & 1 deletion admin/build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '5be3bd6a730a8feec28d');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '2f4f15c59a7d84460eae');
32 changes: 16 additions & 16 deletions admin/build/index.js

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions admin/src/components/SettingsPage.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Settings — one page, no tabs (#285).
*
* - Safety: three switches that save the moment they are flipped. A switch
* - Safety: four switches that save the moment they are flipped. A switch
* moves at once, says "Saved." when the server agrees, and moves back if it
* doesn't (R9, P16).
* - Advanced: rarely used things, each collapsed — single tools, sign-in for
Expand Down Expand Up @@ -49,7 +49,8 @@ const Labelled = ( { children, help } ) => (
);

/**
* Safety: drafts-only, rehearsal and the domain check, each saved on flip
* Safety: drafts-only, rehearsal, the domain check and major WordPress
* releases (#359), each saved on flip
* through `POST /preferences`.
*
* @param {Object} props
Expand Down Expand Up @@ -167,6 +168,21 @@ function Safety( { onRehearsalChanged } ) {
'saddle'
),
} ) }
{ row( {
key: 'core_major_updates',
// The address an app gives the owner when it is refused
// a major release (the settings registry's field id).
id: 'saddle-field-saddle-core_major_updates',
title: __( 'Major WordPress releases', 'saddle' ),
help: __(
'Lets an app update WordPress to a new major version, such as 7.1 to 7.2, after you approve its preview. A major release has no automatic rollback and upgrades the database. Minor and security releases never need this. WordPress’s own “automatic updates for all new versions” in Dashboard → Updates is a separate setting.',
'saddle'
),
meta: __(
'Apps can update WordPress to a new major version.',
'saddle'
),
} ) }
</RowList>
</section>
);
Expand Down
1 change: 1 addition & 0 deletions admin/src/settings-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ export const safetyPrefs = ( res ) => ( {
drafts_only: !! ( res && res.drafts_only ),
rehearsal: !! ( res && res.rehearsal ),
domain_enforced: !! ( res && res.domain && res.domain.enforced ),
core_major_updates: !! ( res && res.core_major_updates ),
} );
30 changes: 14 additions & 16 deletions includes/abilities/core-updates.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
* 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.
* major release only when the owner has turned on Saddle's own setting,
* "Major WordPress releases" in Saddle → Settings, which is off by default
* and which no tool can change (Fahim, 2026-10-13, #359). It replaced
* WordPress's own "automatic updates for all new versions" switch (#351):
* WordPress turns that on by default for sites installed since 5.6, so it
* was rarely the owner's choice, and it decides what WordPress does
* unattended, not what an app may do after a preview. A major has no
* automatic rollback and upgrades the database.
*
* @package Saddle
*/
Expand All @@ -32,7 +33,7 @@ function saddle_register_core_update_abilities() {
'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' ),
'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 the owner has turned on "Major WordPress releases" in Saddle → Settings, which is off by default and which no tool can change; otherwise it is refused, and the owner updates it in Dashboard → Updates or turns that setting on. 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',
Expand Down Expand Up @@ -237,8 +238,8 @@ public static function update_translations( $input = null ) {
/* ------------------------------------------------------------------ */

/**
* The release to offer: the newest minor one, else a major one WordPress
* would apply in the background itself, or a named refusal.
* The release to offer: the newest minor one, else a major one when the
* owner allows majors in Saddle → Settings, or a named refusal.
*
* @return object|WP_Error An offer from the update_core transient.
*/
Expand Down Expand Up @@ -273,11 +274,11 @@ private static function choose() {

$offer = $best['minor'];
if ( ! $offer && $best['major'] ) {
if ( ! Core_Upgrader::should_update_to_version( $best['major']->current ) ) {
if ( ! Saddle_Offer_Runner::core_majors_allowed() ) {
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 ),
sprintf( __( 'WordPress %s is a major release. Saddle applies one only when the owner turns on "Major WordPress releases" in Saddle → Settings, and it is off on this site. Tell the user they can update it in Dashboard → Updates, or turn that setting on and ask again. Do not retry.', 'saddle' ), $best['major']->current ),
array( 'status' => 409 )
);
}
Expand Down Expand Up @@ -315,10 +316,7 @@ private static function choose() {
* @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';
return Saddle_Offer_Runner::is_major( $version ) ? 'major' : 'minor';
}

/**
Expand Down
39 changes: 24 additions & 15 deletions includes/admin/class-saddle-rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,28 @@ public static function register_routes() {
'callback' => array( __CLASS__, 'update_settings' ),
'permission_callback' => array( __CLASS__, 'can_manage' ),
'args' => array(
'tier' => array(
'tier' => array(
'type' => 'string',
'required' => false,
'enum' => Saddle_Capabilities::tiers(),
),
'onboarded' => array(
'onboarded' => array(
'type' => 'boolean',
'required' => false,
),
'paused' => array(
'paused' => array(
'type' => 'boolean',
'required' => false,
),
'drafts_only' => array(
'drafts_only' => array(
'type' => 'boolean',
'required' => false,
),
'rehearsal' => array(
'rehearsal' => array(
'type' => 'boolean',
'required' => false,
),
'core_major_updates' => array(
'type' => 'boolean',
'required' => false,
),
Expand Down Expand Up @@ -631,22 +635,23 @@ public static function can_manage() {
public static function get_settings() {
return new WP_REST_Response(
array(
'tier' => Saddle_Capabilities::get_site_tier(),
'tiers' => Saddle_Capabilities::tiers(),
'default' => Saddle_Capabilities::DEFAULT_TIER,
'onboarded' => Saddle_Onboarding::is_finished(),
'paused' => Saddle_Capabilities::is_paused(),
'domain_warning' => ! Saddle_Capabilities::domain_matches_recorded(),
'domain' => array(
'tier' => Saddle_Capabilities::get_site_tier(),
'tiers' => Saddle_Capabilities::tiers(),
'default' => Saddle_Capabilities::DEFAULT_TIER,
'onboarded' => Saddle_Onboarding::is_finished(),
'paused' => Saddle_Capabilities::is_paused(),
'domain_warning' => ! Saddle_Capabilities::domain_matches_recorded(),
'domain' => array(
'current' => Saddle_Capabilities::current_domain(),
'recorded' => Saddle_Capabilities::recorded_tier_domain(),
'enforced' => Saddle_Capabilities::is_domain_enforced(),
),
'drafts_only' => Saddle_Capabilities::is_drafts_only(),
'rehearsal' => Saddle_Capabilities::is_rehearsal(),
'drafts_only' => Saddle_Capabilities::is_drafts_only(),
'rehearsal' => Saddle_Capabilities::is_rehearsal(),
'core_major_updates' => Saddle_Offer_Runner::core_majors_allowed(),
// The key itself never leaves the server — only whether one is
// set, plus a last-4 hint so the owner can recognize it.
'unsplash' => array(
'unsplash' => array(
'configured' => Saddle_Unsplash::is_configured(),
'key_hint' => Saddle_Unsplash::key_hint(),
),
Expand Down Expand Up @@ -703,6 +708,10 @@ public static function update_settings( WP_REST_Request $request ) {
Saddle_Capabilities::set_rehearsal( (bool) $request->get_param( 'rehearsal' ) );
}

if ( array_key_exists( 'core_major_updates', $params ) ) {
Saddle_Offer_Runner::set_core_majors( (bool) $request->get_param( 'core_major_updates' ) );
}

// Key absent from the body ⇒ untouched; '' or null ⇒ cleared;
// non-empty ⇒ validated and saved.
if ( array_key_exists( 'unsplash_access_key', $params ) ) {
Expand Down
2 changes: 1 addition & 1 deletion includes/class-saddle-context.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public static function system_context() {
$lines[] = '- ' . __( 'A 401 saying no key arrived usually means the web server strips the Authorization header. Ask the user to open Saddle → Settings → Connection check and run it — it can fix this automatically on most hosts.', 'saddle' );
$lines[] = '- ' . __( 'A destructive tool answering with a preview and a confirm_token is NOT an error — that is the approval gate. Show the user the preview; call again with the token only after they agree.', 'saddle' );
$lines[] = '- ' . __( 'Plugins and themes are installed only from the WordPress.org directory, by slug (search-plugins finds a plugin), and arrive inactive: activating is a separate, confirmed step (activate-plugin or activate-theme). Saddle never installs from a zip or a URL and never deletes a plugin or theme, so send the user to wp-admin for those.', 'saddle' );
$lines[] = '- ' . __( 'update-core applies minor WordPress releases, and a major release only when WordPress is set to update itself to all new versions (Dashboard → Updates). A refusal for a major release is that setting: tell the user, do not retry.', 'saddle' );
$lines[] = '- ' . __( 'update-core applies minor WordPress releases, and a major release only when the owner has turned on "Major WordPress releases" in Saddle → Settings. A refusal for a major release is that setting: tell the user, do not retry.', 'saddle' );
$lines[] = '';

// Recent-changes recall: what connected agents changed lately, from
Expand Down
14 changes: 13 additions & 1 deletion includes/class-saddle-core-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
defined( 'ABSPATH' ) || exit;

/**
* Scope `saddle` of the settings registry: the fourteen options Core already
* Scope `saddle` of the settings registry: the fifteen options Core already
* keeps, given one shape so the admin draws them, `/preferences/saddle`
* serves them and `saddle/get-module-settings` explains them.
*
Expand Down Expand Up @@ -142,6 +142,14 @@ public static function fields() {
'section' => __( 'Security', 'saddle' ),
'level' => 'basic',
),
'core_major_updates' => array(
'type' => 'boolean',
'default' => false,
'label' => __( 'Major WordPress releases', 'saddle' ),
'help' => __( 'Let apps update WordPress to a new major version, such as 7.1 to 7.2, after you approve the preview. Minor and security releases never need this.', 'saddle' ),
'screen' => $settings,
'control' => 'custom',
),
'memory_max_entries' => array(
'type' => 'integer',
'minimum' => 10,
Expand Down Expand Up @@ -204,6 +212,7 @@ public static function get() {
'unsplash_key' => Saddle_Unsplash::get_key(),
'memory_autoinject_agent' => (bool) get_option( Saddle_Memory::OPTION_AUTOINJECT, false ),
'enforce_tier_domain' => Saddle_Capabilities::is_domain_enforced(),
'core_major_updates' => Saddle_Offer_Runner::core_majors_allowed(),
'memory_max_entries' => (int) get_option( Saddle_Memory::OPTION_MAX_ENTRIES, Saddle_Memory::DEFAULT_MAX_ENTRIES ),
'memory_core_budget' => (int) get_option( Saddle_Memory::OPTION_CORE_BUDGET, Saddle_Memory::DEFAULT_CORE_BUDGET ),
'memory_recent_changes' => (bool) get_option( self::OPTION_RECENT_CHANGES, true ),
Expand Down Expand Up @@ -253,6 +262,9 @@ public static function set( array $values ) {
case 'enforce_tier_domain':
Saddle_Capabilities::set_domain_enforcement( (bool) $value );
break;
case 'core_major_updates':
Saddle_Offer_Runner::set_core_majors( (bool) $value );
break;
case 'memory_max_entries':
update_option( Saddle_Memory::OPTION_MAX_ENTRIES, (int) $value );
break;
Expand Down
Loading
Loading