Code Freeze CLI: Add release changelog command (#38082)

This commit is contained in:
Paul Sealock 2023-05-12 10:07:41 +12:00 committed by GitHub
parent 68fe31abe4
commit 1188197a2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 359 additions and 72 deletions

View File

@ -3250,6 +3250,9 @@ importers:
'@octokit/graphql-schema':
specifier: ^14.1.0
version: 14.1.0
'@octokit/types':
specifier: ^9.2.0
version: 9.2.1
'@types/uuid':
specifier: ^9.0.1
version: 9.0.1
@ -3507,43 +3510,6 @@ importers:
specifier: ^5.70.0
version: 5.70.0(uglify-js@3.14.5)(webpack-cli@4.9.2)
tools/version-bump:
dependencies:
'@commander-js/extra-typings':
specifier: ^0.1.0
version: 0.1.0(commander@9.4.0)
'@woocommerce/monorepo-utils':
specifier: workspace:*
version: link:../monorepo-utils
chalk:
specifier: ^4.1.2
version: 4.1.2
commander:
specifier: 9.4.0
version: 9.4.0
express:
specifier: ^4.18.1
version: 4.18.1
ora:
specifier: ^5.4.1
version: 5.4.1
semver:
specifier: ^7.3.2
version: 7.3.7
ts-node:
specifier: ^10.9.1
version: 10.9.1(@types/node@16.18.21)(typescript@4.9.5)
devDependencies:
'@tsconfig/node16':
specifier: ^1.0.3
version: 1.0.3
'@types/express':
specifier: ^4.17.13
version: 4.17.14
typescript:
specifier: ^4.9.5
version: 4.9.5
packages:
/@actions/core@1.10.0:
@ -3871,7 +3837,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.3
'@jridgewell/trace-mapping': 0.3.17
'@jridgewell/trace-mapping': 0.3.16
commander: 4.1.1
convert-source-map: 1.8.0
fs-readdir-recursive: 1.1.0
@ -8720,9 +8686,9 @@ packages:
'@babel/core': 7.21.3
'@babel/helper-annotate-as-pure': 7.16.7
'@babel/helper-module-imports': 7.16.7
'@babel/helper-plugin-utils': 7.18.9
'@babel/helper-plugin-utils': 7.20.2
'@babel/plugin-syntax-jsx': 7.16.7(@babel/core@7.21.3)
'@babel/types': 7.17.0
'@babel/types': 7.21.3
dev: true
/@babel/plugin-transform-react-jsx@7.19.0(@babel/core@7.12.9):
@ -8951,8 +8917,8 @@ packages:
dependencies:
'@babel/core': 7.21.3
'@babel/helper-module-imports': 7.16.0
'@babel/helper-plugin-utils': 7.20.2
babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@7.21.3)
'@babel/helper-plugin-utils': 7.14.5
babel-plugin-polyfill-corejs2: 0.3.0(@babel/core@7.21.3)
babel-plugin-polyfill-corejs3: 0.4.0(@babel/core@7.21.3)
babel-plugin-polyfill-regenerator: 0.3.0(@babel/core@7.21.3)
semver: 6.3.0
@ -11969,7 +11935,6 @@ packages:
dependencies:
'@jridgewell/resolve-uri': 3.1.0
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@jridgewell/trace-mapping@0.3.17:
resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==}
@ -12494,7 +12459,7 @@ packages:
'@octokit/graphql': 4.8.0
'@octokit/request': 5.6.3
'@octokit/request-error': 2.1.0
'@octokit/types': 6.34.0
'@octokit/types': 6.41.0
before-after-hook: 2.2.2
universal-user-agent: 6.0.0
transitivePeerDependencies:
@ -12594,10 +12559,6 @@ packages:
- encoding
dev: false
/@octokit/openapi-types@11.2.0:
resolution: {integrity: sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA==}
dev: true
/@octokit/openapi-types@12.11.0:
resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==}
@ -12794,12 +12755,6 @@ packages:
- encoding
dev: false
/@octokit/types@6.34.0:
resolution: {integrity: sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==}
dependencies:
'@octokit/openapi-types': 11.2.0
dev: true
/@octokit/types@6.41.0:
resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==}
dependencies:
@ -20857,8 +20812,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
dependencies:
browserslist: 4.20.2
caniuse-lite: 1.0.30001352
browserslist: 4.21.4
caniuse-lite: 1.0.30001418
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@ -22401,6 +22356,7 @@ packages:
escalade: 3.1.1
node-releases: 2.0.6
picocolors: 1.0.0
dev: true
/browserslist@4.20.4:
resolution: {integrity: sha512-ok1d+1WpnU24XYN7oC3QWgTyMhY/avPJ/r9T00xxvUOIparA/gc+UPUMaod3i+G6s+nI2nUb9xZ5k794uIwShw==}

View File

@ -12,6 +12,7 @@
"@commander-js/extra-typings": "^10.0.3",
"@octokit/graphql": "4.8.0",
"@octokit/graphql-schema": "^14.1.0",
"@octokit/types": "^9.2.0",
"@types/uuid": "^9.0.1",
"chalk": "^4.1.2",
"commander": "^10.0.1",

View File

@ -0,0 +1,73 @@
/**
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import { execSync } from 'child_process';
/**
* Internal dependencies
*/
import { Logger } from '../../../core/logger';
import { cloneAuthenticatedRepo } from '../../../core/git';
import { updateTrunkChangelog, updateReleaseBranchChangelogs } from './lib';
import { Options } from './types';
export const changelogCommand = new Command( 'changelog' )
.description( 'Create a new release branch' )
.option(
'-o --owner <owner>',
'Repository owner. Default: woocommerce',
'woocommerce'
)
.option(
'-n --name <name>',
'Repository name. Default: woocommerce',
'woocommerce'
)
.option(
'-d --dev-repo-path <devRepoPath>',
'Path to existing repo. Use this option to avoid cloning a fresh repo for development purposes. Note that using this option assumes dependencies are already installed.'
)
.requiredOption( '-v, --version <version>', 'Version to bump to' )
.action( async ( options: Options ) => {
const { owner, name, version, devRepoPath } = options;
Logger.startTask(
`Making a temporary clone of '${ owner }/${ name }'`
);
// Use a supplied path, otherwise do a full clone of the repo, including history so that changelogs can be created with links to PRs.
const tmpRepoPath = devRepoPath
? devRepoPath
: await cloneAuthenticatedRepo( options, false );
Logger.endTask();
Logger.notice(
`Temporary clone of '${ owner }/${ name }' created at ${ tmpRepoPath }`
);
// When a devRepoPath is provided, assume that the dependencies are already installed.
if ( ! devRepoPath ) {
Logger.notice( `Installing dependencies in ${ tmpRepoPath }` );
execSync( 'pnpm install --filter woocommerce', {
cwd: tmpRepoPath,
stdio: 'inherit',
} );
}
const releaseBranch = `release/${ version }`;
// Update the release branch.
const releaseBranchChanges = await updateReleaseBranchChangelogs(
options,
tmpRepoPath,
releaseBranch
);
// Update trunk.
await updateTrunkChangelog(
options,
tmpRepoPath,
releaseBranch,
releaseBranchChanges
);
} );

View File

@ -0,0 +1,153 @@
/**
* External dependencies
*/
import simpleGit from 'simple-git';
import { execSync } from 'child_process';
/**
* Internal dependencies
*/
import { Logger } from '../../../../core/logger';
import { checkoutRemoteBranch } from '../../../../core/git';
import { createPullRequest } from '../../../../core/github/repo';
import { Options } from '../types';
/**
* Perform changelog operations on release branch by submitting a pull request. The release branch is a remote branch.
*
* @param {Object} options CLI options
* @param {string} tmpRepoPath temp repo path
* @param {string} releaseBranch release branch name. The release branch is a remote branch on Github.
* @return {Object} update data
*/
export const updateReleaseBranchChangelogs = async (
options: Options,
tmpRepoPath: string,
releaseBranch: string
): Promise< { deletionCommitHash: string; prNumber: number } > => {
const { owner, name, version } = options;
try {
await checkoutRemoteBranch( tmpRepoPath, releaseBranch );
} catch ( e ) {
if ( e.message.includes( "couldn't find remote ref" ) ) {
Logger.error(
`${ releaseBranch } does not exist on ${ owner }/${ name }.`
);
}
Logger.error( e );
}
const git = simpleGit( {
baseDir: tmpRepoPath,
config: [ 'core.hooksPath=/dev/null' ],
} );
const branch = `update/${ version }-changelog`;
try {
await git.checkout( {
'-b': null,
[ branch ]: null,
} );
Logger.notice( `Running the changelog script in ${ tmpRepoPath }` );
execSync(
`pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${ version }`,
{
cwd: tmpRepoPath,
stdio: 'inherit',
}
);
Logger.notice( `Committing deleted files in ${ tmpRepoPath }` );
//Checkout pnpm-lock.yaml to prevent issues in case of an out of date lockfile.
await git.checkout( 'pnpm-lock.yaml' );
await git.add( 'plugins/woocommerce/changelog/' );
await git.commit( `Delete changelog files from ${ version } release` );
const deletionCommitHash = await git.raw( [ 'rev-parse', 'HEAD' ] );
Logger.notice( `git deletion hash: ${ deletionCommitHash }` );
Logger.notice( `Updating readme.txt in ${ tmpRepoPath }` );
execSync( 'php .github/workflows/scripts/release-changelog.php', {
cwd: tmpRepoPath,
stdio: 'inherit',
} );
Logger.notice(
`Committing readme.txt changes in ${ branch } on ${ tmpRepoPath }`
);
await git.add( 'plugins/woocommerce/readme.txt' );
await git.commit(
`Update the readme files for the ${ version } release`
);
await git.push( 'origin', branch );
await git.checkout( '.' );
Logger.notice( `Creating PR for ${ branch }` );
const pullRequest = await createPullRequest( {
owner,
name,
title: `Release: Prepare the changelog for ${ version }`,
body: `This pull request was automatically generated during the code freeze to prepare the changelog for ${ version }`,
head: branch,
base: releaseBranch,
} );
Logger.notice( `Pull request created: ${ pullRequest.html_url }` );
return {
deletionCommitHash: deletionCommitHash.trim(),
prNumber: pullRequest.number,
};
} catch ( e ) {
Logger.error( e );
}
};
/**
* Perform changelog operations on trunk by submitting a pull request.
*
* @param {Object} options CLI options
* @param {string} tmpRepoPath temp repo path
* @param {string} releaseBranch release branch name
* @param {Object} releaseBranchChanges update data from updateReleaseBranchChangelogs
* @param {Object} releaseBranchChanges.deletionCommitHash commit from the changelog deletions in updateReleaseBranchChangelogs
* @param {Object} releaseBranchChanges.prNumber pr number created in updateReleaseBranchChangelogs
*/
export const updateTrunkChangelog = async (
options: Options,
tmpRepoPath: string,
releaseBranch: string,
releaseBranchChanges: { deletionCommitHash: string; prNumber: number }
): Promise< void > => {
const { owner, name, version } = options;
const { deletionCommitHash, prNumber } = releaseBranchChanges;
Logger.notice( `Deleting changelogs from trunk ${ tmpRepoPath }` );
const git = simpleGit( {
baseDir: tmpRepoPath,
config: [ 'core.hooksPath=/dev/null' ],
} );
try {
await git.checkout( 'trunk' );
const branch = `delete/${ version }-changelog`;
Logger.notice(
`Committing deletions in ${ branch } on ${ tmpRepoPath }`
);
await git.checkout( {
'-b': null,
[ branch ]: null,
} );
await git.raw( [ 'cherry-pick', deletionCommitHash ] );
await git.push( 'origin', branch );
Logger.notice( `Creating PR for ${ branch }` );
const pullRequest = await createPullRequest( {
owner,
name,
title: `Release: Remove ${ version } change files`,
body: `This pull request was automatically generated during the code freeze to remove the changefiles from ${ version } that are compiled into the \`${ releaseBranch }\` branch via #${ prNumber }`,
head: branch,
base: 'trunk',
} );
Logger.notice( `Pull request created: ${ pullRequest.html_url }` );
} catch ( e ) {
Logger.error( e );
}
};

View File

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

View File

@ -10,12 +10,14 @@ import { verifyDayCommand } from './verify-day';
import { milestoneCommand } from './milestone';
import { branchCommand } from './branch';
import { versionBumpCommand } from './version-bump';
import { changelogCommand } from './changelog';
const program = new Command( 'code-freeze' )
.description( 'Code freeze utilities' )
.addCommand( verifyDayCommand )
.addCommand( milestoneCommand )
.addCommand( branchCommand )
.addCommand( versionBumpCommand );
.addCommand( versionBumpCommand )
.addCommand( changelogCommand );
export default program;

View File

@ -9,7 +9,7 @@ import simpleGit from 'simple-git';
*/
import { Logger } from '../../../core/logger';
import { sparseCheckoutRepoShallow } from '../../../core/git';
import { octokitWithAuth } from '../../../core/github/api';
import { createPullRequest } from '../../../core/github/repo';
import { getEnvVar } from '../../../core/environment';
import { getMajorMinor } from '../../../core/version';
import { bumpFiles } from './bump';
@ -101,18 +101,16 @@ export const versionBumpCommand = new Command( 'version-bump' )
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 }` );
const pullRequest = await createPullRequest( {
owner,
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: ${ pullRequest.html_url }` );
Logger.endTask();
} catch ( e ) {
Logger.error( e );

View File

@ -10,6 +10,11 @@ import { v4 } from 'uuid';
import { mkdir, rm } from 'fs/promises';
import { URL } from 'node:url';
/**
* Internal dependencies
*/
import { getEnvVar } from './environment';
/**
* Get filename from patch
*
@ -110,6 +115,29 @@ export const cloneRepoShallow = async ( repoPath: string ) => {
return await cloneRepo( repoPath, { '--depth': 1 } );
};
/**
* Clone a repo using the authenticated token `GITHUB_TOKEN`. This allows the script to push branches to origin.
*
* @param {Object} options CLI options
* @param {string} options.owner repo owner
* @param {string} options.name repo name
* @param {boolean} isShallow whether to do a shallow clone or not.
* @return {string} temporary repo path
*/
export const cloneAuthenticatedRepo = async (
options: { owner: string; name: string },
isShallow = true
): Promise< string > => {
const { owner, name } = options;
const source = `github.com/${ owner }/${ name }`;
const token = getEnvVar( 'GITHUB_TOKEN' );
const remote = `https://${ owner }:${ token }@${ source }`;
return isShallow
? await cloneRepoShallow( remote )
: await cloneRepo( remote );
};
/**
* Do a minimal sparse checkout of a github repo.
*
@ -391,3 +419,23 @@ export const generateDiff = async (
return '';
}
};
/**
*
* @param {string} tmpRepoPath path to temporary repo
* @param {string} branch remote branch to checkout
*/
export const checkoutRemoteBranch = async (
tmpRepoPath: string,
branch: string
): Promise< void > => {
const git = simpleGit( {
baseDir: tmpRepoPath,
config: [ 'core.hooksPath=/dev/null' ],
} );
// When the clone is shallow, we need to call this before fetching.
await git.raw( [ 'remote', 'set-branches', '--add', 'origin', branch ] );
await git.raw( [ 'fetch', 'origin', branch ] );
await git.raw( [ 'checkout', '-b', branch, `origin/${ branch }` ] );
};

View File

@ -4,12 +4,17 @@
import { graphql } from '@octokit/graphql';
import { Octokit } from 'octokit';
/**
* Internal dependencies
*/
import { getEnvVar } from '../environment';
export const graphqlWithAuth = graphql.defaults( {
headers: {
authorization: `Bearer ${ process.env.GITHUB_TOKEN }`,
authorization: `Bearer ${ getEnvVar( 'GITHUB_TOKEN', true ) }`,
},
} );
export const octokitWithAuth = new Octokit( {
auth: process.env.GITHUB_TOKEN,
auth: getEnvVar( 'GITHUB_TOKEN', true ),
} );

View File

@ -7,6 +7,8 @@ import { Repository } from '@octokit/graphql-schema';
* Internal dependencies
*/
import { graphqlWithAuth, octokitWithAuth } from './api';
import { Logger } from '../logger';
import { PullRequestEndpointResponse } from './types';
export const getLatestGithubReleaseVersion = async ( options: {
owner?: string;
@ -128,3 +130,39 @@ export const deleteGithubBranch = async (
}
);
};
/**
* Create a pull request from branches on Github.
*
* @param {Object} options pull request options.
* @param {string} options.head branch name containing the changes you want to merge.
* @param {string} options.base branch name you want the changes pulled into.
* @param {string} options.owner repository owner.
* @param {string} options.name repository name.
* @param {string} options.title pull request title.
* @param {string} options.body pull request body.
* @return {Promise<object>} pull request data.
*/
export const createPullRequest = async ( options: {
head: string;
base: string;
owner: string;
name: string;
title: string;
body: string;
} ): Promise< PullRequestEndpointResponse[ 'data' ] > => {
const { head, base, owner, name, title, body } = options;
const pullRequest = await octokitWithAuth.request(
'POST /repos/{owner}/{repo}/pulls',
{
owner,
repo: name,
title,
body,
head,
base,
}
);
//@ts-ignore There is a type mismatch between the graphql schema and the response. pullRequest.data.head.repo.has_discussions is a boolean, but the graphql schema doesn't have that field.
return pullRequest.data;
};

View File

@ -0,0 +1,7 @@
/**
* External dependencies
*/
import { Endpoints } from '@octokit/types';
export type PullRequestEndpointResponse =
Endpoints[ 'POST /repos/{owner}/{repo}/pulls' ][ 'response' ];