Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion bin/plugin/commands/readme.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ 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
*
* @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 = [
Expand All @@ -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,
},
];

/**
Expand Down Expand Up @@ -80,6 +88,7 @@ exports.handler = async ( opt ) => {
await updateReadme( {
pluginSlugs,
token: opt.token,
force: opt.force,
} );
};

Expand Down Expand Up @@ -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, '../../../' );
Expand All @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions bin/plugin/commands/since.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ const { log, formats } = require( '../lib/logger' );
*/
const { plugins } = require( '../../../plugins.json' );
const { getReleaseMilestones } = require( './bump-versions' );
const { isVersionReleased } = require( '../lib/wporg' );

/**
* @typedef WPSinceCommandOptions
*
* @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 = [
Expand All @@ -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,
},
];

/**
Expand Down Expand Up @@ -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` );

Expand Down
21 changes: 19 additions & 2 deletions bin/plugin/commands/versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
* 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 = [
Expand All @@ -23,9 +25,15 @@
description: 'Plugin slug',
defaults: null,
},
{
argname: '-f, --force',
description:
'Verify versions without checking WordPress.org for already released versions.',
defaults: false,
},
];

/**

Check warning on line 36 in bin/plugin/commands/versions.js

View workflow job for this annotation

GitHub Actions / JS Lint

@throws should have a type
* Checks a plugin directory for the required versions.
*
* @throws Error When there are inconsistent versions.
Expand Down Expand Up @@ -140,7 +148,16 @@
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)`
Expand Down
141 changes: 141 additions & 0 deletions bin/plugin/lib/wporg.js
Original file line number Diff line number Diff line change
@@ -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<string, string>}|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<boolean>} 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,
};
53 changes: 53 additions & 0 deletions bin/plugin/lib/wporg.test.js
Original file line number Diff line number Diff line change
@@ -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 );
} );
Loading