Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bootstrap an automation tool #15699

Merged
merged 23 commits into from May 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
66 changes: 34 additions & 32 deletions bin/build-plugin-zip.sh
Expand Up @@ -28,40 +28,42 @@ warning () {

status "💃 Time to release Gutenberg 🕺"

# Make sure there are no changes in the working tree. Release builds should be
# traceable to a particular commit and reliably reproducible. (This is not
# totally true at the moment because we download nightly vendor scripts).
changed=
if ! git diff --exit-code > /dev/null; then
changed="file(s) modified"
elif ! git diff --cached --exit-code > /dev/null; then
changed="file(s) staged"
fi
if [ ! -z "$changed" ]; then
git status
error "ERROR: Cannot build plugin zip with dirty working tree. ☝️
Commit your changes and try again."
exit 1
fi

# Do a dry run of the repository reset. Prompting the user for a list of all
# files that will be removed should prevent them from losing important files!
status "Resetting the repository to pristine condition. ✨"
to_clean=$(git clean -xdf --dry-run)
if [ ! -z "$to_clean" ]; then
echo $to_clean
warning "🚨 About to delete everything above! Is this okay? 🚨"
echo -n "[y]es/[N]o: "
read answer
if [ "$answer" != "${answer#[Yy]}" ]; then
# Remove ignored files to reset repository to pristine condition. Previous
# test ensures that changed files abort the plugin build.
status "Cleaning working directory... 🛀"
git clean -xdf
else
error "Fair enough; aborting. Tidy up your repo and try again. 🙂"
if [ -z "$NO_CHECKS" ]; then
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside: I'm happy to see this change, and had planned to propose similarly, for benefit of gutenberg.run, where I implemented my own custom version of this script to skip these steps, as they'd been slow to execute in containers, and unnecessary for fresh clones.

# Make sure there are no changes in the working tree. Release builds should be
# traceable to a particular commit and reliably reproducible. (This is not
# totally true at the moment because we download nightly vendor scripts).
changed=
if ! git diff --exit-code > /dev/null; then
changed="file(s) modified"
elif ! git diff --cached --exit-code > /dev/null; then
changed="file(s) staged"
fi
if [ ! -z "$changed" ]; then
git status
error "ERROR: Cannot build plugin zip with dirty working tree. ☝️
Commit your changes and try again."
exit 1
fi

# Do a dry run of the repository reset. Prompting the user for a list of all
# files that will be removed should prevent them from losing important files!
status "Resetting the repository to pristine condition. ✨"
to_clean=$(git clean -xdf --dry-run)
if [ ! -z "$to_clean" ]; then
echo $to_clean
warning "🚨 About to delete everything above! Is this okay? 🚨"
echo -n "[y]es/[N]o: "
read answer
if [ "$answer" != "${answer#[Yy]}" ]; then
# Remove ignored files to reset repository to pristine condition. Previous
# test ensures that changed files abort the plugin build.
status "Cleaning working directory... 🛀"
git clean -xdf
else
error "Fair enough; aborting. Tidy up your repo and try again. 🙂"
exit 1
fi
fi
fi

# Download all vendor scripts
Expand Down
293 changes: 293 additions & 0 deletions bin/commander.js
@@ -0,0 +1,293 @@
#!/usr/bin/env node

/* eslint-disable no-console */

// Dependencies
const path = require( 'path' );
const program = require( 'commander' );
const inquirer = require( 'inquirer' );
const semver = require( 'semver' );
const chalk = require( 'chalk' );
const fs = require( 'fs-extra' );
const SimpleGit = require( 'simple-git/promise' );
const childProcess = require( 'child_process' );
const Octokit = require( '@octokit/rest' );
const os = require( 'os' );
const uuid = require( 'uuid/v4' );

// Common info
const workingDirectoryPath = path.join( os.tmpdir(), uuid() );
const packageJsonPath = workingDirectoryPath + '/package.json';
const packageLockPath = workingDirectoryPath + '/package-lock.json';
const pluginFilePath = workingDirectoryPath + '/gutenberg.php';
const gutenbergZipPath = workingDirectoryPath + '/gutenberg.zip';
const repoOwner = 'WordPress';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per testing instructions, if part of the point of making this a variable is for configurability, should we open up that configurability as an environment variable?

Suggested change
const repoOwner = 'WordPress';
const { REPO_OWNER: repoOwner = 'WordPress' } = process.env;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hesitant to introduce this now. I think there's value in it but down the road, we're going to touch SVN repos as well. Will we need an env variable for that repo as well. Ideally we'd have a consistent pattern to ease testing in fake environments. Maybe a single config file?

const repoURL = 'git@github.com:' + repoOwner + '/gutenberg.git';

// UI
const error = chalk.bold.red;
const warning = chalk.bold.keyword( 'orange' );
const success = chalk.bold.green;

// Utils

/**
* Asks the user for a confirmation to continue or abort otherwise
*
* @param {string} message Confirmation message.
* @param {boolean} isDefault Default reply.
* @param {string} abortMessage Abort message.
*/
async function askForConfirmationToContinue( message, isDefault = true, abortMessage = 'Aborting.' ) {
const { isReady } = await inquirer.prompt( [ {
type: 'confirm',
name: 'isReady',
default: isDefault,
message,
} ] );

if ( ! isReady ) {
console.log( error( '\n' + abortMessage ) );
process.exit( 1 );
}
}

/**
* Common logic wrapping a step in the process.
*
* @param {string} name Step name.
* @param {string} abortMessage Abort message.
* @param {function} handler Step logic.
*/
async function runStep( name, abortMessage, handler ) {
try {
await handler();
} catch ( exception ) {
console.log(
error( 'The following error happened during the "' + warning( name ) + '" step:' ) + '\n\n',
exception,
error( '\n\n' + abortMessage )
);

process.exit( 1 );
}
}

/**
* Command used to release an RC version of the plugin
*/
async function releasePluginRC() {
console.log(
chalk.bold( '💃 Time to release Gutenberg 🕺\n\n' ),
'Welcome! This tool is going to help you release a new RC version of the Gutenberg Plugin.\n',
'It goes throught different steps : creating the release branch, bumping the plugin version, tagging and creating the github release, building the zip...\n',
'To perform a release you\'ll have to be a member of the Gutenberg Core Team.\n'
);

// This is a variable that contains the abort message shown when the script is aborted.
let abortMessage = 'Aborting!';
await askForConfirmationToContinue( 'Ready to go? ' );

// Cloning the repository
await runStep( 'Cloning the repository', abortMessage, async () => {
console.log( '>> Cloning the repository' );
const simpleGit = SimpleGit();
await simpleGit.clone( repoURL, workingDirectoryPath, [ '--depth=1' ] );
console.log( '>> The gutenberg repository has been successfully cloned in the following temporary folder: ' + success( workingDirectoryPath ) );
} );

// Creating the release branch
const simpleGit = SimpleGit( workingDirectoryPath );
let nextVersion;
let nextVersionLabel;
let releaseBranch;
const packageJson = require( packageJsonPath );
const packageLock = require( packageLockPath );
await runStep( 'Creating the release branch', abortMessage, async () => {
const parsedVersion = semver.parse( packageJson.version );

// Follow the WordPress version guidelines to compute the version to be used
// By default, increase the "minor" number but if we reach 9, bump to the next major.
if ( parsedVersion.minor === 9 ) {
nextVersion = ( parsedVersion.major + 1 ) + '.0.0-rc.1';
releaseBranch = 'release/' + ( parsedVersion.major + 1 ) + '.0';
nextVersionLabel = ( parsedVersion.major + 1 ) + '.0.0 RC1';
} else {
nextVersion = parsedVersion.major + '.' + ( parsedVersion.minor + 1 ) + '.0-rc.1';
releaseBranch = 'release/' + parsedVersion.major + '.' + ( parsedVersion.minor + 1 );
nextVersionLabel = parsedVersion.major + '.' + ( parsedVersion.minor + 1 ) + '.0 RC1';
}
await askForConfirmationToContinue(
'The RC Version to be applied is ' + success( nextVersion ) + '. Proceed with the creation of the release branch?',
true,
abortMessage
);

// Creating the release branch
await simpleGit.checkoutLocalBranch( releaseBranch );
console.log( '>> The local release branch ' + success( releaseBranch ) + ' has been successfully created.' );
} );

// Bumping the version in the different files (package.json, package-lock.json, gutenberg.php)
let commitHash;
await runStep( 'Updating the plugin version', abortMessage, async () => {
const newPackageJson = {
...packageJson,
version: nextVersion,
};
fs.writeFileSync( packageJsonPath, JSON.stringify( newPackageJson, null, '\t' ) + '\n' );
const newPackageLock = {
...packageLock,
version: nextVersion,
};
fs.writeFileSync( packageLockPath, JSON.stringify( newPackageLock, null, '\t' ) + '\n' );
const content = fs.readFileSync( pluginFilePath, 'utf8' );
fs.writeFileSync( pluginFilePath, content.replace( ' * Version: ' + packageJson.version, ' * Version: ' + nextVersion ) );
console.log( '>> The plugin version has been updated successfully.' );

// Commit the version bump
await askForConfirmationToContinue(
'Please check the diff. Proceed with the version bump commit?',
true,
abortMessage
);
await simpleGit.add( [
packageJsonPath,
packageLockPath,
pluginFilePath,
] );
const commitData = await simpleGit.commit( 'Bump plugin version to ' + nextVersion );
commitHash = commitData.commit;
console.log( '>> The plugin version bump has been commited succesfully.' );
} );

// Plugin ZIP creation
await runStep( 'Plugin ZIP creation', abortMessage, async () => {
await askForConfirmationToContinue(
'Proceed and build the plugin zip? (It takes a few minutes)',
true,
abortMessage
);
childProcess.execSync( '/bin/bash bin/build-plugin-zip.sh', {
cwd: workingDirectoryPath,
env: {
NO_CHECKS: true,
PATH: process.env.PATH,
},
stdio: [ 'inherit', 'ignore', 'inherit' ],
} );

console.log( '>> The plugin zip has been built succesfully. Path: ' + success( gutenbergZipPath ) );
} );

// Creating the git tag
await runStep( 'Creating the git tag', abortMessage, async () => {
await askForConfirmationToContinue(
'Proceed with the creation of the git tag?',
true,
abortMessage
);
await simpleGit.addTag( 'v' + nextVersion );
console.log( '>> The ' + success( 'v' + nextVersion ) + ' tag has been created succesfully.' );
} );

await runStep( 'Pushing the release branch and the tag', abortMessage, async () => {
await askForConfirmationToContinue(
'The release branch and the tag are going to be pushed to the remote repository. Continue?',
true,
abortMessage
);
await simpleGit.push( 'origin', releaseBranch );
await simpleGit.pushTags( 'origin' );
} );
abortMessage = 'Aborting! Make sure to remove remote release branch and tag.';

// Creating the GitHub Release
let octokit;
let release;
await runStep( 'Creating the GitHub release', abortMessage, async () => {
await askForConfirmationToContinue(
'Proceed with the creation of the GitHub release?',
true,
abortMessage
);
const { changelog } = await inquirer.prompt( [ {
type: 'editor',
name: 'changelog',
message: 'Please provide the CHANGELOG of the release (markdown)',
} ] );

const { token } = await inquirer.prompt( [ {
type: 'input',
name: 'token',
message: 'Please provide a GitHub personal authentication token. Navigate to ' + success( 'https://github.com/settings/tokens/new?scopes=repo,admin:org,write:packages' ) + ' to create one.',
} ] );

octokit = new Octokit( {
auth: token,
} );

const releaseData = await octokit.repos.createRelease( {
owner: repoOwner,
repo: 'gutenberg',
tag_name: 'v' + nextVersion,
name: nextVersionLabel,
body: changelog,
prerelease: true,
} );
release = releaseData.data;

console.log( '>> The GitHub release has been created succesfully.' );
} );
abortMessage = 'Aborting! Make sure to remove the remote release branch, the git tag and the GitHub release.';

// Uploading the Gutenberg Zip to the release
await runStep( 'Uploading the plugin zip', abortMessage, async () => {
const filestats = fs.statSync( gutenbergZipPath );
await octokit.repos.uploadReleaseAsset( {
url: release.upload_url,
headers: {
'content-length': filestats.size,
'content-type': 'application/zip',
},
name: 'gutenberg.zip',
file: fs.createReadStream( gutenbergZipPath ),
} );
console.log( '>> The plugin zip has been succesfully uploaded.' );
} );
abortMessage = 'Aborting! Make sure to manually cherry-pick the ' + success( commitHash ) + ' commit to the master branch.';

// Cherry-picking the bump commit into master
await runStep( 'Cherry-picking the bump commit into master', abortMessage, async () => {
await askForConfirmationToContinue(
'The plugin RC is now released. Proceed with the version bump in the master branch?',
true,
abortMessage
);
await simpleGit.fetch();
await simpleGit.checkout( 'master' );
await simpleGit.reset( 'hard' );
await simpleGit.pull( 'origin', 'master' );
await simpleGit.raw( [ 'cherry-pick', commitHash ] );
await simpleGit.push( 'origin', 'master' );
} );

abortMessage = 'Aborting! The release is finished though.';
await runStep( 'Cleaning the temporary folder', abortMessage, async () => {
await fs.remove( workingDirectoryPath );
} );

console.log(
'\n>> 🎉 The Gutenberg ' + success( nextVersionLabel ) + ' has been successfully released.\n',
'You can access the Github release here: ' + success( release.html_url ) + '\n',
'Thanks for performing the release!'
);
}

program
.command( 'release-plugin-rc' )
.alias( 'rc' )
.description( 'Release an RC version of the plugin (supports only rc.1 for now)' )
.action( releasePluginRC );

program.parse( process.argv );