Skip to content

Plugin Directory: Add an API to block a release from being served - #785

Closed
obenland wants to merge 18 commits into
WordPress:trunkfrom
obenland:feature/release-block-api
Closed

Plugin Directory: Add an API to block a release from being served#785
obenland wants to merge 18 commits into
WordPress:trunkfrom
obenland:feature/release-block-api

Conversation

@obenland

@obenland obenland commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Adds the mechanism for holding a release out of the update API: API_Update_Updater::block_release() records a release_block on the release meta, and update_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 reviewer force_release() clears the hold via add_release()'s new unblock flag.

This is the mechanism half of the release-block work, without any UI: block_release() has no callers yet. The scan-driven caller (a high max_risk_score verdict 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 stamps blocked_at, records the block, and re-runs update_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 predicate update_single_plugin() and callers share.
  • get_served_version( $slug ) exposes the row's version for callers' precondition checks.
  • Status changes made while a version is held (a closure, a reopen) still reach the row immediately through Plugin Directory: Sync plugin status to the update API during a release cooldown #783's partial sync.

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 in update_single_plugin() were mutation-tested: removing the is_release_blocked() early return or its wp_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

Copilot AI lite review requested due to automatic review settings August 11, 2026 17:47
@github-actions

Copy link
Copy Markdown

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 props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props obenland.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), and get_served_version() plus update gating in update_single_plugin() to keep blocked versions out of update_source.
  • Extend Plugin_Directory::add_release() to support an unblock flag 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>
@obenland
obenland force-pushed the feature/release-block-api branch from f1a7b70 to 021e523 Compare August 11, 2026 18:02
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>
@obenland
obenland requested a lite review from Copilot August 11, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 whether Plugin_Directory::add_release() succeeded. If that write fails, the method can still return true (because update_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 the REMOTE_ADDR setup out of setUp(), 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() returns true even if Plugin_Directory::add_release() fails to persist the release_block. In that failure mode the release is not actually held, and the subsequent update_single_plugin() call won’t cancel any deferred serve because is_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 via update_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 for force_release() (audit logging), set it locally in test_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';

obenland and others added 16 commits August 11, 2026 13:51
… 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>
@obenland
obenland requested a lite review from Copilot August 11, 2026 20:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

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.

2 participants