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..36d753eb1d --- /dev/null +++ b/bin/plugin/lib/wporg.js @@ -0,0 +1,141 @@ +/** + * 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; + } + + /** + * 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 ) { + 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 ); +} );