Skip to content

Unify the release cooldown (#650) and the Release CPT (#407) into one congruent publishing process #664

@dd32

Description

@dd32

Unify the release cooldown (#650) and the Release CPT (#407) into one congruent publishing process

Summary

We currently have two in-flight efforts that point at the same underlying redesign from different angles:

  • Plugins: Add a release delay between SVN commit of update, and publish #650 — release cooldown. Inserts a delay between an SVN commit and the new version being served, by deferring the write into the update_source table (and a cron + force-release path). Mitigates supply-chain risk by giving scanners and humans a window before a release reaches sites.
  • Add a release page #407 — Release page / Release CPT. Introduces a private plugin_release CPT (child of the plugin post), an internal API (add_release/update_release/update_releases/get_release/publish_release/maybe_backfill_releases), a plugins/v2/.../publish REST route, the plugin_manage_releases cap, and a "Releases" tab UI behind the show_release_beta flag.

This issue proposes treating them as one process: import into a release object first, and make "go live" an explicit promotion of that object onto the canonical plugin post. The cooldown then becomes "how long a release sits in pending before it auto-promotes," and #407's CPT becomes the durable record that replaces the releases postmeta.

Why merge them

The cooldown's hard problem is that the canonical plugin post is the single source for download (class-template.php download_link() reads stable_tag), the update API (class-api-update-updater.php reads version/stable_tag), the info API, and the rendered page — and the importer overwrites all of it in place (cli/class-import.php:404 + :426-578). #650 gates only the update_source write, so a fresh download and the plugin page still jump to the new, unvetted version immediately.

If instead the importer writes to a release object and a separate promote step copies the snapshot onto the canonical post, then gating promotion gates all four surfaces at once — download included — with no split-brain on the page. That is precisely the object #407 already models. The two PRs are the same redesign seen from two ends.

Proposed process (target state)

Phase A — Import → Release object. A commit parses SVN, builds ZIPs, and runs checks/scanners, writing the result onto a plugin_release CPT (draft for trunk, pending for a tagged release in cooldown). The canonical post is untouched; nothing user-visible changes. ZIPs are version-specific ({slug}.{version}.zip) and simply sit on disk, unlinked, until promotion.

Phase B — Promote. When the cooldown elapses (cron) or a reviewer force-releases, Plugin_Release::promote() applies the release's snapshot onto the canonical plugin post (the moved :368-578 logic), refreshes update_source, flips statuses (new → published, previous → superseded), re-fires wporg_plugins_imported (so i18n/Gandalf/PCP chain), and sends the "now live" email.

Status machine: draft → pending → published → superseded, with rejected/reverted branches. Exactly one published release per plugin = the current canonical state; revert = re-promote the previous published snapshot (instant, no rebuild).

The release CPT stores three buckets of metadata per version, kept forever: provenance (version, tag, committer, revision range, commit log — mostly already in #407), the canonical snapshot (sections, headers, requires/tested, assets, blocks, taxonomy sets — the fields the importer writes today), and lifecycle/audit (status, cooldown_until, captured release_delay, plugin_check_result, scan verdicts, promoted-by/at, force-release reason).

Compatibility is via a shim: reimplement Plugin_Directory::get_release(s)/add_release to read/write the CPT while returning the same array shape, with dual-read + lazy backfill (maybe_backfill_releases()), all behind the show_release_beta flag so import behaves exactly as today when off.

Benefits

  • Gates every surface, not just auto-update. Download, update API, info API, and the plugin page all hold on the previous version until promotion — closing the gap Plugins: Add a release delay between SVN commit of update, and publish #650 can't.
  • Net code removal. The deferred-update_source machinery, dynamic per-slug cron, release_time re-anchoring, and the force-release-rewrites-the-row path in Plugins: Add a release delay between SVN commit of update, and publish #650 collapse into "promote a pending release."
  • Scanners gate releases first-class. Gandalf/PCP/Plugin Check run in Phase A and write verdicts onto the pending release before it can promote.
  • Durable per-version history. Each release keeps its own checks, commit log, requires-matrix, and the exact readme/assets snapshot as shipped — queryable, instead of a single overwritten blob. Replaces the releases postmeta.
  • Trivial, recorded rollbacks. Revert re-promotes a prior snapshot; no rebuild, fully audited.
  • No closed-infra changes. update_source is still filled by our own code; the api.wordpress.org update-check endpoint and the download infra are untouched — the same property Plugins: Add a release delay between SVN commit of update, and publish #650 was protecting.

Risks / costs

  • Touches the hottest path in the directory. The importer is load-bearing; redirecting its writes and adding a promote step is higher-stakes than Plugins: Add a release delay between SVN commit of update, and publish #650's localized change. Mitigated by the feature flag + dual-read and a staged rollout (delay 0 first).
  • Snapshot fidelity. Promotion must reproduce exactly what the in-place importer wrote (:368-578), including taxonomy/term sets and asset/blocks bookkeeping. Missing a field = subtle drift between committed and served state. Needs careful field-mapping + tests.
  • Migration surface. Backfilling CPTs for every existing plugin, and the dual-read window, need care; historical releases won't have snapshots (acceptable — they render from canonical as today).
  • Two writers during transition. While the flag rolls out, some plugins use the new path and some the old; the shim must keep both correct.
  • Add a release page #407 is still draft-quality. It's behind a flag with TODOs (caps/checks/warnings in publish_release(), a noted release-page race condition) and a large UI surface; depending on its finish blocks this work (see paths below).
  • Open product calls: custom post statuses vs. release_status meta; per-slug cron vs. a sweep over pending; snapshot-at-import vs. re-export-at-promote; whether trunk-stable releases get a cooldown.

Two paths forward

Path A — Finish #407 first, then build the process on top

Land #407 as-is (CPT + internal API + Releases tab UI behind show_release_beta), then add the status machine, promote(), importer redirection, and cooldown in follow-ups. Pros: one coherent feature; the UI to observe/drive releases lands with the backend. Cons: couples this security-relevant work to finishing a large, still-draft UI PR (race condition, many blocks, TODOs); slower to the supply-chain benefit; bigger surface to review at once.

Path B — Extract the non-UI core of #407 now; leave the UI as a second stab

Pull just the backend primitives out of #407 — the plugin_release CPT, the Plugin_Release internal API, backfill, the publish route + cap — and land them minimal and headless. Build the import-redirect + promote() + cooldown directly on that, replacing #650. The Releases-tab UI (blocks, release page, the race condition) becomes a separate follow-up PR on top. Pros: decouples the security win from UI polish; smaller, safer reviews; the cooldown ships sooner; the UI can iterate without gating the backend. Cons: two PRs to coordinate; the headless period has no admin UI to observe releases (CLI/WP-CLI only); some rework of #407's commit history to split UI from core.

Recommendation: Path B. The cooldown is the time-sensitive, security-relevant piece; the CPT + promote backend is the minimal thing it needs; the UI is valuable but shouldn't gate it. Suggested phasing: (1) extract CPT + API headless behind the flag; (2) shim get_release(s) → CPT with dual-read + backfill (no behavior change); (3) add status machine + promote() with immediate auto-promote (delay 0, still no visible change); (4) redirect the importer into Phase A; (5) turn on the cooldown + force-release and retire #650; (6) land the Releases UI as its own PR.

References


Related meta-trac tickets this would resolve or advance

This redesign isn't only about the cooldown — modelling releases as first-class objects with an explicit promotion step unblocks or advances a cluster of long-standing Plugin Directory tickets. Grouped by how directly the plan addresses them.

Directly the same work (the redesign is the implementation)

Architecturally unblocked (the plan removes the core blocker)

  • #3236 — Allow for beta channels for plugins and themes — The blocker is stated in-ticket: "99% of the update functionality is built around the idea that there'll only ever be a single 'current release.'" The release CPT + status machine decouple "a release" from "the current release" (many release records, one published pointer), which is the prerequisite a beta track needs. Scope: this delivers the data model and removes the architectural blocker; serving a beta track to opt-in users via the update API remains a further step. Strong enabler, not a complete fix on its own.
  • #5903 — Warn when a plugin tag is made, but never released — Wants an alert "after X hours of the tag being created, but the release not being made." "Tagged but not promoted" is a first-class, timestamped pending/unpromoted release status here — directly queryable for that warning. The companion "you forgot to bump the version header" check fits the Phase-A check set. Cleanly resolved.

Strongly advanced by the per-release record + cooldown window

Phased rollouts — partial overlap

Secondary / partial

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions