Plugin Directory: Add an API to block a release from being served - #785
Plugin Directory: Add an API to block a release from being served#785obenland wants to merge 18 commits into
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
There was a problem hiding this comment.
Pull request overview
This PR adds a “release block” mechanism to prevent a specific plugin release from being served via the update API, while continuing to serve the previously-served version and canceling any scheduled cooldown-based serve. It also wires force-release to clear an existing block and includes tests for both cooldown syncing behavior and release blocking.
Changes:
- Add
API_Update_Updater::block_release(),is_release_blocked(), andget_served_version()plus update gating inupdate_single_plugin()to keep blocked versions out ofupdate_source. - Extend
Plugin_Directory::add_release()to support anunblockflag used by force-release to clear a block. - Add PHPUnit coverage for cooldown status syncing and release blocking/force-release behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php | Adds release-block API and updates update_single_plugin() to keep blocked versions from being served while still syncing availability fields. |
| wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php | Adds unblock handling in add_release() to clear release_block metadata. |
| wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php | Tests that cooldown defers only the version bump while status changes sync to update_source. |
| wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test.php | Tests that blocking holds an unserved version, cancels deferred serve, outlasts cooldown, and force-release clears the block. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
block_release() holds a plugin's current version out of update_source until it is force-released: the block is recorded as release_block on the release meta, update_single_plugin() refuses to serve a blocked version and cancels the deferred cooldown serve, and force_release() clears the hold via add_release()'s new unblock flag. The previously served version keeps being served throughout, and status changes still reach the row right away. Nothing calls block_release() yet; the scan-driven caller follows in its own patch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f1a7b70 to
021e523
Compare
A caller bug now fails fast with a clear TypeError at the API boundary instead of an obscure error at the blocked_at write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (5)
wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php:477
force_release()doesn’t check whetherPlugin_Directory::add_release()succeeded. If that write fails, the method can still returntrue(becauseupdate_single_plugin()is largely idempotent), even though the cooldown/block wasn’t actually cleared.
Plugin_Directory::add_release(
$post,
array(
'tag' => $release['tag'],
'release_delay' => 0,
// Clear any release block so update_single_plugin() serves the version.
'unblock' => true,
)
);
return self::update_single_plugin( $plugin_slug );
}
wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test.php:254
force_release()requires$_SERVER['REMOTE_ADDR']for audit logging. After moving theREMOTE_ADDRsetup out ofsetUp(), set it just for this test and restore/unset it afterwards to avoid global leakage.
public function test_force_release_clears_block(): void {
$this->insert_served_row();
$this->assertTrue( $this->block() );
$this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed; false positive.' ) );
wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php:291
block_release()returnstrueeven ifPlugin_Directory::add_release()fails to persist therelease_block. In that failure mode the release is not actually held, and the subsequentupdate_single_plugin()call won’t cancel any deferred serve becauseis_release_blocked()will still be false.
This issue also appears on line 466 of the same file.
$block['blocked_at'] = time();
Plugin_Directory::add_release(
$post,
array(
'tag' => $release['tag'],
'release_block' => $block,
)
);
// Cancel a serve scheduled for cooldown-end; the row keeps the previous version.
self::update_single_plugin( $plugin_slug );
return true;
wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test.php:158
- This test asserts that the deferred serve cron is cancelled, but it never schedules the cooldown cron in the first place (so
wp_next_scheduled()would be false even if the block logic didn’t clear anything). Set up the cooldown schedule viaupdate_single_plugin()before blocking, then assert it gets cleared.
public function test_block_holds_unserved_version(): void {
$this->insert_served_row();
$this->assertTrue( $this->block() );
$this->assertTrue( API_Update_Updater::is_release_blocked( $this->get_release() ) );
wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test.php:54
setUp()unconditionally overwrites$_SERVER['REMOTE_ADDR'], which can leak global state into other tests. Since it’s only needed forforce_release()(audit logging), set it locally intest_force_release_clears_block()and restore/unset it afterwards.
This issue also appears on line 249 of the same file.
wp_cache_flush();
// Tools::audit_log() reads it unguarded.
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
… file. Both suites exercised the same fixture — a published plugin with a staged release in cooldown and an update_source row serving the previous version — duplicating ~90 lines of setup and helpers. Update_Source_Hold_Test now carries both groups of tests with the fixture defined once, the block tests reusing the cooldown tests' get_row()/set_status() helpers, and get_release() gains its array|false return type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llowance. update_source.version is varchar(128), so absurdly long version metas are stored truncated — the reason for cron_trigger()'s left( pm.meta_value, 128 ) clauses. The strict comparisons in update_single_plugin() and block_release()'s already-live guard were blind to that: a served-but- truncated version could still be blocked, and the resulting hold could never self-correct, wedging the row until a manual force-release. Compare against the truncated version meta instead, matching the SQL's allowance, and cover the guard with a regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_block_outlasts_cooldown() ran with the fixture's day-long cooldown still active, so the cooldown deferral alone produced the asserted outcome; clear the cooldown first so only the block can be holding the version. test_block_holds_unserved_version() asserted the deferred serve was cancelled without ever scheduling one; run update_single_plugin() before blocking so the assertion has something to cancel. Both tests now fail when the block gate or its wp_clear_scheduled_hook() call is removed from update_single_plugin(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
force_release()'s log entry only ever described a cooldown bypass, so unblocking a held release left an audit trail that never mentioned the block — and once add_release()'s unblock flag deletes the block record, no trace of it survives anywhere. Compose the entry from what is actually lifted: the block (with its blocked_at date) when one is held, and the cooldown bypass only while the cooldown is still running, so an elapsed or zeroed cooldown is no longer claimed as bypassed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The block time is irrelevant to the action being logged; noting that a block was lifted is the trace that matters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flag is documented where add_release() consumes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nested get_comments() inside wp_list_pluck() fails phpcs (PEAR.Functions.FunctionCallSignature), and CI runs a full-file lint on added files. A get_audit_log() helper reads cleaner anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
block_release() returned true without checking add_release()'s result. A failed meta write would leave no block recorded and the deferred serve still scheduled, while the caller is told the version is held — a false success in exactly the direction a protective control can't afford. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
block_release()'s contract is "whether the version is held as a result", but an already-held release returned false — a caller alerting on failure would fire on the exact state it asked for. The refusal now only covers states where the version is not and cannot be held; a repeat block is an idempotent no-op that preserves the existing record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The summary line ("until it's force-released") read as an unconditional
hold, but a later version deliberately escapes the block — that's the
author's path to shipping a fix without reviewer intervention. State the
design so the escape isn't mistaken for a hole.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docblock still described only the cooldown; lifting a block is now a first-class purpose of the function — and the only way to clear one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The realistic reviewer flow — a release blocked while its cooldown still runs — was never exercised end-to-end: every test lifted zero or one hold, leaving the combined audit message and the unblock/release_delay interplay in a single add_release() call untested. Verified the new test fails when the combined log assembly regresses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
update_single_plugin()'s substr() comparison keeps a served >128-char version from reading as perpetually new — re-anchoring release_time and re-queueing the deferred serve on every cron pass — but only block_release()'s own substr() was covered. Verified the new test fails when the comparison reverts to a strict full-string compare. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deleting the releases meta routed get_releases() through prefill_releases_meta(), which shells out to a live SVN lookup — the test passed only because the request failed. An empty array tests the same contract with no external dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fixture's day-long cooldown would have held the version even without the block, so the no-row assertion didn't prove the block did it. Clearing the delay first attributes the hold to the block alone, as test_block_outlasts_cooldown already does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds the mechanism for holding a release out of the update API:
API_Update_Updater::block_release()records arelease_blockon the release meta, andupdate_single_plugin()refuses to serve a blocked version — the previously served version keeps being served, and the serve deferred to cooldown-end is cancelled rather than postponed, so a block outlasts the cooldown. The existing reviewerforce_release()clears the hold viaadd_release()'s newunblockflag.This is the mechanism half of the release-block work, without any UI:
block_release()has no callers yet. The scan-driven caller (a highmax_risk_scoreverdict blocking the scanned release) follows in #777 once this lands.Blocks are deliberately scoped to a single release: a new version escapes the hold, so an author can ship a fixed release without plugin-review-team intervention, while unblocking the held version itself requires a reviewer force-release.
How it works
block_release( $slug, $block )refuses when the version cannot be held: no plugin, no release record, the version already being served, or the block failing to persist. Blocking an already-held release is an idempotent no-op success that preserves the existing block (a second record would merge into the first). On success it stampsblocked_at, records the block, and re-runsupdate_single_plugin()so a serve scheduled for cooldown-end is cancelled now. Capability checks and audit logging are deliberately the caller's.is_release_blocked( $release )is the single predicateupdate_single_plugin()and callers share.get_served_version( $slug )exposes the row's version for callers' precondition checks.Testing
The cooldown suite and the new block tests are merged into
tests/Update_Source_Hold_Test.php(16 tests). The block half: a block holds an unserved version and cancels the deferred serve (scheduled first, so the cancellation is actually exercised); the block, not the cooldown clock, holds the version — proven with the cooldown cleared for both a staged and a first-ever release; served (including varchar(128)-truncated) and unknown releases refuse to block, without touching the network; re-blocking an already-held release is a no-op success that preserves the first block; a status change reaches the row while held; force-release clears the hold and serves the version, both alone and combined with an active cooldown (covering the combined audit message). Both block gates inupdate_single_plugin()were mutation-tested: removing theis_release_blocked()early return or itswp_clear_scheduled_hook()call fails the suite, as does reverting the cooldown gate's varchar(128) truncation allowance or breaking the combined force-release log assembly.🤖 Generated with Claude Code