From 364fd53161c53daba49366103d14301463c56f38 Mon Sep 17 00:00:00 2001 From: Rashed Hossain Date: Tue, 13 Oct 2026 17:28:08 +0600 Subject: [PATCH 1/2] CLI: Implement WordPress.org sanity checks in since, readme, and versions commands Fixes #1444. Add check to ensure that the plugin version detected locally is not already released on WordPress.org before running replacements in 'since' and changelog updates in 'readme'. Also update 'versions' to flag already-released versions, with an optional '-f, --force' flag to bypass the check when needed. --- bin/plugin/commands/readme.js | 27 ++++++- bin/plugin/commands/since.js | 23 ++++++ bin/plugin/commands/versions.js | 21 ++++- bin/plugin/lib/wporg.js | 135 ++++++++++++++++++++++++++++++++ bin/plugin/lib/wporg.test.js | 53 +++++++++++++ 5 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 bin/plugin/lib/wporg.js create mode 100644 bin/plugin/lib/wporg.test.js diff --git a/bin/plugin/commands/readme.js b/bin/plugin/commands/readme.js index 5cbd315927..ceb5dd695b 100644 --- a/bin/plugin/commands/readme.js +++ b/bin/plugin/commands/readme.js @@ -12,6 +12,7 @@ const config = require( '../config' ); const { getChangelog } = require( './changelog' ); const { getReleaseMilestones } = require( './bump-versions' ); const { plugins } = require( '../../../plugins.json' ); +const { isVersionReleased } = require( '../lib/wporg' ); /** * @typedef WPReadmeCommandOptions @@ -19,6 +20,7 @@ const { plugins } = require( '../../../plugins.json' ); * @property {string=} plugin Optional plugin slug to update a single plugin's readme. Defaults to all plugins with an open, dated release milestone. * @property {string=} token Optional personal GitHub access token. * @property {boolean=} all Update every plugin, ignoring release milestones (legacy behavior). + * @property {boolean=} force Update readme even if the version is already released on WordPress.org. */ exports.options = [ @@ -37,6 +39,12 @@ exports.options = [ 'Update all plugins, ignoring release milestones (legacy behavior). When combined with --plugin, that plugin is updated without checking for a milestone.', defaults: false, }, + { + argname: '-f, --force', + description: + 'Update readme even if the detected version is already released on WordPress.org.', + defaults: false, + }, ]; /** @@ -80,6 +88,7 @@ exports.handler = async ( opt ) => { await updateReadme( { pluginSlugs, token: opt.token, + force: opt.force, } ); }; @@ -303,7 +312,7 @@ function escapeRegExp( value ) { /** * Updates the `readme.txt` file with a specific release changelog. * - * @param {{pluginSlugs: string[], token?: string}} settings + * @param {{pluginSlugs: string[], token?: string, force?: boolean}} settings */ async function updateReadme( settings ) { const pluginRoot = path.resolve( __dirname, '../../../' ); @@ -320,6 +329,22 @@ async function updateReadme( settings ) { 'readme.txt' ); const stableTag = getStableTag( readmeFilePath ); + + if ( ! settings.force ) { + const alreadyReleased = await isVersionReleased( + pluginSlug, + stableTag + ); + if ( alreadyReleased ) { + log( + formats.warning( + `⚠ Skipping ${ pluginSlug }: version ${ stableTag } is already released on WordPress.org. Bump the version before running this command, or pass --force to run anyway.` + ) + ); + continue; + } + } + const changelog = await getChangelog( { owner: config.githubRepositoryOwner, repo: config.githubRepositoryName, diff --git a/bin/plugin/commands/since.js b/bin/plugin/commands/since.js index 5a77c7e863..8b0c2745ac 100644 --- a/bin/plugin/commands/since.js +++ b/bin/plugin/commands/since.js @@ -11,6 +11,7 @@ const { log, formats } = require( '../lib/logger' ); */ const { plugins } = require( '../../../plugins.json' ); const { getReleaseMilestones } = require( './bump-versions' ); +const { isVersionReleased } = require( '../lib/wporg' ); /** * @typedef WPSinceCommandOptions @@ -18,6 +19,7 @@ const { getReleaseMilestones } = require( './bump-versions' ); * @property {string=} plugin Plugin slug. * @property {string=} token Optional personal GitHub access token. * @property {boolean=} all Update every plugin, ignoring release milestones (legacy behavior). + * @property {boolean=} force Update plugins even if the version is already released on WordPress.org. */ exports.options = [ @@ -36,6 +38,12 @@ exports.options = [ 'Update all plugins, ignoring release milestones (legacy behavior). When combined with --plugin, that plugin is updated without checking for a milestone.', defaults: false, }, + { + argname: '-f, --force', + description: + 'Update plugins even if the detected version is already released on WordPress.org.', + defaults: false, + }, ]; /** @@ -99,6 +107,21 @@ exports.handler = async ( opt ) => { } const version = readmeContentMatches[ 1 ]; + if ( ! opt.force ) { + const alreadyReleased = await isVersionReleased( + pluginSlug, + version + ); + if ( alreadyReleased ) { + log( + formats.warning( + `⚠ Skipping ${ pluginSlug }: version ${ version } is already released on WordPress.org. Bump the version before running this command, or pass --force to run anyway.` + ) + ); + continue; + } + } + patterns.push( `${ pluginDirectory }/**/*.php` ); patterns.push( `${ pluginDirectory }/**/*.js` ); diff --git a/bin/plugin/commands/versions.js b/bin/plugin/commands/versions.js index fbfe1fbc71..d61b5e9576 100644 --- a/bin/plugin/commands/versions.js +++ b/bin/plugin/commands/versions.js @@ -10,11 +10,13 @@ const { log, formats } = require( '../lib/logger' ); * Internal dependencies */ const { plugins } = require( '../../../plugins.json' ); +const { isVersionReleased } = require( '../lib/wporg' ); /** * @typedef WPVersionsCommandOptions * - * @property {?string} plugin Plugin slug. + * @property {?string} plugin Plugin slug. + * @property {boolean=} force Skip WordPress.org check for already released versions. */ exports.options = [ @@ -23,6 +25,12 @@ exports.options = [ description: 'Plugin slug', defaults: null, }, + { + argname: '-f, --force', + description: + 'Verify versions without checking WordPress.org for already released versions.', + defaults: false, + }, ]; /** @@ -140,7 +148,16 @@ exports.handler = async ( opt ) => { const slug = path.basename( pluginDirectory ); try { const version = await checkPluginDirectory( pluginDirectory ); - if ( version.includes( '-' ) ) { + const isReleased = + ! opt.force && ( await isVersionReleased( slug, version ) ); + + if ( isReleased ) { + log( + formats.warning( + `⚠ ${ slug }: ${ version } (already released on WordPress.org)` + ) + ); + } else if ( version.includes( '-' ) ) { log( formats.warning( `⚠ ${ slug }: ${ version } (pre-release identifier is present)` diff --git a/bin/plugin/lib/wporg.js b/bin/plugin/lib/wporg.js new file mode 100644 index 0000000000..dc039151e4 --- /dev/null +++ b/bin/plugin/lib/wporg.js @@ -0,0 +1,135 @@ +/** + * Compares two semantic version strings with optional prerelease tags. + * + * @param {string} v1 First version string. + * @param {string} v2 Second version string. + * @return {number} 1 if v1 > v2, -1 if v1 < v2, 0 if v1 === v2. + */ +function compareVersions( v1, v2 ) { + if ( v1 === v2 ) { + return 0; + } + + const parseVersion = ( version ) => { + const match = version.match( /^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?$/ ); + if ( ! match ) { + return null; + } + return { + major: parseInt( match[ 1 ], 10 ), + minor: parseInt( match[ 2 ], 10 ), + patch: parseInt( match[ 3 ], 10 ), + prerelease: match[ 4 ] || null, + }; + }; + + const parsed1 = parseVersion( v1 ); + const parsed2 = parseVersion( v2 ); + + // Fallback to numeric-aware string comparison if not conforming to X.Y.Z(-prerelease). + if ( ! parsed1 || ! parsed2 ) { + return v1.localeCompare( v2, undefined, { numeric: true } ); + } + + if ( parsed1.major !== parsed2.major ) { + return parsed1.major > parsed2.major ? 1 : -1; + } + if ( parsed1.minor !== parsed2.minor ) { + return parsed1.minor > parsed2.minor ? 1 : -1; + } + if ( parsed1.patch !== parsed2.patch ) { + return parsed1.patch > parsed2.patch ? 1 : -1; + } + + // If one has a prerelease and the other doesn't, the one without is greater. + if ( parsed1.prerelease && ! parsed2.prerelease ) { + return -1; + } + if ( ! parsed1.prerelease && parsed2.prerelease ) { + return 1; + } + if ( parsed1.prerelease && parsed2.prerelease ) { + return parsed1.prerelease.localeCompare( + parsed2.prerelease, + undefined, + { + numeric: true, + } + ); + } + + return 0; +} + +/** + * Fetches plugin metadata from WordPress.org Plugins API. + * + * @param {string} pluginSlug Plugin slug. + * @return {Promise<{version?: string, versions?: Record}|null>} Plugin data or null if not found. + */ +async function getWpOrgPluginData( pluginSlug ) { + try { + const response = await fetch( + `https://api.wordpress.org/plugins/info/1.0/${ pluginSlug }.json` + ); + if ( response.status === 404 ) { + return null; + } + if ( ! response.ok ) { + throw new Error( + `Failed to fetch WordPress.org data for ${ pluginSlug }: ${ response.status } ${ response.statusText }` + ); + } + const data = await response.json(); + if ( data && data.error ) { + return null; + } + return data; + } catch ( error ) { + if ( + error instanceof Error && + error.message.includes( 'Failed to fetch WordPress.org data' ) + ) { + throw error; + } + // In case of network errors, rethrow to avoid silent false negatives. + throw new Error( + `Could not reach WordPress.org API to check ${ pluginSlug }: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +} + +/** + * Checks whether a given plugin version is already released on WordPress.org. + * + * @param {string} pluginSlug Plugin slug. + * @param {string} version Version string to check. + * @return {Promise} True if the version is already released, false otherwise. + */ +async function isVersionReleased( pluginSlug, version ) { + const data = await getWpOrgPluginData( pluginSlug ); + if ( ! data ) { + return false; + } + + if ( + data.versions && + Object.prototype.hasOwnProperty.call( data.versions, version ) + ) { + return true; + } + + if ( data.version && compareVersions( version, data.version ) <= 0 ) { + return true; + } + + return false; +} + +module.exports = { + compareVersions, + getWpOrgPluginData, + isVersionReleased, +}; diff --git a/bin/plugin/lib/wporg.test.js b/bin/plugin/lib/wporg.test.js new file mode 100644 index 0000000000..3fa3acd99d --- /dev/null +++ b/bin/plugin/lib/wporg.test.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +const test = require( 'node:test' ); +const assert = require( 'node:assert/strict' ); + +/** + * Internal dependencies + */ +const { + compareVersions, + getWpOrgPluginData, + isVersionReleased, +} = require( './wporg' ); + +test( 'compareVersions correctly compares version strings', () => { + assert.equal( compareVersions( '1.0.0', '1.0.0' ), 0 ); + assert.equal( compareVersions( '1.0.1', '1.0.0' ), 1 ); + assert.equal( compareVersions( '1.0.0', '1.0.1' ), -1 ); + assert.equal( compareVersions( '2.0.0', '1.9.9' ), 1 ); + assert.equal( compareVersions( '1.10.0', '1.2.0' ), 1 ); + + // Prerelease comparisons. + assert.equal( compareVersions( '1.0.0', '1.0.0-beta1' ), 1 ); + assert.equal( compareVersions( '1.0.0-beta1', '1.0.0' ), -1 ); + assert.equal( compareVersions( '1.0.0-beta2', '1.0.0-beta1' ), 1 ); + assert.equal( compareVersions( '1.0.0-beta1', '1.0.0-beta2' ), -1 ); + assert.equal( compareVersions( '1.0.0-beta10', '1.0.0-beta2' ), 1 ); +} ); + +test( 'getWpOrgPluginData returns null for non-existent plugins', async () => { + const data = await getWpOrgPluginData( + 'non-existent-plugin-slug-for-unit-test' + ); + assert.equal( data, null ); +} ); + +test( 'isVersionReleased identifies released and unreleased versions', async () => { + // A known released version of webp-uploads. + const released = await isVersionReleased( 'webp-uploads', '2.7.1' ); + assert.equal( released, true ); + + // A future version that has not been released yet. + const unreleased = await isVersionReleased( 'webp-uploads', '99.99.99' ); + assert.equal( unreleased, false ); + + // A plugin that does not exist on WordPress.org. + const nonExistent = await isVersionReleased( + 'non-existent-plugin-slug-for-unit-test', + '1.0.0' + ); + assert.equal( nonExistent, false ); +} ); From e842940616ad60937505af46ec076461f4e8d5d4 Mon Sep 17 00:00:00 2001 From: Rashed Hossain Date: Tue, 13 Oct 2026 17:54:54 +0600 Subject: [PATCH 2/2] Add JSDoc parameter type to parseVersion for TypeScript compilation --- bin/plugin/lib/wporg.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bin/plugin/lib/wporg.js b/bin/plugin/lib/wporg.js index dc039151e4..36d753eb1d 100644 --- a/bin/plugin/lib/wporg.js +++ b/bin/plugin/lib/wporg.js @@ -10,6 +10,12 @@ function compareVersions( v1, v2 ) { return 0; } + /** + * Parses a version string. + * + * @param {string} version Version string. + * @return {{major: number, minor: number, patch: number, prerelease: string|null}|null} Parsed version or null. + */ const parseVersion = ( version ) => { const match = version.match( /^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?$/ ); if ( ! match ) {