Skip to content

feat: update-core applies the release WordPress offers (#351) - #357

Merged
ifahimreza merged 6 commits into
mainfrom
feat/351-update-core
Oct 12, 2026
Merged

ifahimreza merged 6 commits into
mainfrom
feat/351-update-core

Conversation

@ifahimreza

@ifahimreza ifahimreza commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #351

Stacked on #356 (base feat/350-install-theme). Merge #353 → #354 → #355 → #356 → this, with merge commits.

What

A new tool, saddle/update-core. It updates WordPress itself to a release WordPress already offers for background updates, through core's own automatic updater in the background runner, after a confirmed preview.

Why

#347: core updates join plugin and theme updates. Fahim's decision (2026-10-12): minor and security releases always. A major only behind WordPress's own "automatic updates for all new versions" switch, which no tool can flip.

⚠️ One thing Fahim should know

Since WordPress 5.6, a new install defaults that switch to on (auto_update_core_major = enabled in schema.php). Sites upgraded from before 5.6 have unset, and an owner who picked "maintenance and security releases only" has disabled; both mean minor only.

So on most modern sites, update-core will apply a major release when asked. This is exactly what WordPress would do by itself within about 12 hours. Saddle just does it now, after a confirmed preview. It is refused only where the owner (or an old install) has majors off.

That matches the rule you chose. Your expectation may have been that majors are usually refused, so I changed the copy to say "when WordPress is set to update itself to all new versions" rather than "when the owner turned it on", and a test pins core's default. If you want majors to need a separate Saddle opt-in instead, that's a small change. Say so.

How

  • Offers. Saddle reads the autoupdate entries of the update_core transient: the releases WordPress would apply in the background. get_core_updates() leaves those out; find_core_auto_update() reads the same. It picks the newest minor release, else a major only if Core_Upgrader::should_update_to_version() says WordPress would apply it itself (the switch, WP_AUTO_UPDATE_CORE, allow_major_auto_core_updates). When both are offered, the minor comes first.
  • Refused before any token:
    • blocked_by('core') (context ABSPATH)
    • no update_core
    • a development version
    • a recorded critical auto_core_update_failed
    • PHP or MySQL below the offer's needs (MySQL is skipped on SQLite, as core does)
    • a major WordPress would not apply
    • nothing offered
  • Preview. From and to, minor or major, the locale, and the PHP and MySQL needed against this site's. For a major: the active plugins WordPress.org says are not tested with the new X.Y, plus three plain sentences on what can't be undone (no rollback, database upgrade, Saddle relies on core APIs). The token binds release and locale.
  • Run. A one-shot auto_update_core filter says yes for exactly the confirmed version, and WP_Automatic_Updater::update( 'core', … ) applies it.
  • The runner split. Saddle_Update_Runner had reached 461 lines. The offer path (offers, the one-shot filter, the apply loop, result recording) moved to Saddle_Offer_Runner (180 lines), beside Saddle_Install_Runner. Saddle_Update_Runner keeps scheduling, guards, the lock and the run store (304 lines), and its public API is unchanged. The offer path also closes the output buffer core's skin opens on a failure (WP_Upgrader_Skin::error() calls header()).
  • Copy. A context refusal line, an undo reason (no rollback for core), CLAUDE.md and ROADMAP wording, the readme Updates and Undo bullets, a 1.5.2 changelog line, and the .pot.

Testing

  • phpunit on PHP 8.2: 1503 tests, 8570 assertions, 1 skipped (the existing 11-plugin test).
  • phpcs on every changed PHP file: clean.
  • New tests/core-updates-test.php (13 tests). Offers are built on a version-check answer recorded live, which pins the field names. Covered:
    • the tier test, and a minor preview that changes nothing
    • a confirm with zero upgrader calls, and single-use tokens
    • a token for one release failing for another
    • minor before major
    • core's new-install default pinned, a major refused when WordPress is minor-only, and a major allowed with untested plugins listed
    • every refusal
    • the runner handing exactly the confirmed package to core (download refused in the test, WordPress unchanged, maintenance off), and a release no longer offered reported up_to_date
  • A real core update, end to end. A disposable Playground was pinned to WordPress 7.1.2, with the built wporg zip and a Saddle key at Manage the site, driven by a small MCP client:
    1. list-updates offered 7.1.3.
    2. The update-core preview said minor, 7.1.2 to 7.1.3.
    3. Confirming queued the run, and one page load ran cron.
    4. The run finished applied, and get-site-info reports 7.1.3.
    5. The front page returns 200.
    6. The log reads "Updated WordPress from 7.1.2 to 7.1.3."

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

ifahimreza and others added 6 commits October 12, 2026 04:37
Rewrite the hard line before any code: Saddle installs a plugin or
theme only from the WordPress.org directory, by slug, through
WordPress's own installer, and the new install arrives inactive. Core
updates are minor and security releases, plus a major one only behind
WordPress's own switch for all new versions. Translations go through
core's language pack upgrader. Nothing deletes a plugin or theme. Adds
the two "What NOT to do" bullets and the ROADMAP entry.

Refs #347

Co-Authored-By: Claude Opus 5.5 <[email protected]>
New admin-tier, read-only tool. It asks the WordPress.org directory
through core's own plugins_api(), the same request Plugins > Add New
makes, by search words or one exact slug, at most 10 results. Each row
has the slug, name, author, version, active installs, rating, last
update, tested up to, the WordPress and PHP it needs, and whether it is
installed and active here, by core's install_plugin_install_status().
The directory answer is cached for an hour; the installed state never
is. Tests answer the directory from responses recorded from the live
API, which pins the field names.

Refs #348

Co-Authored-By: Claude Opus 5.5 <[email protected]>
New admin-tier, gated tool. One slug per call. The preview asks the
directory fresh and shows the version, installs, rating, last update,
tested-up-to against this WordPress and the PHP it needs, with warnings
for untested, two years without an update, or plugins it needs. The
token binds the version. Refused before any token: a site that forbids
file changes, an account without install_plugins, an unknown or closed
slug, an installed plugin or a leftover folder, PHP or WordPress too
old, and a package outside downloads.wordpress.org.

A confirm queues the install on the existing runner; the request never
calls the installer (pinned, and the update-plugin test now counts
upgrader calls too). Saddle_Install_Runner asks the directory again,
installs only the confirmed version through Plugin_Upgrader::install(),
and the plugin arrives inactive. Saddle_Directory holds the lookups and
refusals both sides need. The installed check reads core's rule
without install_plugin_install_status(), which forces an update check.

Refs #349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ated

Same shape as install-plugin: one slug, a fresh directory answer
through themes_api(), a preview with the version, installs, rating,
last update and the WordPress and PHP it needs, a token bound to the
version, a background run through Theme_Upgrader::install(). The theme
is never activated. The permission capability is switch_themes and the
handler checks install_themes after naming any blocked_by() reason.

Core's theme installer fetches a missing parent theme on its own. A
child theme whose parent is not installed is refused before a token,
the run checks again (the folder too, since WP_Theme can answer from
the object cache after a delete), and during the run the directory
answers only the confirmed slug.

Saddle_Directory now only talks to the API. The installed check and
the refusals moved to Saddle_Install_Runner, where the run and the
abilities both reach them, as blocked_by() sits on the update runner.

Refs #350

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Found end to end on Playground: install-theme over MCP failed with
"Call to undefined function themes_api()". The update runner's loader
keys the admin theme.php on wp_get_themes(), which wp-includes always
defines, so a REST request never loaded it. The test suite loads the
admin files up front, which hid it.

Refs #350

Co-Authored-By: Claude Opus 5.5 <[email protected]>
New admin-tier, gated tool. It reads the "autoupdate" entries of the
update_core transient, the releases WordPress would apply in the
background (get_core_updates() leaves them out), and picks the newest
minor release, else a major one only when Core_Upgrader says WordPress
would apply it itself. The preview shows the from and to version, minor
or major, the PHP and MySQL needed, and for a major the active plugins
not tested with it and what cannot be undone. The token binds the
release. Refused before a token: blocked_by() reasons, no update_core,
a development version, a recorded critical failure, PHP or MySQL too
old, a major WordPress would not apply, nothing offered.

Found while testing: since WordPress 5.6 a new install defaults
auto_update_core_major to "enabled", so on most sites WordPress's own
switch already allows majors. The copy now says "when WordPress is set
to update itself to all new versions" instead of "when the owner turned
it on", and a test pins core's default.

The runner was at 461 lines, so the offer path (offers, the one-shot
auto_update filter, the apply loop, result recording) moved to
Saddle_Offer_Runner beside Saddle_Install_Runner; Saddle_Update_Runner
keeps scheduling, guards, the lock and the run store (304 lines). The
offer path also closes the output buffer core's skin opens on failure.

Refs #351

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ifahimreza
ifahimreza changed the base branch from feat/350-install-theme to main October 12, 2026 10:03
@ifahimreza ifahimreza linked an issue Oct 12, 2026 that may be closed by this pull request
@ifahimreza
ifahimreza merged commit ca6d4e1 into main Oct 12, 2026
8 checks passed
@ifahimreza
ifahimreza deleted the feat/351-update-core branch October 12, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update-core: apply WordPress core updates through core's updater

1 participant