Code Freeze CLI: Add version bump (#38036)

This commit is contained in:
Paul Sealock 2023-05-03 20:31:09 +12:00 committed by GitHub
parent ea58ae339a
commit 05452979df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 405 additions and 7 deletions

View File

@ -9,11 +9,13 @@ import { Command } from '@commander-js/extra-typings';
import { verifyDayCommand } from './verify-day';
import { milestoneCommand } from './milestone';
import { branchCommand } from './branch';
import { versionBumpCommand } from './version-bump';
const program = new Command( 'code-freeze' )
.description( 'Code freeze utilities' )
.addCommand( verifyDayCommand )
.addCommand( milestoneCommand )
.addCommand( branchCommand );
.addCommand( branchCommand )
.addCommand( versionBumpCommand );
export default program;

View File

@ -0,0 +1,26 @@
/**
* Internal dependencies
*/
import { stripPrereleaseParameters } from './lib/validate';
import {
updatePluginFile,
updateReadmeChangelog,
updateJSON,
updateClassPluginFile,
} from './lib/update';
export const bumpFiles = async ( tmpRepoPath, version ) => {
let nextVersion = version;
await updatePluginFile( tmpRepoPath, nextVersion );
// Any updated files besides the plugin file get a version stripped of prerelease parameters.
nextVersion = stripPrereleaseParameters( nextVersion );
// Bumping the dev version means updating the readme's changelog.
await updateReadmeChangelog( tmpRepoPath, nextVersion );
await updateJSON( 'composer', tmpRepoPath, nextVersion );
await updateJSON( 'package', tmpRepoPath, nextVersion );
await updateClassPluginFile( tmpRepoPath, nextVersion );
};

View File

@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import simpleGit from 'simple-git';
/**
* Internal dependencies
*/
import { Logger } from '../../../core/logger';
import { sparseCheckoutRepoShallow } from '../../../core/git';
import { octokitWithAuth } from '../../../core/github/api';
import { getEnvVar } from '../../../core/environment';
import { getMajorMinor } from '../../../core/version';
import { bumpFiles } from './bump';
import { validateArgs } from './lib/validate';
import { Options } from './types';
const genericErrorFunction = ( err ) => {
if ( err.git ) {
return err.git;
}
throw err;
};
export const versionBumpCommand = new Command( 'version-bump' )
.description( 'Bump versions ahead of new development cycle' )
.requiredOption( '-v, --version <version>', 'Version to bump to' )
.option(
'-o --owner <owner>',
'Repository owner. Default: woocommerce',
'woocommerce'
)
.option(
'-n --name <name>',
'Repository name. Default: woocommerce',
'woocommerce'
)
.option(
'-b --base <base>',
'Base branch to create the PR against. Default: trunk',
'trunk'
)
.action( async ( options: Options ) => {
const { owner, name, version, base } = options;
Logger.startTask(
`Making a temporary clone of '${ owner }/${ name }'`
);
const source = `github.com/${ owner }/${ name }`;
const token = getEnvVar( 'GITHUB_TOKEN', true );
const remote = `https://${ owner }:${ token }@${ source }`;
const tmpRepoPath = await sparseCheckoutRepoShallow(
remote,
'woocommerce',
[
'plugins/woocommerce/includes/class-woocommerce.php',
// All that's needed is the line above, but including these here for completeness.
'plugins/woocommerce/composer.json',
'plugins/woocommerce/package.json',
'plugins/woocommerce/readme.txt',
'plugins/woocommerce/woocommerce.php',
]
);
Logger.endTask();
Logger.notice(
`Temporary clone of '${ owner }/${ name }' created at ${ tmpRepoPath }`
);
await validateArgs( tmpRepoPath, version );
const git = simpleGit( {
baseDir: tmpRepoPath,
config: [ 'core.hooksPath=/dev/null' ],
} );
const majorMinor = getMajorMinor( version );
const branch = `prep/trunk-for-next-dev-cycle-${ majorMinor }`;
const exists = await git.raw( 'ls-remote', 'origin', branch );
if ( exists.trim().length > 0 ) {
Logger.error(
`Branch ${ branch } already exists. Run \`git push <remote> --delete ${ branch }\` and rerun this command.`
);
}
await git.checkoutBranch( branch, base ).catch( genericErrorFunction );
Logger.notice( `Bumping versions in ${ owner }/${ name }` );
bumpFiles( tmpRepoPath, version );
Logger.notice( 'Adding and committing changes' );
await git.add( '.' ).catch( genericErrorFunction );
await git
.commit( `Prep trunk for ${ majorMinor } cycle` )
.catch( genericErrorFunction );
Logger.notice( 'Pushing to Github' );
await git.push( 'origin', branch ).catch( ( e ) => {
Logger.error( e );
} );
try {
Logger.startTask( 'Creating a pull request' );
const pr = await octokitWithAuth.request(
'POST /repos/{owner}/{repo}/pulls',
{
owner,
repo: name,
title: `Prep trunk for ${ majorMinor } cycle`,
body: `This PR updates the versions in trunk to ${ version } for next development cycle.`,
head: branch,
base,
}
);
Logger.notice( `Pull request created: ${ pr.data.html_url }` );
Logger.endTask();
} catch ( e ) {
Logger.error( e );
}
} );

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { readFile, writeFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
/**
* Internal dependencies
*/
import { Logger } from '../../../../core/logger';
/**
* Update plugin readme changelog.
*
* @param tmpRepoPath cloned repo path
* @param nextVersion version to bump to
*/
export const updateReadmeChangelog = async (
tmpRepoPath: string,
nextVersion: string
): Promise< void > => {
const filePath = join( tmpRepoPath, 'plugins/woocommerce/readme.txt' );
try {
const readmeContents = await readFile( filePath, 'utf8' );
const updatedReadmeContents = readmeContents.replace(
/= \d+\.\d+\.\d+ \d\d\d\d-XX-XX =\n/m,
`= ${ nextVersion } ${ new Date().getFullYear() }-XX-XX =\n`
);
await writeFile( filePath, updatedReadmeContents );
} catch ( e ) {
Logger.error( e );
}
};
/**
* Update plugin class file.
*
* @param tmpRepoPath cloned repo path
* @param nextVersion version to bump to
*/
export const updateClassPluginFile = async (
tmpRepoPath: string,
nextVersion: string
): Promise< void > => {
const filePath = join(
tmpRepoPath,
`plugins/woocommerce/includes/class-woocommerce.php`
);
if ( ! existsSync( filePath ) ) {
Logger.error( "File 'class-woocommerce.php' does not exist." );
}
try {
const classPluginFileContents = await readFile( filePath, 'utf8' );
const updatedClassPluginFileContents = classPluginFileContents.replace(
/public \$version = '\d+\.\d+\.\d+';\n/m,
`public $version = '${ nextVersion }';\n`
);
await writeFile( filePath, updatedClassPluginFileContents );
} catch ( e ) {
Logger.error( e );
}
};
/**
* Update plugin JSON files.
*
* @param {string} type plugin to update
* @param {string} tmpRepoPath cloned repo path
* @param {string} nextVersion version to bump to
*/
export const updateJSON = async (
type: 'package' | 'composer',
tmpRepoPath: string,
nextVersion: string
): Promise< void > => {
const filePath = join( tmpRepoPath, `plugins/woocommerce/${ type }.json` );
try {
const composerJson = JSON.parse( await readFile( filePath, 'utf8' ) );
composerJson.version = nextVersion;
await writeFile(
filePath,
JSON.stringify( composerJson, null, '\t' ) + '\n'
);
} catch ( e ) {
Logger.error( e );
}
};
/**
* Update plugin main file.
*
* @param tmpRepoPath cloned repo path
* @param nextVersion version to bump to
*/
export const updatePluginFile = async (
tmpRepoPath: string,
nextVersion: string
): Promise< void > => {
const filePath = join( tmpRepoPath, `plugins/woocommerce/woocommerce.php` );
try {
const pluginFileContents = await readFile( filePath, 'utf8' );
const updatedPluginFileContents = pluginFileContents.replace(
/Version: \d+\.\d+\.\d+.*\n/m,
`Version: ${ nextVersion }\n`
);
await writeFile( filePath, updatedPluginFileContents );
} catch ( e ) {
Logger.error( e );
}
};

View File

@ -0,0 +1,84 @@
/**
* External dependencies
*/
import { valid, lt as versionLessThan, parse, prerelease } from 'semver';
import { join } from 'path';
import { readFile } from 'fs/promises';
/**
* Internal dependencies
*/
import { Logger } from '../../../../core/logger';
/**
* Get a plugin's current version.
*
* @param tmpRepoPath cloned repo path
*/
export const getCurrentVersion = async (
tmpRepoPath: string
): Promise< string | void > => {
const filePath = join( tmpRepoPath, `plugins/woocommerce/composer.json` );
try {
const composerJSON = JSON.parse( await readFile( filePath, 'utf8' ) );
return composerJSON.version;
} catch ( e ) {
Logger.error( e );
}
};
/**
* When given a prerelease version, return just the version.
*
* @param {string} prereleaseVersion version with prerelease params
* @return {string} version
*/
export const stripPrereleaseParameters = (
prereleaseVersion: string
): string => {
const parsedVersion = parse( prereleaseVersion );
if ( parsedVersion ) {
const { major, minor, patch } = parsedVersion;
return `${ major }.${ minor }.${ patch }`;
}
return prereleaseVersion;
};
/**
* Validate inputs.
*
* @param plugin plugin
* @param options options
* @param options.version version
*/
export const validateArgs = async (
tmpRepoPath: string,
version: string
): Promise< void > => {
const nextVersion = version;
if ( ! valid( nextVersion ) ) {
Logger.error(
'Invalid version supplied, please pass in a semantically correct version.'
);
}
const prereleaseParameters = prerelease( nextVersion );
const isDevVersionBump =
prereleaseParameters && prereleaseParameters[ 0 ] === 'dev';
if ( ! isDevVersionBump ) {
Logger.error(
`Version ${ nextVersion } is not a development version bump. This tool is only intended to bump development versions for the preparation of the next development cycle.`
);
}
const currentVersion = await getCurrentVersion( tmpRepoPath );
if ( ! currentVersion ) {
Logger.error( 'Unable to determine current version' );
} else if ( versionLessThan( nextVersion, currentVersion ) ) {
Logger.error(
'The version supplied is less than the current version, please supply a valid version.'
);
}
};

View File

@ -0,0 +1,6 @@
export type Options = {
owner?: string;
name?: string;
version?: string;
base?: string;
};

View File

@ -5,7 +5,7 @@ import { execSync } from 'child_process';
import { join } from 'path';
import { tmpdir } from 'os';
import { mkdirSync } from 'fs';
import { simpleGit } from 'simple-git';
import { simpleGit, TaskOptions } from 'simple-git';
import { v4 } from 'uuid';
import { mkdir, rm } from 'fs/promises';
import { URL } from 'node:url';
@ -75,15 +75,19 @@ const isUrl = ( maybeURL: string ) => {
/**
* Clone a git repository.
*
* @param {string} repoPath - the path (either URL or file path) to the repo to clone.
* @param {string} repoPath - the path (either URL or file path) to the repo to clone.
* @param {TaskOptions} options - options to pass to simple-git.
* @return {Promise<string>} the path to the cloned repo.
*/
export const cloneRepo = async ( repoPath: string ) => {
export const cloneRepo = async (
repoPath: string,
options: TaskOptions = {}
) => {
const folderPath = join( tmpdir(), 'code-analyzer-tmp', v4() );
mkdirSync( folderPath, { recursive: true } );
const git = simpleGit( { baseDir: folderPath } );
await git.clone( repoPath, folderPath );
await git.clone( repoPath, folderPath, options );
// If this is a local clone then the simplest way to maintain remote settings is to copy git config across
if ( ! isUrl( repoPath ) ) {
@ -96,18 +100,32 @@ export const cloneRepo = async ( repoPath: string ) => {
return folderPath;
};
/**
* Clone a git repository without history.
*
* @param {string} repoPath - the path (either URL or file path) to the repo to clone.
* @return {Promise<string>} the path to the cloned repo.
*/
export const cloneRepoShallow = async ( repoPath: string ) => {
return await cloneRepo( repoPath, { '--depth': 1 } );
};
/**
* Do a minimal sparse checkout of a github repo.
*
* @param {string} githubRepoUrl - the URL to the repo to checkout.
* @param {string} path - the path to checkout to.
* @param {Array<string>} directories - the files or directories to checkout.
* @param {string} base - the base branch to checkout from. Defaults to trunk.
* @param {TaskOptions} options - options to pass to simple-git.
* @return {Promise<string>} the path to the cloned repo.
*/
export const sparseCheckoutRepo = async (
githubRepoUrl: string,
path: string,
directories: string[]
directories: string[],
base = 'trunk',
options: TaskOptions = {}
) => {
const folderPath = join( tmpdir(), path );
@ -117,13 +135,37 @@ export const sparseCheckoutRepo = async (
const git = simpleGit( { baseDir: folderPath } );
await git.clone( githubRepoUrl, folderPath );
const cloneOptions = { '--no-checkout': null };
await git.clone( githubRepoUrl, folderPath, {
...cloneOptions,
...options,
} );
await git.raw( 'sparse-checkout', 'init', { '--cone': null } );
await git.raw( 'sparse-checkout', 'set', directories.join( ' ' ) );
await git.checkout( base );
return folderPath;
};
/**
* Do a minimal sparse checkout of a github repo without history.
*
* @param {string} githubRepoUrl - the URL to the repo to checkout.
* @param {string} path - the path to checkout to.
* @param {Array<string>} directories - the files or directories to checkout.
* @return {Promise<string>} the path to the cloned repo.
*/
export const sparseCheckoutRepoShallow = async (
githubRepoUrl: string,
path: string,
directories: string[],
base = 'trunk'
) => {
return await sparseCheckoutRepo( githubRepoUrl, path, directories, base, {
'--depth': 1,
} );
};
/**
* checkoutRef - checkout a ref in a git repo.
*