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
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,19 @@ jobs:
# and WordPress checkout and needs neither `vendor/` nor the WordPress
# test suite.
smoke:
name: "Smoke / PHP ${{ matrix.php }}"
name: "Smoke / PHP ${{ matrix.php }}${{ matrix.wp == 'nightly' && ' / WP nightly' || '' }}"
needs: static
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.3', '8.5']
wp: ['latest']
# A WordPress that ships the Secrets API: the plugin loads none of its own
# copy and the commands run on core's implementation (ADR 0014).
include:
- php: '8.3'
wp: 'nightly'
services:
mysql:
image: mysql:8.0
Expand All @@ -312,4 +318,4 @@ jobs:
extensions: sodium, mysqli
coverage: none

- run: make smoke DB_HOST=127.0.0.1
- run: make smoke DB_HOST=127.0.0.1 WP_VERSION=${{ matrix.wp }}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ be copied verbatim into `wordpress-develop` — same paths, same coding style, s
domain.

Once core ships the API, the plugin stands down on its own and says so in an admin notice rather
than shadowing core's implementation.
than shadowing core's implementation. It keeps providing `wp secret` and `wp network-secret`, which
core leaves out, running on core's implementation.

> **Status: feature plugin, pre-core-merge.** The public API surface is settled — it is the one
> described in the proposal. See [`docs/journal/open-questions.md`](docs/journal/open-questions.md) for what is
Expand Down
15 changes: 15 additions & 0 deletions cli/class-wp-cli-secret-command.php
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ public function import_option( array $args ): void {
* There is no delete step. Removing a legacy option once the migration is
* verified is left to the operator, using wp option delete.
*
* Not available on a WordPress that ships the Secrets API: the migrator loads
* only with this plugin's own copy of the API. Migrate before upgrading.
*
* ## OPTIONS
*
* [--dry-run]
Expand Down Expand Up @@ -463,6 +466,12 @@ public function migrate_legacy( array $args, array $assoc_args ): void {
return;
}

if ( ! class_exists( 'Secrets_API_Migrator', false ) ) {
WP_CLI::error( 'migrate-legacy is not available on a WordPress that ships the Secrets API. Migrate legacy secrets before upgrading.' );

return;
}

$map = array();

foreach ( explode( ',', $this->string_arg( $assoc_args, 'map' ) ) as $pair ) {
Expand Down Expand Up @@ -640,6 +649,12 @@ public function generate_key(): void {
* @param array<string, string|bool> $assoc_args Associative arguments.
*/
public function health( array $args, array $assoc_args ): void {
// Core loads its Site Health tests only for the Site Health screen, so on a
// WordPress that ships the API they are not loaded under WP-CLI.
if ( ! function_exists( 'wp_secrets_site_health_test_key_source' ) ) {
require_once ABSPATH . 'wp-admin/includes/secrets-site-health.php';
}

$results = array(
wp_secrets_site_health_test_key_source(),
wp_secrets_site_health_test_undecryptable(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: "ADR 0014: The plugin keeps WP-CLI on a core that ships the API"
description: "Once WordPress ships the Secrets API, the plugin loads none of its own copy but still registers wp secret and wp network-secret, running on core's implementation. migrate-legacy does not come along."
---

# ADR 0014: The plugin keeps WP-CLI on a core that ships the API

| | |
|---|---|
| **Number** | 0014 |
| **Date** | 2026-10-26 |
| **Status** | Accepted |

## Context

The Secrets API landed in core trunk as r64337, for 7.2. Core took `src/` and left the WP-CLI
commands out: WP-CLI commands ship as WP-CLI packages rather than in core, and the commit message
says the commands stay in the feature plugin.

Until now the plugin's bootstrap went fully dark on a WordPress that ships the API. It loaded
nothing, and its admin notice said it could be deactivated. That made the commands disappear on
exactly the release the commit message promised them for.

## Decision

**On a WordPress that ships the API, the plugin registers `wp secret` and `wp network-secret` and
nothing else.** It loads none of `src/` or `plugin/`. The commands call the public API and the
internals core has as well (`_wp_secrets_get_key_manager()`, `_wp_secrets_get_provider()`,
`_wp_secrets_get_store()`, `WP_Secrets_Config_Key_Provider`), so the same `cli/` runs on core's
implementation unchanged. `wp secret health` loads core's
`wp-admin/includes/secrets-site-health.php` itself, since core loads it only for the Site Health
screen.

**`migrate-legacy` does not come along.** The prototype was deprecated and its users told to
migrate more than a month before 7.2. The migrator and legacy reader load only with the plugin's
own copy. On core, the subcommand stays registered, because WP-CLI registers every public method,
but it exits 1 with a message saying to migrate before upgrading.

**A conflict still registers nothing.** When something other than core declared
`wp_get_secret()`, the commands would run against an unknown credential store, so they stay off.

## Consequences

- The admin notice on a WordPress that ships the API says the plugin now provides only its WP-CLI
commands, and can be deactivated by anyone who does not use them.
- `cli/` must keep calling nothing that only the plugin's copy defines. The CLI tests and the smoke
test run against trunk in CI, which catches a call that would break there.
- The read-time prototype fallback store is gone on 7.2. A site still holding prototype rows
has to run `migrate-legacy` on 6.8–7.1 before upgrading.
18 changes: 18 additions & 0 deletions docs/reference/functions.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/reference/wp-cli.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions docs/spec/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ It copies `src/` unchanged, then adds the loading the plugin's bootstrap does no

It also brings the tests for everything in `src/`.

**Standing down.** `wp_secrets_api_bootstrap()` in `secrets-api.php` loads nothing and shows an
admin notice when `$wp_version` is at least `WP_SECRETS_API_CORE_VERSION` (`7.2`, overridable)
**Standing down.** `wp_secrets_api_bootstrap()` in `secrets-api.php` loads none of `src/` or
`plugin/`, registers only the WP-CLI commands, and shows an admin notice when `$wp_version` is at least `WP_SECRETS_API_CORE_VERSION` (`7.2`, overridable)
**and** `wp_get_secret()` already exists. A beta, RC, or trunk build counts as its release, so
`7.2-beta1-64606-src` passes the version test. If the symbol exists but the version test fails, the
plugin refuses to load and shows a conflict notice instead of deferring to an unknown
Expand All @@ -63,8 +63,9 @@ the named surface, all in `src/wp-includes/secrets.php` unless noted:
`wp secret` with `set`, `get`, `delete`, `list`, `retire`, `import-option`, `migrate-legacy`,
`rotate`, `generate-key`, `health`, and `dropin`. `WP_CLI_Secret_Network_Command` registers
`wp network-secret` with the same subcommands for network scope. Both load only when `WP_CLI` is
defined and true. Both are exercised end to end against a real `wp` binary by
`tests/smoke/smoke.sh`, on single site and multisite.
defined and true. On a WordPress that ships the API they run on core's implementation, and
`migrate-legacy` refuses, since its migrator loads only with the plugin's copy. Both are exercised
end to end against a real `wp` binary by `tests/smoke/smoke.sh`, on single site and multisite.

**Site Health.** Three tests (`secrets_api_key_source`, undecryptable secrets,
`secrets_api_needs_rotation`) and a debug-information section, in
Expand All @@ -91,6 +92,11 @@ without the API, stranding every site relying on it. The proposal's own timeline
deferral to 7.3. `docs/decisions/` does not record this separately; the reasoning lives in the
bootstrap's comments.

**Why WP-CLI stays in the plugin.** The proposal puts WP-CLI support in 7.2. Core took the API
and left the commands out, since WP-CLI commands ship as WP-CLI packages, so the plugin keeps
providing them on 7.2. See
[ADR 0014](../decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md).

**`migrate-legacy` is outside the proposal.** It, and the read-time upgrade behind it, exist for
sites that adopted an earlier prototype. Neither is core-bound. See [import.md](import.md).

Expand Down
2 changes: 1 addition & 1 deletion phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
<rule ref="PHPCompatibilityWP"/>
<config name="testVersion" value="7.4-"/>

<config name="minimum_wp_version" value="6.6"/>
<config name="minimum_wp_version" value="6.8"/>

<rule ref="WordPress.WP.I18n">
<properties>
Expand Down
38 changes: 31 additions & 7 deletions secrets-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,19 @@ function wp_secrets_api_bootstrap(): void {
// version_compare() alone ranks every pre-release below '7.2'.
$release = explode( '-', $wp_version )[0];

// Core ships the API. Load nothing; the plugin is redundant.
/*
* Core ships the API. Load nothing of src/ or plugin/: core's implementation
* serves every call. The WP-CLI commands are the exception, because core leaves
* WP-CLI commands to WP-CLI packages, so this plugin still provides them, running
* on core's functions and classes.
*/
if ( version_compare( $release, WP_SECRETS_API_CORE_VERSION, '>=' ) && $symbol_taken ) {
add_action( 'admin_notices', 'wp_secrets_api_notice_superseded' );

if ( defined( 'WP_CLI' ) && WP_CLI ) {
wp_secrets_api_register_cli();
}

return;
}

Expand Down Expand Up @@ -205,14 +214,29 @@ function wp_secrets_api_bootstrap(): void {
*/
require_once WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-migrator.php';

require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-command.php';
require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-network-command.php';

WP_CLI::add_command( 'secret', 'WP_CLI_Secret_Command' );
WP_CLI::add_command( 'network-secret', 'WP_CLI_Secret_Network_Command' );
wp_secrets_api_register_cli();
}
}

/**
* Registers `wp secret` and `wp network-secret`.
*
* Called under WP-CLI from both the branch that loads the plugin's own copy of
* the API and the branch where core ships it. cli/ is never copied to core, and
* every call it makes is to the public API or to internals core has too, so the
* same commands run on either implementation. The one exception is
* `migrate-legacy`, whose migrator loads only with the plugin's copy.
*
* @return void
*/
function wp_secrets_api_register_cli(): void {
require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-command.php';
require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-network-command.php';

WP_CLI::add_command( 'secret', 'WP_CLI_Secret_Command' );
WP_CLI::add_command( 'network-secret', 'WP_CLI_Secret_Network_Command' );
}

/**
* Admin notice shown when core supersedes this plugin.
*
Expand All @@ -224,7 +248,7 @@ function wp_secrets_api_notice_superseded(): void {
}

wp_admin_notice(
esc_html__( 'This version of WordPress provides the Secrets API natively. The Secrets API feature plugin is no longer doing anything and can be deactivated.', 'secrets-api' ),
esc_html__( 'This version of WordPress provides the Secrets API natively. The Secrets API feature plugin now provides only its WP-CLI commands, wp secret and wp network-secret, and can be deactivated if you do not use them.', 'secrets-api' ),
array( 'type' => 'info' )
);
}
Expand Down
11 changes: 8 additions & 3 deletions tests/includes/class-mock-wp-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class WP_CLI {
public static $errors = array();
public static $confirm_response = true;
public static $formatted_items = array();
public static $commands = array();

public static function reset() {
self::$log = array();
Expand All @@ -57,6 +58,7 @@ public static function reset() {
self::$errors = array();
self::$confirm_response = true;
self::$formatted_items = array();
self::$commands = array();
}

public static function log( $message ) {
Expand Down Expand Up @@ -118,11 +120,14 @@ public static function confirm( $question, $assoc_args = array() ) {
}

/**
* @param string $name Command name. Ignored by this test double.
* @param string $command_class Command class name. Ignored by this test double.
* Records the registration. This test double never dispatches real
* subcommands; tests call the command class's methods directly.
*
* @param string $name Command name.
* @param string $command_class Command class name.
*/
public static function add_command( $name, $command_class ) {
// No-op: this test double never dispatches real subcommands.
self::$commands[ $name ] = $command_class;
}
}
}
Expand Down
53 changes: 28 additions & 25 deletions tests/includes/trait-secrets-api-plugin-layer-tests.php
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
<?php
/**
* For test cases that cover the plugin-only layer: the WP-CLI commands, the
* migrator, the legacy reader, and the prototype fallback store.
*
* On a WordPress that ships the Secrets API (trunk since r64337), the plugin
* stands down and loads none of that layer, so these cases skip. The skip keys
* on core's own file declaring wp_get_secret(), not on a plugin class being
* missing: a plugin that failed to load its layer on 6.8 should fail here, not
* skip.
* For test cases that cover the plugin-only layer the plugin stops loading once
* WordPress ships the Secrets API: the migrator, the legacy reader, and the
* prototype fallback store. The WP-CLI commands are not part of it; they load
* on either implementation.
*
* @package SecretsAPI
*/

/**
* Whether this WordPress ships the Secrets API (trunk since r64337), meaning core's
* own wp-includes/secrets.php declared wp_get_secret() rather than the plugin's
* copy in src/.
*
* Tests skip on this, not on a plugin class being missing: a plugin that failed
* to load its layer on 6.8 should fail, not skip.
*
* @return bool
*/
function secrets_api_tests_core_ships_the_api(): bool {
if ( ! function_exists( 'wp_get_secret' ) ) {
return false;
}

$declared_in = ( new ReflectionFunction( 'wp_get_secret' ) )->getFileName();

return false !== $declared_in && realpath( $declared_in ) === realpath( ABSPATH . WPINC . '/secrets.php' );
}

/**
* Skips the whole test case when the plugin has stood down for core.
*/
trait Secrets_API_Plugin_Layer_Tests {

public static function set_up_before_class(): void {
if ( self::core_provides_the_secrets_api() ) {
if ( secrets_api_tests_core_ships_the_api() ) {
self::markTestSkipped( 'This WordPress ships the Secrets API, so the plugin stands down and loads no plugin-only layer.' );
}

parent::set_up_before_class();
}

/**
* Whether wp_get_secret() was declared by core's wp-includes/secrets.php
* rather than by the plugin's copy in src/.
*
* @return bool
*/
private static function core_provides_the_secrets_api(): bool {
if ( ! function_exists( 'wp_get_secret' ) ) {
return false;
}

$declared_in = ( new ReflectionFunction( 'wp_get_secret' ) )->getFileName();

return false !== $declared_in && realpath( $declared_in ) === realpath( ABSPATH . WPINC . '/secrets.php' );
}
}
Loading
Loading