diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4453b7d3..763224d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,13 +20,14 @@ To create a new release: - Create a release branch named `release/vx.y.z`. -- Submit a release PR targeting the `master` branch: +- Submit a release PR: - Bump the version in `package.json.js`. - Run `npm run install-project` to update the `package.json` and `package-lock.json` files. - - The commit message should be of the form "Release vx.y.z" + - Create a new PR targeting the `master` branch. + - The commit message should be of the form "Release vx.y.z". - The title of the release PR should be of the form "Release vx.y.z" -- Once the PR is merged, create a new release: +- Once the PR is merged, create a new GitHub release: - Go the GitHub repo, and navigate to ["Releases"](https://github.com/fortanix/baklava/releases). - Click ["Draft a new release"](https://github.com/fortanix/baklava/releases/new). - Under "Choose a new tag", create a new tag of the form `vx.y.z`. diff --git a/package.json b/package.json index 8892f3c7..810a0ef0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "repl": "tsx", "plop": "NODE_OPTIONS=\"--import tsx\" plop", "import": "tsx scripts/import.ts", + "automate": "tsx scripts/automate.ts", "serve:dev": "vite --config=./vite.config.ts serve", "build": "vite --config=./vite.config.ts --emptyOutDir build", "storybook:serve": "storybook dev -p 6006", diff --git a/package.json.js b/package.json.js index 89ba80fe..868863e2 100644 --- a/package.json.js +++ b/package.json.js @@ -45,6 +45,7 @@ const packageConfig = { 'repl': 'tsx', 'plop': 'NODE_OPTIONS="--import tsx" plop', 'import': 'tsx scripts/import.ts', + 'automate': 'tsx scripts/automate.ts', // Library //'lib:build': '', diff --git a/scripts/automate.ts b/scripts/automate.ts new file mode 100644 index 00000000..b9ac9d12 --- /dev/null +++ b/scripts/automate.ts @@ -0,0 +1,167 @@ + +import { dedent } from 'ts-dedent'; +import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs/promises'; +import { exec } from 'node:child_process'; +import { AsyncLocalStorage } from 'node:async_hooks'; + + +// +// Setup +// + +type Logger = Pick; +type Services = { logger: Logger }; +const servicesStorage = new AsyncLocalStorage(); +const getServices = () => { + const services = servicesStorage.getStore(); + if (typeof services === 'undefined') { throw new Error(`Missing services`); } + return services; +}; + +type ScriptArgs = { + values: { + help?: undefined | boolean, + silent?: undefined | boolean, + }, + positionals: Array, +}; + + +// +// Common +// + +const getCurrentGitBranch = () => new Promise((resolve, reject) => { + return exec('git rev-parse --abbrev-ref HEAD', (err, stdout, stderr) => { + if (err) { + reject(`Failed to determine current git branch: ${err}`); + } else if (typeof stdout === 'string') { + resolve(stdout.trim()); + } + }); +}); + +const readPackageJson = async () => { + const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url)); + const packageJsonContent = (await fs.readFile(packageJsonPath)).toString(); + return JSON.parse(packageJsonContent); // May throw +}; + + +// +// Commands +// + +// Utility automation to prepopulate a URL with the parameters for a new release on GitHub. +export const runGithubCreateReleasePullRequest = async (args: ScriptArgs) => { + const { logger } = getServices(); + + const branchName = await getCurrentGitBranch(); + + const packageJson = await readPackageJson(); + const version = packageJson.version; + + // https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/using-query-parameters-to-create-a-pull-request + const prUrl = new URL(`https://github.com/fortanix/baklava/compare/master...${branchName}`); + prUrl.search = new URLSearchParams({ + quick_pull: '1', + title: `Release v${version}`, + labels: `new-release`, + body: `Update the version to v${version}.`, + }).toString(); + + logger.log('\n'); + logger.log(`Open the following URL in your browser to create the pull request:`); + logger.log(prUrl.toString()); + logger.log('\n'); +}; + +// Utility automation to prepopulate a URL with the parameters for a new release on GitHub. +export const runGithubCreateRelease = async (args: ScriptArgs) => { + const { logger } = getServices(); + + const packageJson = await readPackageJson(); + const version = packageJson.version; + const isPrerelease = ['alpha', 'beta', 'rc', 'pre'].some(qualifier => version.includes(`-${qualifier}`)); + + // https://docs.github.com/en/repositories/releasing-projects-on-github/automation-for-release-forms-with-query-parameters + const releaseUrl = new URL('https://github.com/fortanix/baklava/releases/new'); + releaseUrl.search = new URLSearchParams({ + tag: `v${version}`, + prerelease: isPrerelease ? '1' : '0', + title: `Release v${version}`, + }).toString(); + + logger.log('\n'); + logger.log(`Open the following URL in your browser, and add the release notes:`); + logger.log(releaseUrl.toString()); + logger.log('\n'); +}; + + +// +// Run +// + +const printUsage = () => { + const { logger } = getServices(); + + logger.info(dedent` + Usage: automate.ts <...args> + + Commands: + - github:create-release + `); +}; + +// Run the script with the given CLI arguments +export const run = async (argsRaw: Array): Promise => { + // Ref: https://exploringjs.com/nodejs-shell-scripting/ch_node-util-parseargs.html + const args = parseArgs({ + args: argsRaw, + allowPositionals: true, + options: { + help: { type: 'boolean', short: 'h' }, + silent: { type: 'boolean' }, + }, + }); + + // Services + const logger: Logger = { + info: args.values.silent ? () => {} : console.info, + error: console.error, + log: console.log, + }; + + await servicesStorage.run({ logger }, async () => { + const command: null | string = args.positionals[0] ?? null; + if (command === null || args.values.help) { + printUsage(); + return; + } + + const argsForCommand: ScriptArgs = { ...args, positionals: args.positionals.slice(1) }; + switch (command) { + case 'github:create-release-pr': await runGithubCreateReleasePullRequest(argsForCommand); break; + case 'github:create-release': await runGithubCreateRelease(argsForCommand); break; + default: + logger.error(`Unknown command '${command}'\n`); + printUsage(); + break; + } + }); +}; + +// Detect if this module is being run directly from the command line +const [_argExec, argScript, ...args] = process.argv; // First two arguments should be the executable + script +if (argScript && await fs.realpath(argScript) === fileURLToPath(import.meta.url)) { + try { + await run(args); + process.exit(0); + } catch (error: unknown) { + console.error(error); + process.exit(1); + } +}