Skip to content

Plugin Directory: Resolve release holds from the stable tag - #807

Closed
obenland wants to merge 15 commits into
WordPress:trunkfrom
obenland:release-resolution-mismatched-header
Closed

Plugin Directory: Resolve release holds from the stable tag#807
obenland wants to merge 15 commits into
WordPress:trunkfrom
obenland:release-resolution-mismatched-header

Conversation

@obenland

@obenland obenland commented Aug 13, 2026

Copy link
Copy Markdown
Member

The release cooldown and #785's release block are resolved by looking up the release named after the plugin's Version header: update_single_plugin(), block_release(), and force_release() all call get_release( $post, $version ), which matches by tag. An author can commit one line into a held tag — changing Version: 1.4.4 to Version: 1.4.5 — and the lookup finds no release for "1.4.5": no block, no cooldown, and the tag's rebuilt package is written to update_source immediately under the new version label. The importer's version_tag_mismatch check is a warning, not a gate. The same rename can point at another, clean release's version to inherit its (absent) hold. With the release cooldown live in production, this is an active bypass of it today: the rename ships a held tag's rebuilt package immediately instead of waiting out the cooldown. It would equally bypass #777's scan-driven blocks once those land.

Builds on #785; complements #777 and #806.

What it does

  • API_Update_Updater::get_current_release() resolves the release being served or held from the stable tag — the source of the served package, so the hold follows the content, not the header's label — falling back to the version for trunk releases, which are keyed trunk@{version}. A rename inside a held tag now resolves to that tag's release, block and cooldown intact.
  • update_single_plugin(), block_release(), and force_release() use it, so enforcement, scan-driven blocking, and the reviewer's release all target the same release row. Force-release keeps working on a renamed release rather than failing to find it.
  • The two cooldown displays (wp-admin publish metabox, Release Management notice) use it too, so the UI shows the hold that is actually enforced.
  • For the normal case — header matching its tag, or a trunk release — both lookups land on the same row and nothing changes. Plugins whose stable tag legitimately differs from their version (e.g. tag 1.4 carrying version 1.4.0) were previously invisible to the cooldown entirely; they now resolve correctly.

Testing

tests/Current_Release_Resolution_Test.php (6 tests): a renamed header keeps the cooldown hold and keeps a block's hold, a rename pointing at another clean release's version still holds, block_release() lands the block on the stable tag's release, force-release serves the renamed version, and trunk releases still resolve by version. Verified the first three fail against the previous resolution.

🤖 Generated with Claude Code

A release block or cooldown was resolved by looking up the release named
after the plugin's Version header. Renaming the header inside a held tag
orphaned that lookup — no release found, so no block and no cooldown —
and the tag's rebuilt package shipped immediately under the new version.

Resolve the current release from the stable tag first, the source of the
served package, falling back to the version for trunk releases, which
are keyed trunk@{version}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 13, 2026 19:32

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

@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.

@obenland

Copy link
Copy Markdown
Member Author

@Luc45 This moves the version lookup from Stable Tag in plugin readme to the tag folder in SVN.

obenland and others added 2 commits August 13, 2026 15:23
Review fixes:
- get_current_release() no longer falls back to the header version for
  tagged plugins (only trunk-stable ones, which are keyed trunk@{version}),
  closing a fail-open that reverted tagged plugins to header-keyed
  resolution and removing a redundant get_release pass per plugin.
- block_release()'s already-served guard and force_release()'s audit note
  now key on the resolved release's version instead of the header meta.
- Both cooldown notices name the resolved release; the committer notice
  computes its window from compute_release_time() to match enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…der.

Second review round:
- get_current_release() matches the release strictly by tag instead of
  through Plugin_Directory::get_release(), whose loose == collapses
  numerically-equal tags ('1.4'/'1.40') and whose tag-first lookup lets a
  dormant tag shadow the trunk@{version} row.
- The block hold no longer gates on the header-derived is_new_version, so a
  header renamed to match the served version can't write the blocked tag live.
- block_release()'s already-served guard no longer reads empty === empty as
  a match.
- The cooldown UIs resolve and key off the stable-tag release: the metabox
  force-release staleness token is the release tag (not the header version),
  and both notices no longer bail on an empty header version, so an enforced
  hold stays visible and liftable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
block_release()'s already-served guard compared the resolved release's
version against update_source.version, while the release identity is the
tag: a re-commit into the served tag with a bumped header missed the
guard and recorded a block that held nothing. The guard now compares by
identity — the ref against the row's stable_tag for tagged releases,
falling back to the version for ref-less trunk-stable rows — via a
get_served_row() helper that get_served_version() now delegates to.

add_release() looked up the existing release through get_release(),
whose wp_list_filter() match is loose ('1.4' == '1.40'): the write could
merge onto a numerically-equal different release and, through the strict
dedupe below it, delete the genuine row. The lookup is now a strict tag
match, consistent with the dedupe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@obenland
obenland force-pushed the release-resolution-mismatched-header branch from 74c9040 to 7b89c7f Compare August 14, 2026 16:55
obenland and others added 3 commits August 14, 2026 12:03
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
render_cooldown_status() was the last copy of the cooldown-clock
arithmetic still on the old inline formula (latest confirmation, else
the release row's date), so the releases listing could show a serve
time that disagreed with the metabox and the frontend notice on the
same screen. It now derives the window from compute_release_time(),
the anchor update_single_plugin() actually gates on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For plugins without a version_date meta, compute_release_time() fell
back to post_modified, which moves on any post edit: a moderator note
or term change would re-arm the cooldown displays for a version live
for months. The fallback is now the release row's own date, which is
fixed at creation; post_modified remains only as a last resort when
there is no release row at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@obenland
obenland force-pushed the release-resolution-mismatched-header branch from 83ef9d7 to 07d3401 Compare August 14, 2026 17:09
obenland and others added 8 commits August 14, 2026 12:27
Its last production caller moved onto get_served_row() when the
already-served guard became identity-based; the tests now read the
row directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
render_cooldown_status() runs per row in the releases listing, but
compute_release_time() prefers the plugin-wide version_date, so after
a new commit every superseded row with a release_delay re-displayed a
pending serve time. A superseded row is never served; skip everything
but the release the stable tag resolves to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both walked get_releases() with the same strict tag comparison, with
nothing persisted in between; one fetch now captures the existing
release and removes every same-tag entry on the way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
render_cooldown_status() called get_current_release() per release row,
and that call walks all releases — O(N^2) over a plugin's release
count. The row in hand only needs its tag compared against the current
target, so the target derivation moves into get_current_release_tag()
and the walk disappears from the listing entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stable tag flipped to trunk at an unchanged version creates no
trunk@{version} release row, so get_current_release() returned false —
and a false release fails every gate open: release_delay reads 0,
is_release_blocked() reads false, and the row is rewritten to serve
trunk, whose bytes are where the blocked tag was cut from. The strict
ref lookup now falls back to the version-named, then version-carrying,
release; the fallback only fires on a miss, so it can never redirect a
hold away from a ref-resolved release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The listing's cooldown line compared rows against the strict
get_current_release_tag() while get_current_release() gained a
fallback, so in the trunk-flip state the listing suppressed the line
for the release the metabox showed as current. single_plugin() now
resolves get_current_release() once and passes it down the rows —
one walk per listing, one canonical notion — and the helper, whose
only remaining caller was the resolver itself, is inlined back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trunk's baseline-scan commit added get_served_release() — the same
row fetch this branch had introduced as get_served_row(). The trunk
name and docblock win; get_served_version() stays dropped, as its
last caller was the version-keyed guard this branch replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bazza bazza closed this in 85c6d29 Aug 14, 2026
obenland added a commit to obenland/wordpress.org that referenced this pull request Aug 14, 2026
A completed verdict was matched against the plugin's Version header before
its release was held, which is the lookup WordPress#807 replaced: the header is
author-controlled, so committing a rename into the scanned tag made the
scan's version no longer the plugin's and the block was refused. Blocking
now resolves the current release the way the updater does, from the stable
tag, and applies only when that release is the one the verdict examined.

The test environment gains WP_GANDALF_SCAN_SHARED_SECRET so the callback
route can be driven by hand; the PHPUnit suite defines it itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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