Skip to content

🚀 chore(release): WP Bones 3.0.0 - #131

Merged
gfazioli merged 26 commits into
masterfrom
release/v3.0.0
Oct 6, 2026
Merged

gfazioli merged 26 commits into
masterfrom
release/v3.0.0

Conversation

@gfazioli

@gfazioli gfazioli commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Bumps WPBONES_COMMAND_LINE_VERSION to 3.0.0, and brings v3 to master as a fast-forward: master (v2.1.3) is an ancestor of this branch.

WP Bones 3.0 changes what the framework does when a plugin says nothing:

The release notes and the upgrade guide (/docs/migrating-to-v3) describe each change and how to keep the 2.x behaviour.

Tests on this branch

  • composer test: 350 tests and 897 assertions (224 unit, 126 console), against 205 and 533 at v2.1.3.
  • Live, in WPKirk-Developing, all green:
    • migrations (33)
    • csrf (12)
    • v3-access (18)
    • storage (11)
    • activation
    • console
    • view
    • qb
    • version
    • bones-safety (11)
    • bones-robustness (17)
  • Expected failures:
    • route-capability fails the 3 checks that assume the 2.x defaults.
    • eloquent fails on SQLite only, because it runs the published 2.1.3 Database ZIP, whose seeders 3.0 no longer runs. It runs again on the 3.0 ZIP after the cascade.
  • The 14 boilerplates were run against this code before the release, with v3 copied over their vendor/: 12 smokes pass. The ReactJS and TypeScript Jest runs failed only because node_modules was missing.
  • On Options, a PUT without the nonce got 403, and with it 200.
  • On Database, activation created the tables and the converted seed migrations filled them.

…eck on init

Every activation and every update used to run every migration and every seeder, and an
update ran them from upgrader_post_install: in the request doing the update, with the old
classes still loaded, and never for a ZIP replaced through Upload Plugin or for files copied
by FTP, git or Composer. A runOnce seeder ran only while its table was empty, so data added
in a later release never arrived (#40).

- Migrator: the files in database/migrations/ run once per site, in file name order,
  recorded by name in an option. A version stored per site is the cheap check on init that
  something may have changed; a pending migration that sorts before one that ran still runs,
  with a warning. One request at a time (INSERT IGNORE lock), stop at the first failure
  without recording it, tell the administrators, retry only from the admin.
- Migration moves to WPBones\Database\Migration and its constructor runs nothing; the 2.x
  name stays as a deprecated subclass. insert(), truncate(), isEmpty() for seed data.
- Seeders do not run any more: seed data is a migration. The class stays as a no-op so that
  a 2.x seeder file still loads during the update to 3.0.
- Activation read the plugin header nowhere: the slug was empty there and the options
  delta wrote to an options row with no name. It now loads the header first.
…table it is given

php bones migrate runs the pending migrations on this site through the plugin's own
runner, the way to run one added during development before a version bump. migrate:status
lists every migration, whether it ran, in which batch and version, and flags one that sorts
before a migration that already ran. Both exit 1 without WordPress above the plugin.

The migrate:create stub ignored the table name and always created my_plugin_products; it
now creates the table it was asked for, on the 3.0 base class.
…t dbDelta could not tidy

The first live run of the 2.1 → 3.0 update stopped at a 2.x migration: dbDelta() re-applying
a schema written on one line tried ALTER COLUMN `id` SET DEFAULT '' and MySQL refused. 2.x
ran that migration again on every activation and update and never saw the error; 3.0 applies
it once more and counted it as a failure, which blocked every migration after it.

create() now decides for itself: it throws when the table, or a column the schema declares
(read the way dbDelta reads them, one per line), is missing afterwards, and logs anything else
dbDelta left behind as tolerated. The Migrator skips exactly those errors, one occurrence
each, and the DESCRIBE probe dbDelta makes of a table it is about to create.
3.0 runs no seeders, so a plugin coming from 2.x needs its database folder converted:

- every database/migrations/*.php moves to the 3.0 base class name;
- every database/seeders/*.php becomes a migration that sorts after the latest migration, in
  the order the seeders ran, and the seeder file is deleted. The body of run() becomes up(),
  read with PHP's tokenizer so that comments and strings stay as they are; insert(),
  truncate() and count() gain the table the seeder implied, $this->tablename keeps working,
  and a seeder with $runOnce seeds only an empty table, as before;
- a seeder it cannot convert (no run(), other methods, no table) is kept and listed for
  review, and so is one that ran on every update and will now run once.

Running it again changes nothing. Migration gains count() and query(), the last two helpers
the 2.x Seeder had.
…Error

A deployed plugin has no namespace file, and getNamespace() died on it. The conversion belongs in
the plugin's sources: say so and exit 1, changing nothing.
…ry for the notice

A plugin moved to 3.0 without migrate:to-v3 now hears about its seeders when it is activated, not
only after an update. The admin notice reads the failure only when the stored version is behind
the plugin's, which a failure always leaves it: the steady state costs no query on admin pages.
The lock and the ledger:
- the lock row carries `time|token`: unlock() deletes only its own, touch() refreshes it before
  each migration, and an abandoned lock is taken over after an hour (WP_Upgrader's default)
  instead of ten minutes, so a long `php bones migrate` is not run twice;
- log() reports a ledger write the database refused, and the run stops there without moving
  the version, instead of running that migration again on the next update;
- a run started by a page load asks again under the lock: another request may have finished,
  or failed, while it waited, and a public page must not rerun what just failed;
- a cache older than the database (persistent object cache after an import) is dropped when
  refresh() sees it disagree, instead of costing a lock on every request.

Failures:
- the failure option stays, empty and autoloaded, so checking it costs no query; it is checked
  whatever the version, so a migration added without a bump that fails is retried from the admin
  and shown; a failure of 1.1.0 no longer holds back 1.1.1, which may fix it;
- insert(), truncate(), query() and count() throw when wpdb answers false, which it also does
  without recording an error (a value too long for its column).

The rest:
- activation finishes an update made while the plugin was inactive: plugin/updated.php runs;
- create() no longer counts CONSTRAINT, FOREIGN KEY and CHECK lines as columns;
- the automatic run is skipped under the bones CLI, so `php bones migrate` lists what it runs;
- OptionRepository::forget() for an uninstall.php that drops the tables;
- migrate:to-v3 reads the seeder's settings past its comments, keeps a namespace declaration,
  supports $this->wpdb, `$this -> insert(` and `$this?->insert(`, and flags truncate('name')
  under $usePrefix = false, which 2.x prefixed anyway.
…rt without losing a seeder

From Copilot's second pass on #119:

- setVersion() reports a write the database refused (a false from update_option() is checked
  against the row), and only the request that stored the new version finishes the update, so
  plugin/updated.php runs once; the next request retries the write.
- migrate:to-v3 deletes a seeder only once its migration is written and passes php -l; a
  migration that does not parse is removed and the seeder kept, listed for review. A migration
  file it cannot rewrite is reported instead of counted as updated.
…0 run too

From Codex's review of v3 (gpt-6-sol):

- The update's own work (the options delta, plugin/updated.php) ran after the version was stored
  and the lock released: a failure there was never retried, and another request could go on
  before it ended. Migrator::migrate() now takes it as a callback, runs it under the lock once
  the migrations went through and before the version moves; when it throws, the failure is
  recorded and the next run finishes the update again.
- The first 3.0 run of a site updated from 2.x has no stored version, so plugin/updated.php was
  skipped as if it were an installation, and that update's work was lost. It now runs with
  $previousVersion null; only a first activation, which is an installation, skips it.

Proven live: migrations-live-smoke.sh, the 2.1.0 → 3.0 update through the real upgrader now
finishes with plugin/updated.php on the first 3.0 request (33 checks green).
…failure

From Codex's second round on v3: when setVersion() failed after the update's work ran, the
failure had already been cleared, so every public request found the update due and ran
plugin/updated.php again. The version is now stored before the failure is cleared, and a refused
write is recorded like any failure: retried by an administrator, at most every RETRY_AFTER.
Breaking, for 3.0 (audit S2, S3, S9):
- a page of config/routes.php or of pages/ without a capability asks for
  manage_options; it asked for read (2.1.2), and before that for nothing;
- a menu of config/menus.php without a capability asks for manage_options,
  and its items with it; it asked for read;
- a REST route without a permission_callback refuses every request with
  WordPress's rest_forbidden (401 or 403), and the notice says how to make it
  public; up to 2.x it was public.

A page, menu or route meant for every logged-in user, or for everyone, says so:
'capability' => 'read', capability() returning 'read', or
'permission_callback' => '__return_true'.
The pages of config/routes.php and pages/, the menus of config/menus.php
and the REST routes of api/ that declare no capability or permission_callback,
one line each with the file (and the line, for a route), read from the
tokens. Nothing is rewritten: who may open a page is the author's call.
- the REST folder is the one config/api.php names (api.custom.path), not
  always api/; a path that is not a literal is reported;
- a config/routes.php or config/menus.php that does not return a literal
  array is a review item, never "nothing to change";
- only a key of the options argument counts as a permission_callback, not
  the string anywhere in the call; options that are not a literal array are
  reported.
…tom.path

The REST folder is read from the custom block's own path key, and only
when its value is one whole string literal: another path key earlier in
config/api.php, or a concatenation, made the report scan the wrong folder.
The report said "Nothing to change" where something did:
- a config entry whose key is a constant or an interpolation made the next
  entries' keys count for the previous one: such a file is now unreadable,
  as one that returns a variable is;
- the first return in the file was taken, an ABSPATH guard's included: only
  the file's own top-level return counts, and only one;
- 'capability' => null or '' counted as declared (the providers fall back to
  manage_options), and so did 'permission_callback' => null;
- a Route imported under another name, written in lower case, or nested in
  another call's arguments was not read; an attribute in the arguments made
  a declared route look undeclared;
- a pages/ capability() was found by a regex on the raw text: in a comment
  it counted, a protected one or one with required arguments counted, an
  implicitly public one did not. Read from the tokens now.

Tests: the menu spy kept one capability per slug, so the first item hid the
menu's own (a hard-coded 'read' in add_menu_page() passed); it keeps both,
and the post-type branch has a test. The REST status stub returns 418, so a
hard-coded 401 would fail.
Audit S6/S7. Up to 2.x compiled Blade views (<plugin>/.cache, .bladec) and
log files (<plugin>/storage/logs) were written inside the plugin folder,
created 0777, where the web server serves them as text; and "errorlog", the
default, also wrote a daily file there, against its own documentation.

- Support\Storage::path(plugin, folder): uploads/wpbones/<plugin folder>/
  <folder>, made with wp_mkdir_p, an index.php at every level and a
  deny-all .htaccess at the top (left alone once it exists); the temp folder
  when uploads cannot be written. Named after the plugin's folder: the slug
  is read from the header on init, after the logger is made.
- Blade is made when a Blade view first renders, compiles there, as .php.
- Logs: "errorlog" is error_log() only; "single" and "daily" default to
  Storage, with a hash keyed by AUTH_SALT in the file name (not wp_hash(),
  which is pluggable and not loaded yet at boot); a plugin.logging.path of
  the plugin's own keeps its folder and names.
- no temporary-folder fallback: a shared /tmp would let other local users
  read the logs and plant compiled views. When uploads cannot be written,
  Storage::path() answers null: a Blade view throws a RuntimeException that
  says why, a single/daily log goes to error_log() only;
- a deny rule that cannot be written means no folder, as Apache would serve
  what it holds;
- the test no longer removes a shared /tmp/wpbones;
- View::render(true) closes its buffer when the view throws.
- an .htaccess is accepted only if it denies: one that a short write left
  empty or partial (a full disk) is removed, and an existing one that denies
  nothing means no folder;
- View::render(true) restores the buffer level it found, so a view that
  throws inside a Blade @section or @Push no longer leaves render()'s own
  buffer open.
- a folder another request made first is used (wp_mkdir_p() answers false
  when it loses the race), and the .htaccess is written aside and renamed,
  so no request reads it half written;
- `Require all denied` alone (Apache 2.4) is accepted as the rule;
- the log name is keyed with AUTH_SALT only (or the salt WordPress keeps in
  the options): ABSPATH differed between a web request and WP-CLI, and
  between the releases of an atomic deploy;
- Blade runs with throwOnError: BladeOne's showError() closed a buffer it
  had not opened, the page's own;
- Storage::delete($plugin), for an uninstall.php;
- tests: the log key with a real AUTH_SALT, in a process of its own (the
  suite never defined one, so dropping the salt passed).
Breaking, for 3.0 (audit S4, S5):
- every request to a WP Bones admin page (menu item, route page, pages/
  class) that is not a GET or a HEAD carries the plugin's nonce, in the
  _wpbones_nonce field $plugin->csrfField() prints; Routing\Csrf checks it on
  load, before any load callback, and again before the page renders, and
  answers wp_nonce_ays(). A route that takes requests from elsewhere says
  'csrf' => false; a pages/ class, a public csrf() returning false.
  Up to 2.x store()/update()/destroy() and the load callbacks ran for a
  form posted from another site.
- a logged Ajax action of a provider without $nonceHash (or $nonceKey)
  refuses every request, with a notice; up to 2.x its nonce check returned
  true. useHTTPPost() returns unslashed values.
- Request::verifyNonce() reads a missing _wpnonce as a failed check, not an
  undefined index.
… open Ajax providers

The views whose POST forms do not print $plugin->csrfField(), and the
direct children of WordPressAjaxServiceProvider with logged actions and no
$nonceHash (a child of the plugin's own base class may inherit one, so it is
not listed).
- a pages/ csrf() is the opt-out only when declared, public and callable
  with no arguments: a csrf($token) of the page's own threw during menu setup;
- migrate:to-v3 checks each POST form on its own (one form with the field
  hid another without it), and the pages/ classes that print their own forms.
…ings

A pages/ class that opts out with a public csrf() returning false is not
listed, and in a view only its HTML counts: a POST form printed as an example
inside a PHP string (htmlentities(), as the Options boilerplate does) is not
one a browser submits. A pages/ class's returned strings still count.
- the nonce may come in the query string or, for a fetch() with a JSON body
  (no $_POST), in an X-WPBones-Nonce header; a logged Ajax action reads it
  from $_REQUEST, as check_ajax_referer() does;
- WordPress's own forms that post to the page they are on, each with a nonce
  of its own, go through: Screen Options and the filesystem credentials form;
- migrate:to-v3 finds the framework's Ajax provider under the name a use
  statement gives it (make:ajax and the boilerplates import it as
  ServiceProvider), and lists every stripslashes()/wp_unslash() in a file
  that calls useHTTPPost(), which now unslashes (Scotty decodes JSON after
  stripslashes: a quote would come back null);
- make:ajax writes $nonceHash = '{ClassName}' (it wrote '', which 3.0 refuses);
- the opt-out is described as what it is, for a page that checks a nonce of
  its own: wp-admin asks for a logged-in browser first, so it is no door for
  requests from elsewhere;
- route and pages/ load callbacks hang on the same hook name as the guard;
- tests: every menu item is guarded, a pages/ POST without the nonce is
  refused (removing either guard used to pass).
The Options boilerplate writes action="#<?php echo $mid ?>": the > that
closes the PHP ended the tag, so its five POST forms were never listed. The
tag is matched over the whole file now, with PHP blocks inside it, and kept
when it starts in the markup (a view's HTML, a pages/ class's strings).
@gfazioli
gfazioli merged commit 08f6420 into master Oct 6, 2026
4 checks passed
@gfazioli
gfazioli deleted the release/v3.0.0 branch October 6, 2026 15: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.

1 participant