diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97db743d6ae..55e4ad72e4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1753,6 +1753,33 @@ importers: tslib: 2.3.1 typescript: 4.4.4 + tools/package-release: + specifiers: + '@oclif/core': ^1 + '@oclif/plugin-help': ^5 + '@oclif/plugin-plugins': ^2.0.1 + '@types/node': ^16.9.4 + '@woocommerce/eslint-plugin': workspace:* + globby: ^11 + oclif: ^2 + shx: ^0.3.3 + ts-node: ^10.2.1 + tslib: ^2.3.1 + typescript: ^4.4.3 + dependencies: + '@oclif/core': 1.3.4 + '@oclif/plugin-help': 5.1.11 + '@oclif/plugin-plugins': 2.1.0 + devDependencies: + '@types/node': 16.10.3 + '@woocommerce/eslint-plugin': link:../../packages/js/eslint-plugin + globby: 11.1.0 + oclif: 2.4.5 + shx: 0.3.4 + ts-node: 10.5.0_506ca6ef959d35afcce359030b1bc9ff + tslib: 2.3.1 + typescript: 4.6.2 + packages: /@ampproject/remapping/2.1.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b2a35b24a2..2cdd101f7ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,4 @@ packages: - 'tools/monorepo-merge' - 'tools/code-analyzer' - 'tools/create-extension' + - 'tools/package-release' diff --git a/tools/package-release/.eslintignore b/tools/package-release/.eslintignore new file mode 100644 index 00000000000..9b1c8b133c9 --- /dev/null +++ b/tools/package-release/.eslintignore @@ -0,0 +1 @@ +/dist diff --git a/tools/package-release/.eslintrc b/tools/package-release/.eslintrc new file mode 100644 index 00000000000..b2a08066eea --- /dev/null +++ b/tools/package-release/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": [ "plugin:@woocommerce/eslint-plugin/recommended" ] +} diff --git a/tools/package-release/.gitignore b/tools/package-release/.gitignore new file mode 100644 index 00000000000..8cd56546876 --- /dev/null +++ b/tools/package-release/.gitignore @@ -0,0 +1 @@ +/oclif.manifest.json diff --git a/tools/package-release/bin/dev b/tools/package-release/bin/dev new file mode 100755 index 00000000000..bbc3f51d59a --- /dev/null +++ b/tools/package-release/bin/dev @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +const oclif = require('@oclif/core') + +const path = require('path') +const project = path.join(__dirname, '..', 'tsconfig.json') + +// In dev mode -> use ts-node and dev plugins +process.env.NODE_ENV = 'development' + +require('ts-node').register({project}) + +// In dev mode, always show stack traces +oclif.settings.debug = true; + +// Start the CLI +oclif.run().then(oclif.flush).catch(oclif.Errors.handle) diff --git a/tools/package-release/bin/dev.cmd b/tools/package-release/bin/dev.cmd new file mode 100755 index 00000000000..8ae2b12c192 --- /dev/null +++ b/tools/package-release/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\dev" %* diff --git a/tools/package-release/bin/run b/tools/package-release/bin/run new file mode 100755 index 00000000000..a7635de86ed --- /dev/null +++ b/tools/package-release/bin/run @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const oclif = require('@oclif/core') + +oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) diff --git a/tools/package-release/bin/run.cmd b/tools/package-release/bin/run.cmd new file mode 100755 index 00000000000..968fc30758e --- /dev/null +++ b/tools/package-release/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/tools/package-release/package.json b/tools/package-release/package.json new file mode 100644 index 00000000000..100c4d26f26 --- /dev/null +++ b/tools/package-release/package.json @@ -0,0 +1,60 @@ +{ + "name": "package-release", + "version": "0.1.0", + "description": "A tool to Monorepo JS packages.", + "author": "Automattic", + "bin": { + "package-release": "./bin/run" + }, + "homepage": "https://github.com/woocommerce/woocommerce", + "license": "GPLv2", + "main": "dist/index.js", + "repository": "woocommerce/woocommerce", + "files": [ + "/bin", + "/dist", + "/npm-shrinkwrap.json", + "/oclif.manifest.json" + ], + "dependencies": { + "@oclif/core": "^1", + "@oclif/plugin-help": "^5", + "@oclif/plugin-plugins": "^2.0.1" + }, + "devDependencies": { + "@types/node": "^16.9.4", + "@woocommerce/eslint-plugin": "workspace:*", + "globby": "^11", + "oclif": "^2", + "shx": "^0.3.3", + "ts-node": "^10.2.1", + "tslib": "^2.3.1", + "typescript": "^4.4.3" + }, + "oclif": { + "bin": "package-release", + "dirname": "package-release", + "commands": "./dist/commands", + "plugins": [ + "@oclif/plugin-help", + "@oclif/plugin-plugins" + ], + "topicSeparator": " ", + "topics": { + "package-release": { + "description": "Releases JS packages" + } + } + }, + "scripts": { + "build": "shx rm -rf dist && tsc -b", + "lint": "eslint . --ext .ts --config .eslintrc", + "postpack": "shx rm -f oclif.manifest.json", + "posttest": "pnpm lint", + "prepack": "pnpm build && oclif manifest" + }, + "engines": { + "node": ">=12.0.0" + }, + "types": "dist/index.d.ts" +} diff --git a/tools/package-release/src/changelogger.ts b/tools/package-release/src/changelogger.ts new file mode 100644 index 00000000000..7566f1206e0 --- /dev/null +++ b/tools/package-release/src/changelogger.ts @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import { execSync } from 'child_process'; +import { readdirSync } from 'fs'; +import { join } from 'path'; + +/** + * Internal dependencies + */ +import { getFilepathFromPackageName } from './validate'; + +/** + * Call changelogger's next version function to get the version for the next release. + * + * @param {string} name Package name. + * @return {string} Next release version. + */ +export const getNextVersion = ( name: string ) => { + try { + const cwd = getFilepathFromPackageName( name ); + return execSync( './vendor/bin/changelogger version next', { + cwd, + encoding: 'utf-8', + } ).trim(); + } catch ( e ) { + let message = ''; + if ( e instanceof Error ) { + message = e.message; + throw new Error( message ); + } + } +}; + +/** + * Call Changelogger's validate function on changelog entries. + * + * @param {string} name + * @return {Error|void} Output of changelogger exec. + */ +export const validateChangelogEntries = ( name: string ) => { + try { + const cwd = getFilepathFromPackageName( name ); + return execSync( './vendor/bin/changelogger validate', { + cwd, + encoding: 'utf-8', + } ); + } catch ( e ) { + let message = ''; + if ( e instanceof Error ) { + message = e.message; + throw new Error( message ); + } + } +}; + +/** + * Write the changelog. + * + * @param {string} name Package name. + */ +export const writeChangelog = ( name: string ) => { + try { + const cwd = getFilepathFromPackageName( name ); + execSync( './vendor/bin/changelogger write', { + cwd, + encoding: 'utf-8', + } ); + } catch ( e ) { + let message = ''; + if ( e instanceof Error ) { + message = e.message; + throw new Error( + message + ' - Package may not have changelog entries.' + ); + } + } +}; + +/** + * Determine if a package has changelogs to release. + * + * @param {string} name Package name. + * @return {boolean} If there are changelogs. + */ +export const hasChangelogs = ( name: string ): boolean | void => { + try { + const changelogDir = join( + getFilepathFromPackageName( name ), + 'changelog' + ); + const changelogDirContents = readdirSync( changelogDir, { + encoding: 'utf-8', + } ); + + return ( + changelogDirContents.filter( ( entry ) => entry !== '.gitkeep' ) + .length > 0 + ); + } catch ( e ) { + let message = ''; + if ( e instanceof Error ) { + message = e.message; + throw new Error( message ); + } + } +}; diff --git a/tools/package-release/src/commands/prepare/index.ts b/tools/package-release/src/commands/prepare/index.ts new file mode 100644 index 00000000000..5a0058a14bd --- /dev/null +++ b/tools/package-release/src/commands/prepare/index.ts @@ -0,0 +1,130 @@ +/** + * External dependencies + */ +import { CliUx, Command, Flags } from '@oclif/core'; +import { readFileSync, writeFileSync } from 'fs'; + +/** + * Internal dependencies + */ +import { + getAllPackges, + validatePackage, + getFilepathFromPackageName, +} from '../../validate'; +import { + getNextVersion, + validateChangelogEntries, + writeChangelog, + hasChangelogs, +} from '../../changelogger'; + +/** + * PackageRelease class + */ +export default class PackageRelease extends Command { + /** + * CLI description + */ + static description = 'Release Monorepo JS packages'; + + /** + * CLI arguments + */ + static args = [ + { + name: 'packages', + description: + 'Package to release, or packages to release separated by commas.', + required: false, + }, + ]; + + /** + * CLI flags. + */ + static flags = { + all: Flags.boolean( { + char: 'a', + default: false, + description: 'Perform prepare function on all packages.', + } ), + }; + + /** + * This method is called to execute the command + */ + async run(): Promise< void > { + const { args, flags } = await this.parse( PackageRelease ); + + if ( ! args.packages && ! flags.all ) { + this.error( 'No packages supplied.' ); + } + + if ( flags.all ) { + this.preparePackages( getAllPackges() ); + return; + } + + const packages = args.packages.split( ',' ); + + packages.forEach( ( name: string ) => + validatePackage( name, ( e: string ): void => this.error( e ) ) + ); + + this.preparePackages( packages ); + } + + /** + * Prepare packages for release by creating the changelog and bumping version. + * + * @param {Array} packages Packages to prepare. + */ + private preparePackages( packages: Array< string > ) { + packages.forEach( ( name ) => { + CliUx.ux.action.start( `Preparing ${ name }` ); + + try { + if ( hasChangelogs( name ) ) { + validateChangelogEntries( name ); + const nextVersion = getNextVersion( name ); + writeChangelog( name ); + if ( nextVersion ) { + this.bumpPackageVersion( name, nextVersion ); + } + } else { + this.log( `Skipping ${ name }, no changelogs available.` ); + } + } catch ( e ) { + if ( e instanceof Error ) { + this.error( e.message ); + } + } + + CliUx.ux.action.stop(); + } ); + } + + /** + * Update the version number in package.json. + * + * @param {string} name Package name. + * @param {string} version Next version. + */ + private bumpPackageVersion( name: string, version: string ) { + const filepath = getFilepathFromPackageName( name ); + const packageJsonFilepath = `${ filepath }/package.json`; + try { + const packageJson = JSON.parse( + readFileSync( packageJsonFilepath, 'utf8' ) + ); + packageJson.version = version; + writeFileSync( + packageJsonFilepath, + JSON.stringify( packageJson, null, '\t' ) + '\n' + ); + } catch ( e ) { + this.error( `Can't bump version for ${ name }.` ); + } + } +} diff --git a/tools/package-release/src/const.ts b/tools/package-release/src/const.ts new file mode 100644 index 00000000000..43ffa98342c --- /dev/null +++ b/tools/package-release/src/const.ts @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { dirname } from 'path'; + +// Escape from ./tools/package-release/src +export const MONOREPO_ROOT = dirname( dirname( dirname( __dirname ) ) ); + +// Packages that are not meant to be released by monorepo team for whatever reason. +export const excludedPackages = [ + '@woocommerce/admin-e2e-tests', + '@woocommerce/api', + '@woocommerce/api-core-tests', + '@woocommerce/e2e-core-tests', + '@woocommerce/e2e-environment', + '@woocommerce/e2e-utils', +]; diff --git a/tools/package-release/src/index.ts b/tools/package-release/src/index.ts new file mode 100644 index 00000000000..d620e709ab9 --- /dev/null +++ b/tools/package-release/src/index.ts @@ -0,0 +1 @@ +export { run } from '@oclif/core'; diff --git a/tools/package-release/src/validate.ts b/tools/package-release/src/validate.ts new file mode 100644 index 00000000000..923873dfc27 --- /dev/null +++ b/tools/package-release/src/validate.ts @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; + +/** + * Internal dependencies + */ +import { MONOREPO_ROOT, excludedPackages } from './const'; + +/** + * Get filepath for a given package name. + * + * @param {string} name package name. + * @return {string} Absolute path for the package. + */ +export const getFilepathFromPackageName = ( name: string ): string => + join( MONOREPO_ROOT, 'packages/js', name.replace( '@woocommerce', '' ) ); + +/** + * Check if package is valid and can be deployed to NPM. + * + * @param {string} name package name. + * @return {boolean} true if the package is private. + */ +export const isValidPackage = ( name: string ): boolean => { + const filepath = getFilepathFromPackageName( name ); + const packageJsonFilepath = `${ filepath }/package.json`; + const packageJsonExists = existsSync( packageJsonFilepath ); + if ( ! packageJsonExists ) { + return false; + } + const packageJson = JSON.parse( + readFileSync( packageJsonFilepath, 'utf8' ) + ); + + if ( name !== packageJson.name ) { + return false; + } + + const isPrivatePackage = !! packageJson.private; + + if ( isPrivatePackage ) { + return false; + } + + return true; +}; + +/** + * Validate package name. + * + * @param {string} name package name. + * @param {Function} error Error logging function. + */ +export const validatePackageName = ( + name: string, + error: ( s: string ) => void +) => { + const filepath = getFilepathFromPackageName( name ); + + try { + const exists = existsSync( filepath ); + if ( ! exists ) { + throw new Error(); + } + } catch ( e ) { + error( `${ name } does not exist as a package.` ); + } +}; + +/** + * Get all releaseable package names. + * + * @return {Array} Package names. + */ +export const getAllPackges = (): Array< string > => { + const jsPackageFolders = readdirSync( + join( MONOREPO_ROOT, 'packages/js' ), + { + encoding: 'utf-8', + } + ); + + return jsPackageFolders + .map( ( folder ) => '@woocommerce/' + folder ) + .filter( ( name ) => { + if ( excludedPackages.includes( name ) ) { + return false; + } + return isValidPackage( name ); + } ); +}; + +/** + * Validate a package. + * + * @param {string} name package name. + * @param {Function} error Error logging function. + */ +export const validatePackage = ( + name: string, + error: ( s: string ) => void +) => { + validatePackageName( name, error ); + + if ( ! isValidPackage( name ) ) { + error( + `${ name } is not a valid package. It may be private or incorrectly configured.` + ); + } +}; diff --git a/tools/package-release/tsconfig.json b/tools/package-release/tsconfig.json new file mode 100644 index 00000000000..cc26db8e5bf --- /dev/null +++ b/tools/package-release/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "declaration": true, + "importHelpers": true, + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "es2019", + "typeRoots": [ + "./node_modules/@types" + ], + }, + "include": [ + "src/**/*" + ] +}